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.test >>{{ checksum "requirements_test.txt" }}<>-<<# parameters.test_all >>{{ checksum "requirements_test_all.txt" }}<> + - 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.test >>pip install -q --progress-bar off -r requirements_test.txt -c homeassistant/package_constraints.txt<> + <<# parameters.test_all >>pip install -q --progress-bar off -r requirements_test_all.txt -c homeassistant/package_constraints.txt<> + no_output_timeout: 15m + - save_cache: + paths: + - ./venv + key: v1-<< parameters.python >>-{{ checksum "homeassistant/package_constraints.txt" }}-<<# parameters.all >>{{ checksum "requirements_all.txt" }}<>-<<# parameters.test >>{{ checksum "requirements_test.txt" }}<>-<<# parameters.test_all >>{{ checksum "requirements_test_all.txt" }}<> + + 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('' + '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![Description image]({})'.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 -%} - - - - - - - - - - - - - -
- - {# #} - - - {% 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 @@ - \ 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 @@ - \ 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 @@ - \ 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 @@ - \ 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 @@ - \ 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 @@ - \ 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 @@ - \ 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 @@ - \ 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 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 @@ - \ 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![Ubicaci\u00f3 del bot\u00f3 al pont](/static/images/config_flows/config_homematicip_cloud.png)", + "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 ![Placering af knap p\u00e5 bridge](/static/images/config_flows/config_homematicip_cloud.png)", + "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![Position des Tasters auf dem AP](/static/images/config_flows/config_homematicip_cloud.png)", + "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![Location of button on bridge](/static/images/config_flows/config_homematicip_cloud.png)", + "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![Ubicaci\u00f3n del bot\u00f3n en el puente](/static/images/config_flows/config_homematicip_cloud.png)", + "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 ![Emplacement du bouton sur le pont](/static/images/config_flows/config_homematicip_cloud.png)", + "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![\u05de\u05d9\u05e7\u05d5\u05dd \u05d4\u05db\u05e4\u05ea\u05d5\u05e8 \u05d1\u05de\u05d2\u05e9\u05e8](/static/images/config_flows/config_homematicip_cloud.png)", + "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 ![Posizione del pulsante sul bridge](/static/images/config_flows/config_homematicip_cloud.png)", + "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![\ube0c\ub9bf\uc9c0\uc758 \ubc84\ud2bc \uc704\uce58 \ubcf4\uae30](/static/images/config_flows/config_homematicip_cloud.png)", + "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![Locatie van knop op bridge](/static/images/config_flows/\nconfig_homematicip_cloud.png)", + "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![Plassering av knapp p\u00e5 bridge](/static/images/config_flows/config_homematicip_cloud.png)", + "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![Location of button on bridge](/static/images/config_flows/config_homematicip_cloud.png)", + "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![Location of button on bridge](/static/images/config_flows/config_homematicip_cloud.png)", + "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![Localiza\u00e7\u00e3o do bot\u00e3o na bridge](/ static/images/config_flows/config_homematicip_cloud.png)", + "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 ![\u0420\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u043a\u043d\u043e\u043f\u043a\u0438](/static/images/config_flows/config_homematicip_cloud.png)", + "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![Location of button on bridge](/static/images/config_flows/config_homematicip_cloud.png)", + "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 ![Placering av knappen p\u00e5 bryggan](/static/images/config_flows/config_homematicip_cloud.png)", + "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![\u6a4b\u63a5\u5668\u4e0a\u7684\u6309\u9215\u4f4d\u7f6e](/static/images/config_flows/config_homematicip_cloud.png)", + "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![Location of button on bridge](/static/images/config_flows/config_homematicip_cloud.png)" + } + }, + "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