diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index 294b5ab1db915..0000000000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,272 +0,0 @@ -# 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 deleted file mode 100644 index be739b6180983..0000000000000 --- a/.codecov.yml +++ /dev/null @@ -1,16 +0,0 @@ -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 2b5f328466cdf..e36d6b252bd78 100644 --- a/.coveragerc +++ b/.coveragerc @@ -5,21 +5,26 @@ omit = homeassistant/__main__.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/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/airly/__init__.py + homeassistant/components/airly/air_quality.py + homeassistant/components/airly/sensor.py + homeassistant/components/airly/const.py + homeassistant/components/airvisual/__init__.py + homeassistant/components/airvisual/air_quality.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 @@ -27,27 +32,40 @@ omit = 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/apache_kafka/* homeassistant/components/apcupsd/* homeassistant/components/apple_tv/* homeassistant/components/aqualogic/* homeassistant/components/aquostv/media_player.py + homeassistant/components/arcam_fmj/media_player.py + homeassistant/components/arcam_fmj/__init__.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/arris_tg2492lg/* 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/atag/__init__.py + homeassistant/components/atag/climate.py + homeassistant/components/atag/sensor.py + homeassistant/components/atag/water_heater.py + homeassistant/components/aten_pe/* + homeassistant/components/atome/* + homeassistant/components/aurora_abb_powerone/sensor.py homeassistant/components/automatic/device_tracker.py + homeassistant/components/avea/light.py homeassistant/components/avion/light.py + homeassistant/components/avri/sensor.py + homeassistant/components/azure_event_hub/* + homeassistant/components/azure_service_bus/* homeassistant/components/baidu/tts.py + homeassistant/components/beewi_smartclim/sensor.py homeassistant/components/bbb_gpio/* homeassistant/components/bbox/device_tracker.py homeassistant/components/bbox/sensor.py @@ -59,16 +77,20 @@ omit = 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/bluesound/* + homeassistant/components/bluetooth_tracker/* homeassistant/components/bme280/sensor.py homeassistant/components/bme680/sensor.py + homeassistant/components/bmp280/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/__init__.py + homeassistant/components/braviatv/const.py homeassistant/components/braviatv/media_player.py + homeassistant/components/broadlink/const.py + homeassistant/components/broadlink/remote.py homeassistant/components/broadlink/sensor.py homeassistant/components/broadlink/switch.py homeassistant/components/brottsplatskartan/sensor.py @@ -77,17 +99,17 @@ omit = homeassistant/components/bt_home_hub_5/device_tracker.py homeassistant/components/bt_smarthub/device_tracker.py homeassistant/components/buienradar/sensor.py + homeassistant/components/buienradar/util.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/cert_expiry/helper.py + homeassistant/components/channels/* 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 @@ -101,7 +123,9 @@ omit = homeassistant/components/comfoconnect/* homeassistant/components/concord232/alarm_control_panel.py homeassistant/components/concord232/binary_sensor.py + homeassistant/components/coolmaster/__init__.py homeassistant/components/coolmaster/climate.py + homeassistant/components/coolmaster/const.py homeassistant/components/cppm_tracker/device_tracker.py homeassistant/components/cpuspeed/sensor.py homeassistant/components/crimereports/sensor.py @@ -113,6 +137,7 @@ omit = homeassistant/components/ddwrt/device_tracker.py homeassistant/components/decora/light.py homeassistant/components/decora_wifi/light.py + homeassistant/components/delijn/* homeassistant/components/deluge/sensor.py homeassistant/components/deluge/switch.py homeassistant/components/denon/media_player.py @@ -121,7 +146,6 @@ omit = 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 @@ -130,28 +154,35 @@ omit = homeassistant/components/dlna_dmr/media_player.py homeassistant/components/dnsip/sensor.py homeassistant/components/dominos/* + homeassistant/components/doods/* homeassistant/components/doorbird/* homeassistant/components/dovado/* homeassistant/components/downloader/* + homeassistant/components/dsmr_reader/* 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/ecobee/__init__.py + homeassistant/components/ecobee/binary_sensor.py + homeassistant/components/ecobee/climate.py + homeassistant/components/ecobee/notify.py + homeassistant/components/ecobee/sensor.py + homeassistant/components/ecobee/weather.py + homeassistant/components/econet/* homeassistant/components/ecovacs/* + homeassistant/components/edl21/* 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/elv/* homeassistant/components/emby/media_player.py homeassistant/components/emoncms/sensor.py homeassistant/components/emoncms_history/* @@ -160,9 +191,11 @@ omit = 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/const.py homeassistant/components/epson/media_player.py homeassistant/components/epsonworkforce/sensor.py homeassistant/components/eq3btsmart/climate.py @@ -171,6 +204,7 @@ omit = 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 @@ -180,59 +214,74 @@ omit = homeassistant/components/eufy/* homeassistant/components/everlights/light.py homeassistant/components/evohome/* + homeassistant/components/ezviz/* 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/fleetgo/device_tracker.py homeassistant/components/flexit/climate.py homeassistant/components/flic/binary_sensor.py homeassistant/components/flock/notify.py + homeassistant/components/flume/* + homeassistant/components/flunearyou/__init__.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/fortios/device_tracker.py + homeassistant/components/fortigate/* homeassistant/components/foscam/camera.py + homeassistant/components/foscam/const.py homeassistant/components/foursquare/* homeassistant/components/free_mobile/notify.py - homeassistant/components/freebox/* + homeassistant/components/freebox/__init__.py + homeassistant/components/freebox/device_tracker.py + homeassistant/components/freebox/router.py + homeassistant/components/freebox/sensor.py + homeassistant/components/freebox/switch.py 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/fronius/sensor.py homeassistant/components/frontier_silicon/media_player.py homeassistant/components/futurenow/light.py homeassistant/components/garadget/cover.py + homeassistant/components/garmin_connect/__init__.py + homeassistant/components/garmin_connect/const.py + homeassistant/components/garmin_connect/sensor.py homeassistant/components/gc100/* homeassistant/components/geniushub/* homeassistant/components/gearbest/sensor.py homeassistant/components/geizhals/sensor.py + homeassistant/components/gios/__init__.py + homeassistant/components/gios/air_quality.py homeassistant/components/github/sensor.py homeassistant/components/gitlab_ci/sensor.py homeassistant/components/gitter/sensor.py + homeassistant/components/glances/__init__.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/growatt_server/sensor.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 @@ -240,36 +289,49 @@ omit = 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/harmony/* 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/hisense_aehw4a1/* homeassistant/components/hitron_coda/device_tracker.py homeassistant/components/hive/* homeassistant/components/hlk_sw16/* - homeassistant/components/homekit_controller/* 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/__init__.py homeassistant/components/hunterdouglas_powerview/scene.py + homeassistant/components/hunterdouglas_powerview/sensor.py + homeassistant/components/hunterdouglas_powerview/cover.py + homeassistant/components/hunterdouglas_powerview/entity.py homeassistant/components/hydrawise/* homeassistant/components/hyperion/light.py homeassistant/components/ialarm/alarm_control_panel.py + homeassistant/components/iammeter/sensor.py + homeassistant/components/iaqualink/binary_sensor.py + homeassistant/components/iaqualink/climate.py + homeassistant/components/iaqualink/light.py + homeassistant/components/iaqualink/sensor.py + homeassistant/components/iaqualink/switch.py + homeassistant/components/icloud/__init__.py + homeassistant/components/icloud/account.py homeassistant/components/icloud/device_tracker.py + homeassistant/components/icloud/sensor.py + homeassistant/components/izone/climate.py + homeassistant/components/izone/discovery.py + homeassistant/components/izone/__init__.py homeassistant/components/idteck_prox/* homeassistant/components/ifttt/* homeassistant/components/iglo/light.py @@ -279,6 +341,7 @@ omit = homeassistant/components/influxdb/sensor.py homeassistant/components/insteon/* homeassistant/components/incomfort/* + homeassistant/components/intesishome/* homeassistant/components/ios/* homeassistant/components/iota/* homeassistant/components/iperf3/* @@ -290,8 +353,11 @@ omit = homeassistant/components/itunes/media_player.py homeassistant/components/joaoapps_join/* homeassistant/components/juicenet/* + homeassistant/components/kaiterra/* homeassistant/components/kankun/switch.py + homeassistant/components/keba/* homeassistant/components/keenetic_ndms2/device_tracker.py + homeassistant/components/kef/* homeassistant/components/keyboard/* homeassistant/components/keyboard_remote/* homeassistant/components/kira/* @@ -299,6 +365,8 @@ omit = homeassistant/components/knx/* homeassistant/components/knx/climate.py homeassistant/components/knx/cover.py + homeassistant/components/kodi/__init__.py + homeassistant/components/kodi/const.py homeassistant/components/kodi/media_player.py homeassistant/components/kodi/notify.py homeassistant/components/konnected/* @@ -311,18 +379,18 @@ omit = 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/__init__.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 @@ -344,33 +412,49 @@ omit = 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/melcloud/__init__.py + homeassistant/components/melcloud/climate.py + homeassistant/components/melcloud/const.py + homeassistant/components/melcloud/sensor.py + homeassistant/components/melcloud/water_heater.py homeassistant/components/message_bird/notify.py homeassistant/components/met/weather.py - homeassistant/components/meteo_france/* + homeassistant/components/meteo_france/__init__.py + homeassistant/components/meteo_france/const.py + homeassistant/components/meteo_france/sensor.py + homeassistant/components/meteo_france/weather.py 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/hub.py homeassistant/components/mikrotik/device_tracker.py homeassistant/components/mill/climate.py + homeassistant/components/mill/const.py + homeassistant/components/minecraft_server/__init__.py + homeassistant/components/minecraft_server/binary_sensor.py + homeassistant/components/minecraft_server/const.py + homeassistant/components/minecraft_server/helpers.py + homeassistant/components/minecraft_server/sensor.py + homeassistant/components/minio/* 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/msteams/notify.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 @@ -378,23 +462,34 @@ omit = homeassistant/components/n26/* homeassistant/components/nad/media_player.py homeassistant/components/nanoleaf/light.py - homeassistant/components/neato/* + homeassistant/components/neato/camera.py + homeassistant/components/neato/sensor.py + homeassistant/components/neato/switch.py + homeassistant/components/neato/vacuum.py 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/netatmo/__init__.py + homeassistant/components/netatmo/api.py + homeassistant/components/netatmo/camera.py + homeassistant/components/netatmo/climate.py + homeassistant/components/netatmo/const.py + homeassistant/components/netatmo/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/nextcloud/* 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/notion/__init__.py + homeassistant/components/notion/binary_sensor.py + homeassistant/components/notion/sensor.py homeassistant/components/noaa_tides/sensor.py homeassistant/components/norway_air/air_quality.py homeassistant/components/nsw_fuel_station/sensor.py @@ -402,13 +497,17 @@ omit = homeassistant/components/nuki/lock.py homeassistant/components/nut/sensor.py homeassistant/components/nx584/alarm_control_panel.py + homeassistant/components/nzbget/__init__.py homeassistant/components/nzbget/sensor.py + homeassistant/components/obihai/* homeassistant/components/octoprint/* homeassistant/components/oem/climate.py homeassistant/components/oasa_telematics/sensor.py homeassistant/components/ohmconnect/sensor.py + homeassistant/components/ombi/* homeassistant/components/onewire/sensor.py homeassistant/components/onkyo/media_player.py + homeassistant/components/onvif/__init__.py homeassistant/components/onvif/camera.py homeassistant/components/opencv/* homeassistant/components/openevse/sensor.py @@ -417,24 +516,31 @@ omit = homeassistant/components/openhome/media_player.py homeassistant/components/opensensemap/air_quality.py homeassistant/components/opensky/sensor.py - homeassistant/components/opentherm_gw/* + homeassistant/components/opentherm_gw/__init__.py + homeassistant/components/opentherm_gw/binary_sensor.py + homeassistant/components/opentherm_gw/climate.py + homeassistant/components/opentherm_gw/sensor.py 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/opnsense/* homeassistant/components/opple/light.py homeassistant/components/orangepi_gpio/* + homeassistant/components/oru/* 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/pcal9535a/* homeassistant/components/pencom/switch.py homeassistant/components/philips_js/media_player.py homeassistant/components/pi_hole/sensor.py + homeassistant/components/pi4ioe5v9xxxx/binary_sensor.py + homeassistant/components/pi4ioe5v9xxxx/switch.py homeassistant/components/picotts/tts.py homeassistant/components/piglow/light.py homeassistant/components/pilight/* @@ -442,24 +548,23 @@ omit = homeassistant/components/ping/device_tracker.py homeassistant/components/pioneer/media_player.py homeassistant/components/pjlink/media_player.py + homeassistant/components/plaato/* homeassistant/components/plex/media_player.py homeassistant/components/plex/sensor.py + homeassistant/components/plugwise/* 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/proxmoxve/* 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 @@ -468,7 +573,7 @@ omit = homeassistant/components/qnap/sensor.py homeassistant/components/qrcode/image_processing.py homeassistant/components/quantum_gateway/device_tracker.py - homeassistant/components/qwikswitch/* + homeassistant/components/qvr_pro/* homeassistant/components/rachio/* homeassistant/components/radarr/sensor.py homeassistant/components/radiotherm/climate.py @@ -480,6 +585,7 @@ omit = homeassistant/components/rainmachine/binary_sensor.py homeassistant/components/rainmachine/sensor.py homeassistant/components/rainmachine/switch.py + homeassistant/components/rainforest_eagle/sensor.py homeassistant/components/raspihats/* homeassistant/components/raspyrfm/* homeassistant/components/recollect_waste/sensor.py @@ -487,19 +593,26 @@ omit = 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/roku/remote.py + homeassistant/components/roomba/binary_sensor.py + homeassistant/components/roomba/braava.py + homeassistant/components/roomba/irobot_base.py + homeassistant/components/roomba/roomba.py + homeassistant/components/roomba/sensor.py homeassistant/components/roomba/vacuum.py homeassistant/components/route53/* homeassistant/components/rova/sensor.py - homeassistant/components/rpi_camera/camera.py + homeassistant/components/rpi_camera/* homeassistant/components/rpi_gpio/* homeassistant/components/rpi_gpio/cover.py homeassistant/components/rpi_gpio_pwm/light.py @@ -508,9 +621,11 @@ omit = 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/saj/sensor.py + homeassistant/components/salt/device_tracker.py homeassistant/components/satel_integra/* + homeassistant/components/schluter/* homeassistant/components/scrape/sensor.py homeassistant/components/scsgate/* homeassistant/components/scsgate/cover.py @@ -519,6 +634,7 @@ omit = homeassistant/components/sensehat/light.py homeassistant/components/sensehat/sensor.py homeassistant/components/sensibo/climate.py + homeassistant/components/sentry/__init__.py homeassistant/components/serial/sensor.py homeassistant/components/serial_pm/sensor.py homeassistant/components/sesame/lock.py @@ -531,37 +647,54 @@ omit = homeassistant/components/simplepush/notify.py homeassistant/components/simplisafe/__init__.py homeassistant/components/simplisafe/alarm_control_panel.py + homeassistant/components/simplisafe/lock.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/sinch/* + homeassistant/components/slide/* homeassistant/components/sma/sensor.py homeassistant/components/smappee/* + homeassistant/components/smarty/* + homeassistant/components/smarthab/* + homeassistant/components/sms/* homeassistant/components/smtp/notify.py - homeassistant/components/snapcast/media_player.py + homeassistant/components/snapcast/* homeassistant/components/snmp/* homeassistant/components/sochain/sensor.py homeassistant/components/socialblade/sensor.py + homeassistant/components/solaredge/__init__.py homeassistant/components/solaredge/sensor.py + homeassistant/components/solaredge_local/sensor.py + homeassistant/components/solarlog/* + homeassistant/components/solax/sensor.py + homeassistant/components/soma/cover.py + homeassistant/components/soma/__init__.py + homeassistant/components/somfy/* homeassistant/components/somfy_mylink/* homeassistant/components/sonarr/sensor.py - homeassistant/components/songpal/media_player.py + homeassistant/components/songpal/* 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/__init__.py homeassistant/components/spotify/media_player.py - homeassistant/components/squeezebox/media_player.py - homeassistant/components/srp_energy/sensor.py + homeassistant/components/squeezebox/* + homeassistant/components/starline/* homeassistant/components/starlingbank/sensor.py homeassistant/components/steam_online/sensor.py homeassistant/components/stiebel_eltron/* - homeassistant/components/stride/notify.py + homeassistant/components/stookalert/* + homeassistant/components/streamlabswater/* + homeassistant/components/suez_water/* homeassistant/components/supervisord/sensor.py + homeassistant/components/surepetcare/*.py homeassistant/components/swiss_hydrological_data/sensor.py homeassistant/components/swiss_public_transport/sensor.py homeassistant/components/swisscom/device_tracker.py @@ -571,15 +704,16 @@ omit = homeassistant/components/syncthru/sensor.py homeassistant/components/synology/camera.py homeassistant/components/synology_chat/notify.py + homeassistant/components/synology_dsm/__init__.py + homeassistant/components/synology_dsm/sensor.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/tankerkoenig/* homeassistant/components/tapsaff/binary_sensor.py homeassistant/components/tautulli/sensor.py homeassistant/components/ted5000/sensor.py @@ -590,7 +724,14 @@ omit = homeassistant/components/telnet/switch.py homeassistant/components/temper/sensor.py homeassistant/components/tensorflow/image_processing.py - homeassistant/components/tesla/* + homeassistant/components/tesla/__init__.py + homeassistant/components/tesla/binary_sensor.py + homeassistant/components/tesla/climate.py + homeassistant/components/tesla/const.py + homeassistant/components/tesla/device_tracker.py + homeassistant/components/tesla/lock.py + homeassistant/components/tesla/sensor.py + homeassistant/components/tesla/switch.py homeassistant/components/tfiac/climate.py homeassistant/components/thermoworks_smoke/sensor.py homeassistant/components/thethingsnetwork/* @@ -601,49 +742,72 @@ omit = homeassistant/components/tikteck/light.py homeassistant/components/tile/device_tracker.py homeassistant/components/time_date/sensor.py + homeassistant/components/tmb/sensor.py homeassistant/components/todoist/calendar.py + homeassistant/components/todoist/const.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/totalconnect/* 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/traccar/const.py homeassistant/components/trackr/device_tracker.py homeassistant/components/tradfri/* homeassistant/components/tradfri/light.py + homeassistant/components/tradfri/cover.py + homeassistant/components/tradfri/base_class.py + homeassistant/components/trafikverket_train/sensor.py homeassistant/components/trafikverket_weatherstation/sensor.py - homeassistant/components/transmission/* + homeassistant/components/transmission/sensor.py + homeassistant/components/transmission/switch.py + homeassistant/components/transmission/const.py + homeassistant/components/transmission/errors.py homeassistant/components/travisci/sensor.py homeassistant/components/tuya/* + homeassistant/components/twentemilieu/const.py + homeassistant/components/twentemilieu/sensor.py 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/unifiled/* homeassistant/components/upcloud/* homeassistant/components/upnp/* - homeassistant/components/ups/sensor.py + homeassistant/components/upc_connect/* homeassistant/components/uptimerobot/binary_sensor.py homeassistant/components/uscis/sensor.py - homeassistant/components/usps/* + homeassistant/components/vallox/* homeassistant/components/vasttrafik/sensor.py - homeassistant/components/velbus/* + homeassistant/components/velbus/__init__.py + homeassistant/components/velbus/binary_sensor.py + homeassistant/components/velbus/climate.py + homeassistant/components/velbus/const.py + homeassistant/components/velbus/cover.py + homeassistant/components/velbus/light.py + homeassistant/components/velbus/sensor.py + homeassistant/components/velbus/switch.py homeassistant/components/velux/* homeassistant/components/venstar/climate.py - homeassistant/components/vera/* homeassistant/components/verisure/* + homeassistant/components/versasense/* + homeassistant/components/vesync/__init__.py + homeassistant/components/vesync/common.py + homeassistant/components/vesync/const.py homeassistant/components/vesync/switch.py homeassistant/components/viaggiatreno/sensor.py - homeassistant/components/vizio/media_player.py + homeassistant/components/vicare/* + homeassistant/components/vilfo/__init__.py + homeassistant/components/vilfo/sensor.py + homeassistant/components/vilfo/const.py + homeassistant/components/vivotek/camera.py homeassistant/components/vlc/media_player.py + homeassistant/components/vlc_telnet/media_player.py homeassistant/components/volkszaehler/sensor.py homeassistant/components/volumio/media_player.py homeassistant/components/volvooncall/* @@ -651,29 +815,41 @@ omit = 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/wwlln/__init__.py + homeassistant/components/wwlln/geo_location.py 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_miio/__init__.py + homeassistant/components/xiaomi_miio/air_quality.py + homeassistant/components/xiaomi_miio/alarm_control_panel.py + homeassistant/components/xiaomi_miio/device_tracker.py + homeassistant/components/xiaomi_miio/fan.py + homeassistant/components/xiaomi_miio/gateway.py + homeassistant/components/xiaomi_miio/light.py + homeassistant/components/xiaomi_miio/remote.py + homeassistant/components/xiaomi_miio/sensor.py + homeassistant/components/xiaomi_miio/switch.py + homeassistant/components/xiaomi_miio/vacuum.py 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/yandex_transport/* homeassistant/components/yeelight/* homeassistant/components/yeelightsunflower/light.py homeassistant/components/yi/camera.py @@ -685,13 +861,14 @@ omit = 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/core/patches.py + homeassistant/components/zha/core/registries.py + homeassistant/components/zha/core/typing.py homeassistant/components/zha/entity.py homeassistant/components/zha/light.py homeassistant/components/zha/sensor.py diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000000000..7b56b66d0b59c --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,33 @@ +{ + "name": "Home Assistant Dev", + "context": "..", + "dockerFile": "../Dockerfile.dev", + "postCreateCommand": "mkdir -p config && pip3 install -e .", + "appPort": 8123, + "runArgs": ["-e", "GIT_EDITOR=code --wait"], + "extensions": [ + "ms-python.python", + "visualstudioexptteam.vscodeintellicode", + "ms-azure-devops.azure-pipelines", + "redhat.vscode-yaml", + "esbenp.prettier-vscode" + ], + "settings": { + "python.pythonPath": "/usr/local/bin/python", + "python.linting.pylintEnabled": true, + "python.linting.enabled": true, + "python.formatting.provider": "black", + "editor.formatOnPaste": false, + "editor.formatOnSave": true, + "editor.formatOnType": true, + "files.trimTrailingWhitespace": true, + "terminal.integrated.shell.linux": "/bin/bash", + "yaml.customTags": [ + "!secret scalar", + "!include_dir_named scalar", + "!include_dir_list scalar", + "!include_dir_merge_list scalar", + "!include_dir_merge_named scalar" + ] + } +} diff --git a/.dockerignore b/.dockerignore index 3d8c32cfb926e..8144367ede156 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,9 +2,15 @@ .git .github config +docs + +# Development +.devcontainer +.vscode # Test related files .tox +tests # Other virtualization methods venv diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md index 57244b44d9a87..713c7dc287272 100644 --- a/.github/ISSUE_TEMPLATE.md +++ b/.github/ISSUE_TEMPLATE.md @@ -1,47 +1,49 @@ - -**Home Assistant release with the issue:** - -**Last working Home Assistant release (if known):** - - -**Operating environment (Hass.io/Docker/Windows/etc.):** +## Environment -**Component/platform:** +- Home Assistant Core release with the issue: +- Last working Home Assistant Core release (if known): +- Operating environment (Home Assistant/Supervised/Docker/venv): +- Integration causing this issue: +- Link to integration documentation on our website: + +## Problem-relevant `configuration.yaml` - -**Description of problem:** - - - -**Problem-relevant `configuration.yaml` entries and (fill out even if it seems unimportant):** ```yaml ``` -**Traceback (if applicable):** -``` +## Traceback/Error logs + + +```txt ``` -**Additional information:** +## Additional information diff --git a/.github/ISSUE_TEMPLATE/BUG_REPORT.md b/.github/ISSUE_TEMPLATE/BUG_REPORT.md new file mode 100644 index 0000000000000..9bfecda724f38 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/BUG_REPORT.md @@ -0,0 +1,53 @@ +--- +name: Report a bug with Home Assistant Core +about: Report an issue with Home Assistant Core +--- + +## The problem + + + +## Environment + + +- Home Assistant Core release with the issue: +- Last working Home Assistant Core release (if known): +- Operating environment (Home Assistant/Supervised/Docker/venv): +- Integration causing this issue: +- Link to integration documentation on our website: + +## Problem-relevant `configuration.yaml` + + +```yaml + +``` + +## Traceback/Error logs + + +```txt + +``` + +## Additional information + diff --git a/.github/ISSUE_TEMPLATE/Bug_report.md b/.github/ISSUE_TEMPLATE/Bug_report.md deleted file mode 100644 index 2abfa6f9b6f66..0000000000000 --- a/.github/ISSUE_TEMPLATE/Bug_report.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -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/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000000..2440cb7ff29ac --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,17 @@ +blank_issues_enabled: false +contact_links: + - name: Report a bug with the UI, Frontend or Lovelace + url: https://github.com/home-assistant/frontend/issues + about: This is the issue tracker for our backend. Please report issues with the UI in the frontend repository. + - name: Report incorrect or missing information on our website + url: https://github.com/home-assistant/home-assistant.io/issues + about: Our documentation has its own issue tracker. Please report issues with the website there. + - name: I have a question or need support + url: https://www.home-assistant.io/help + about: We use GitHub for tracking bugs, check our website for resources on getting help. + - name: Feature Request + url: https://community.home-assistant.io/c/feature-requests + about: Please use our Community Forum for making feature requests. + - name: I'm unsure where to go + url: https://www.home-assistant.io/join-chat + about: If you are unsure where to go, then joining our chat is recommended; Just ask! diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 474dff86b3dfa..1ada6d3af86cd 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,35 +1,109 @@ -## Breaking Change: + +## Breaking change + - -## Description: +## Proposed change + -**Related issue (if applicable):** fixes # +## Type of change + -**Pull request with documentation for [home-assistant.io](https://github.com/home-assistant/home-assistant.io) (if applicable):** home-assistant/home-assistant.io# +- [ ] Dependency upgrade +- [ ] Bugfix (non-breaking change which fixes an issue) +- [ ] New integration (thank you!) +- [ ] New feature (which adds functionality to an existing integration) +- [ ] Breaking change (fix/feature causing existing functionality to break) +- [ ] Code quality improvements to existing code or addition of tests + +## Example entry for `configuration.yaml`: + -## Example entry for `configuration.yaml` (if applicable): ```yaml +# Example configuration.yaml ``` -## 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] +## Additional information + + +- This PR fixes or closes issue: fixes # +- This PR is related to issue: +- Link to documentation pull request: + +## Checklist + + +- [ ] The code change is tested and works locally. +- [ ] Local tests pass. **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] +- [ ] The code has been formatted using Black (`black --fast homeassistant tests`) +- [ ] Tests have been added to verify that the new code works. If user exposed functionality or configuration variables are added/changed: - - [ ] Documentation added/updated in [home-assistant.io](https://github.com/home-assistant/home-assistant.io) + +- [ ] Documentation added/updated for [www.home-assistant.io][docs-repository] If the code communicates with devices, web services, or third-party tools: - - [ ] [_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: - - [ ] Tests have been added to verify that the new code works. +- [ ] The [manifest file][manifest-docs] has all fields filled out correctly. + Updated and included derived files by running: `python3 -m script.hassfest`. +- [ ] New or updated dependencies have been added to `requirements_all.txt`. + Updated by running `python3 -m script.gen_requirements_all`. +- [ ] Untested files have been added to `.coveragerc`. + +The integration reached or maintains the following [Integration Quality Scale][quality-scale]: + + +- [ ] No score or internal +- [ ] 🥈 Silver +- [ ] 🥇 Gold +- [ ] 🏆 Platinum + + [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 +[quality-scale]: https://developers.home-assistant.io/docs/en/next/integration_quality_scale_index.html +[docs-repository]: https://github.com/home-assistant/home-assistant.io diff --git a/.github/lock.yml b/.github/lock.yml new file mode 100644 index 0000000000000..7ce0cc6561948 --- /dev/null +++ b/.github/lock.yml @@ -0,0 +1,27 @@ +# Configuration for Lock Threads - https://github.com/dessant/lock-threads + +# Number of days of inactivity before a closed issue or pull request is locked +daysUntilLock: 1 + +# Skip issues and pull requests created before a given timestamp. Timestamp must +# follow ISO 8601 (`YYYY-MM-DD`). Set to `false` to disable +skipCreatedBefore: 2019-07-01 + +# Issues and pull requests with these labels will be ignored. Set to `[]` to disable +exemptLabels: [] + +# Label to add before locking, such as `outdated`. Set to `false` to disable +lockLabel: false + +# Comment to post before locking. Set to `false` to disable +lockComment: false + +# Assign `resolved` as the reason for locking. Set to `false` to disable +setLockReason: false + +# Limit to only `issues` or `pulls` +only: pulls + +# Optionally, specify configuration settings just for `issues` or `pulls` +issues: + daysUntilLock: 30 diff --git a/.github/stale.yml b/.github/stale.yml new file mode 100644 index 0000000000000..f09f37336511a --- /dev/null +++ b/.github/stale.yml @@ -0,0 +1,66 @@ +# Configuration for probot-stale - https://github.com/probot/stale + +# Number of days of inactivity before an Issue or Pull Request becomes stale +daysUntilStale: 90 + +# Number of days of inactivity before an Issue or Pull Request with the stale label is closed. +# Set to false to disable. If disabled, issues still need to be closed manually, but will remain marked as stale. +daysUntilClose: 7 + +# Only issues or pull requests with all of these labels are check if stale. Defaults to `[]` (disabled) +onlyLabels: [] + +# Issues or Pull Requests with these labels will never be considered stale. Set to `[]` to disable +exemptLabels: + - under investigation + - Help wanted + +# Set to true to ignore issues in a project (defaults to false) +exemptProjects: true + +# Set to true to ignore issues in a milestone (defaults to false) +exemptMilestones: true + +# Set to true to ignore issues with an assignee (defaults to false) +exemptAssignees: false + +# Label to use when marking as stale +staleLabel: stale + +# Comment to post when marking as stale. Set to `false` to disable +markComment: > + There hasn't been any activity on this issue recently. Due to the high number + of incoming GitHub notifications, we have to clean some of the old issues, + as many of them have already been resolved with the latest updates. + + Please make sure to update to the latest Home Assistant version and check + if that solves the issue. Let us know if that works for you by adding a + comment 👍 + + This issue now has been marked as stale and will be closed if no further + activity occurs. Thank you for your contributions. + +# Comment to post when removing the stale label. +# unmarkComment: > +# Your comment here. + +# Comment to post when closing a stale Issue or Pull Request. +# closeComment: > +# Your comment here. + +# Limit the number of actions per hour, from 1-30. Default is 30 +limitPerRun: 30 + +# Limit to only `issues` or `pulls` +# only: issues + +# Handle pull requests a little bit faster and with an adjusted comment. +pulls: + daysUntilStale: 30 + exemptProjects: false + markComment: > + There hasn't been any activity on this pull request recently. This pull + request has been automatically marked as stale because of that and will + be closed if no further activity occurs within 7 days. + + Thank you for your contributions. diff --git a/.gitignore b/.gitignore index 7a0cb29bc2b26..2473aeb4bf650 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,10 @@ config2/* tests/testing_config/deps tests/testing_config/home-assistant.log +# hass-release +data/ +.token + # Hide sublime text stuff *.sublime-project *.sublime-workspace @@ -46,6 +50,7 @@ develop-eggs .installed.cfg lib lib64 +pip-wheel-metadata # Logs *.log @@ -54,9 +59,12 @@ pip-log.txt # Unit test / coverage reports .coverage .tox +coverage.xml nosetests.xml htmlcov/ test-reports/ +test-results.xml +test-output.xml # Translations *.mo @@ -94,7 +102,10 @@ virtualization/vagrant/.vagrant virtualization/vagrant/config # Visual Studio Code -.vscode +.vscode/* +!.vscode/cSpell.json +!.vscode/extensions.json +!.vscode/tasks.json # Built docs docs/build @@ -107,9 +118,16 @@ desktop.ini # mypy /.mypy_cache/* +/.dmypy.json # Secrets .lokalise_token # monkeytype monkeytype.sqlite3 + +# This is left behind by Azure Restore Cache +tmp_cache + +# python-language-server / Rope +.ropeproject diff --git a/.hadolint.yaml b/.hadolint.yaml new file mode 100644 index 0000000000000..06de09b5460ee --- /dev/null +++ b/.hadolint.yaml @@ -0,0 +1,5 @@ +ignored: + - DL3006 + - DL3008 + - DL3013 + - DL3018 diff --git a/.hound.yml b/.hound.yml deleted file mode 100644 index c5ab91614dc6d..0000000000000 --- a/.hound.yml +++ /dev/null @@ -1,2 +0,0 @@ -python: - enabled: true diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000000000..05e062e43b9c0 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,91 @@ +repos: + - repo: https://github.com/asottile/pyupgrade + rev: v2.2.1 + hooks: + - id: pyupgrade + args: [--py37-plus] + - repo: https://github.com/psf/black + rev: 19.10b0 + hooks: + - id: black + args: + - --safe + - --quiet + files: ^((homeassistant|script|tests)/.+)?[^/]+\.py$ + - repo: https://github.com/codespell-project/codespell + rev: v1.16.0 + hooks: + - id: codespell + args: + - --ignore-words-list=hass,alot,datas,dof,dur,farenheit,hist,iff,ines,ist,lightsensor,mut,nd,pres,referer,ser,serie,te,technik,ue,uint,visability,wan,wanna,withing + - --skip="./.*,*.json" + - --quiet-level=2 + exclude_types: [json] + - repo: https://gitlab.com/pycqa/flake8 + rev: 3.7.9 + hooks: + - id: flake8 + additional_dependencies: + - flake8-docstrings==1.5.0 + - pydocstyle==5.0.2 + files: ^(homeassistant|script|tests)/.+\.py$ + - repo: https://github.com/PyCQA/bandit + rev: 1.6.2 + hooks: + - id: bandit + args: + - --quiet + - --format=custom + - --configfile=tests/bandit.yaml + files: ^(homeassistant|script|tests)/.+\.py$ + - repo: https://github.com/pre-commit/mirrors-isort + rev: v4.3.21 + hooks: + - id: isort + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v2.4.0 + hooks: + - id: check-executables-have-shebangs + stages: [manual] + - id: check-json + - id: no-commit-to-branch + args: + - --branch=dev + - --branch=master + - --branch=rc + - repo: https://github.com/adrienverge/yamllint.git + rev: v1.23.0 + hooks: + - id: yamllint + - repo: https://github.com/prettier/prettier + rev: 2.0.4 + hooks: + - id: prettier + stages: [manual] + - repo: local + hooks: + # Run mypy through our wrapper script in order to get the possible + # pyenv and/or virtualenv activated; it may not have been e.g. if + # committing from a GUI tool that was not launched from an activated + # shell. + - id: mypy + name: mypy + entry: script/run-in-env.sh mypy + language: script + types: [python] + require_serial: true + files: ^homeassistant/.+\.py$ + - id: gen_requirements_all + name: gen_requirements_all + entry: script/run-in-env.sh python3 -m script.gen_requirements_all + pass_filenames: false + language: script + types: [json] + files: ^homeassistant/.+/manifest\.json$ + - id: hassfest + name: hassfest + entry: script/run-in-env.sh python3 -m script.hassfest + pass_filenames: false + language: script + types: [json] + files: ^homeassistant/.+/(manifest|strings)\.json$ diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000000000..1102d3a4e26d4 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,5 @@ +*.md +azure-*.yml +docs/source/_templates/* +homeassistant/components/*/translations/*.json +tests/fixtures/* diff --git a/.readthedocs.yml b/.readthedocs.yml index 923a03f03dd8a..0303f84d51c1f 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -4,7 +4,7 @@ build: image: latest python: - version: 3.6 + version: 3.7 setup_py_install: true requirements_file: requirements_docs.txt diff --git a/.travis.yml b/.travis.yml index 4167b1c9923e3..6add8c15bfc5a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,9 +1,7 @@ sudo: false -dist: xenial +dist: bionic addons: apt: - sources: - - sourceline: "ppa:jonathonf/ffmpeg-4" packages: - libudev-dev - libavformat-dev @@ -16,18 +14,19 @@ addons: matrix: fast_finish: true include: - - python: "3.5.3" + - python: "3.7.0" env: TOXENV=lint - - python: "3.5.3" - env: TOXENV=pylint - - python: "3.5.3" + - python: "3.7.0" + env: TOXENV=pylint PYLINT_ARGS=--jobs=0 TRAVIS_WAIT=30 + - python: "3.7.0" env: TOXENV=typing - - python: "3.5.3" - env: TOXENV=py35 - - python: "3.7" + - python: "3.7.0" env: TOXENV=py37 -cache: pip +cache: + pip: true + directories: + - $HOME/.cache/pre-commit install: pip install -U tox language: python -script: travis_wait 40 tox --develop +script: ${TRAVIS_WAIT:+travis_wait $TRAVIS_WAIT} tox --develop diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000000000..951134133e57e --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["esbenp.prettier-vscode", "ms-python.python"] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000000000..1a0bfb16a9be4 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,105 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Preview", + "type": "shell", + "command": "hass -c ./config", + "group": { + "kind": "test", + "isDefault": true + }, + "presentation": { + "reveal": "always", + "panel": "new" + }, + "problemMatcher": [] + }, + { + "label": "Pytest", + "type": "shell", + "command": "pytest --timeout=10 tests", + "dependsOn": ["Install all Test Requirements"], + "group": { + "kind": "test", + "isDefault": true + }, + "presentation": { + "reveal": "always", + "panel": "new" + }, + "problemMatcher": [] + }, + { + "label": "Flake8", + "type": "shell", + "command": "pre-commit run flake8 --all-files", + "group": { + "kind": "test", + "isDefault": true + }, + "presentation": { + "reveal": "always", + "panel": "new" + }, + "problemMatcher": [] + }, + { + "label": "Pylint", + "type": "shell", + "command": "pylint homeassistant", + "dependsOn": ["Install all Requirements"], + "group": { + "kind": "test", + "isDefault": true + }, + "presentation": { + "reveal": "always", + "panel": "new" + }, + "problemMatcher": [] + }, + { + "label": "Generate Requirements", + "type": "shell", + "command": "./script/gen_requirements_all.py", + "group": { + "kind": "build", + "isDefault": true + }, + "presentation": { + "reveal": "always", + "panel": "new" + }, + "problemMatcher": [] + }, + { + "label": "Install all Requirements", + "type": "shell", + "command": "pip3 install -r requirements_all.txt -c homeassistant/package_constraints.txt", + "group": { + "kind": "build", + "isDefault": true + }, + "presentation": { + "reveal": "always", + "panel": "new" + }, + "problemMatcher": [] + }, + { + "label": "Install all Test Requirements", + "type": "shell", + "command": "pip3 install -r requirements_test_all.txt -c homeassistant/package_constraints.txt", + "group": { + "kind": "build", + "isDefault": true + }, + "presentation": { + "reveal": "always", + "panel": "new" + }, + "problemMatcher": [] + } + ] +} diff --git a/.yamllint b/.yamllint new file mode 100644 index 0000000000000..c2f877a2b7a07 --- /dev/null +++ b/.yamllint @@ -0,0 +1,61 @@ +ignore: | + azure-*.yml +rules: + braces: + level: error + min-spaces-inside: 0 + max-spaces-inside: 1 + min-spaces-inside-empty: -1 + max-spaces-inside-empty: -1 + brackets: + level: error + min-spaces-inside: 0 + max-spaces-inside: 0 + min-spaces-inside-empty: -1 + max-spaces-inside-empty: -1 + colons: + level: error + max-spaces-before: 0 + max-spaces-after: 1 + commas: + level: error + max-spaces-before: 0 + min-spaces-after: 1 + max-spaces-after: 1 + comments: + level: error + require-starting-space: true + min-spaces-from-content: 2 + comments-indentation: + level: error + document-end: + level: error + present: false + document-start: + level: error + present: false + empty-lines: + level: error + max: 1 + max-start: 0 + max-end: 1 + hyphens: + level: error + max-spaces-after: 1 + indentation: + level: error + spaces: 2 + indent-sequences: true + check-multi-line-strings: false + key-duplicates: + level: error + line-length: disable + new-line-at-end-of-file: + level: error + new-lines: + level: error + type: unix + trailing-spaces: + level: error + truthy: + level: error diff --git a/CODEOWNERS b/CODEOWNERS index 90fb72378bcde..a021a4a28ed17 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1,4 +1,4 @@ -# This file is generated by script/manifest/codeowners.py +# This file is generated by script/hassfest/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 @@ -9,109 +9,175 @@ 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/abode/* @shred86 +homeassistant/components/adguard/* @frenck +homeassistant/components/airly/* @bieniu homeassistant/components/airvisual/* @bachya -homeassistant/components/alarm_control_panel/* @colinodell +homeassistant/components/alarmdecoder/* @ajschmidt8 +homeassistant/components/alexa/* @home-assistant/cloud @ochlocracy +homeassistant/components/almond/* @gcampax @balloob homeassistant/components/alpha_vantage/* @fabaff homeassistant/components/amazon_polly/* @robbiet480 homeassistant/components/ambiclimate/* @danielhiversen homeassistant/components/ambient_station/* @bachya +homeassistant/components/amcrest/* @pnbruckner +homeassistant/components/androidtv/* @JeffLIrion +homeassistant/components/apache_kafka/* @bachya homeassistant/components/api/* @home-assistant/core +homeassistant/components/apprise/* @caronc +homeassistant/components/aprs/* @PhilRW +homeassistant/components/arcam_fmj/* @elupus homeassistant/components/arduino/* @fabaff homeassistant/components/arest/* @fabaff +homeassistant/components/arris_tg2492lg/* @vanbalken homeassistant/components/asuswrt/* @kennedyshead +homeassistant/components/atag/* @MatsNL +homeassistant/components/aten_pe/* @mtdcr +homeassistant/components/atome/* @baqs +homeassistant/components/august/* @bdraco +homeassistant/components/aurora_abb_powerone/* @davet2001 homeassistant/components/auth/* @home-assistant/core homeassistant/components/automatic/* @armills homeassistant/components/automation/* @home-assistant/core +homeassistant/components/avea/* @pattyland +homeassistant/components/avri/* @timvancann +homeassistant/components/awair/* @danielsjf homeassistant/components/aws/* @awarecan @robbiet480 homeassistant/components/axis/* @kane610 +homeassistant/components/azure_event_hub/* @eavanvalkenburg +homeassistant/components/azure_service_bus/* @hfurubotten +homeassistant/components/beewi_smartclim/* @alemuro 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/bmp280/* @belidzs +homeassistant/components/bmw_connected_drive/* @gerard33 +homeassistant/components/bom/* @maddenp +homeassistant/components/braviatv/* @robbiet480 @bieniu +homeassistant/components/broadlink/* @danielhiversen @felipediel +homeassistant/components/brother/* @bieniu homeassistant/components/brunt/* @eavanvalkenburg homeassistant/components/bt_smarthub/* @jxwolstenholme +homeassistant/components/buienradar/* @mjj4791 @ties +homeassistant/components/cast/* @emontnemery +homeassistant/components/cert_expiry/* @Cereal2nd @jjlawren 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/cloud/* @home-assistant/cloud homeassistant/components/cloudflare/* @ludeeus +homeassistant/components/comfoconnect/* @michaelarnauts homeassistant/components/config/* @home-assistant/core homeassistant/components/configurator/* @home-assistant/core homeassistant/components/conversation/* @home-assistant/core homeassistant/components/coolmaster/* @OnFreund +homeassistant/components/coronavirus/* @home_assistant/core 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/daikin/* @fredrike homeassistant/components/darksky/* @fabaff homeassistant/components/deconz/* @kane610 +homeassistant/components/delijn/* @bollewolle homeassistant/components/demo/* @home-assistant/core +homeassistant/components/denonavr/* @scarface-4711 @starkillerOG +homeassistant/components/derivative/* @afaucogney +homeassistant/components/device_automation/* @home-assistant/core homeassistant/components/digital_ocean/* @fabaff +homeassistant/components/directv/* @ctalkington homeassistant/components/discogs/* @thibmaek -homeassistant/components/doorbird/* @oblogic7 +homeassistant/components/doorbird/* @oblogic7 @bdraco +homeassistant/components/dsmr_reader/* @depl0y homeassistant/components/dweet/* @fabaff +homeassistant/components/dynalite/* @ziv1234 +homeassistant/components/dyson/* @etheralm +homeassistant/components/ecobee/* @marthoc homeassistant/components/ecovacs/* @OverloadUT -homeassistant/components/edp_redy/* @abmantis +homeassistant/components/edl21/* @mtdcr homeassistant/components/egardia/* @jeroenterheerdt homeassistant/components/eight_sleep/* @mezz64 +homeassistant/components/elgato/* @frenck +homeassistant/components/elkm1/* @bdraco +homeassistant/components/elv/* @majuss homeassistant/components/emby/* @mezz64 +homeassistant/components/emoncms/* @borpin homeassistant/components/enigma2/* @fbradyirl homeassistant/components/enocean/* @bdurrer +homeassistant/components/entur_public_transport/* @hfurubotten +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/ezviz/* @baqs +homeassistant/components/fastdotcom/* @rohankapoorcom homeassistant/components/file/* @fabaff homeassistant/components/filter/* @dgomes homeassistant/components/fitbit/* @robbiet480 homeassistant/components/fixer/* @fabaff homeassistant/components/flock/* @fabaff +homeassistant/components/flume/* @ChrisMandich @bdraco homeassistant/components/flunearyou/* @bachya +homeassistant/components/fortigate/* @kifeo +homeassistant/components/fortios/* @kimfrellsen +homeassistant/components/foscam/* @skgsergio homeassistant/components/foursquare/* @robbiet480 -homeassistant/components/freebox/* @snoof85 -homeassistant/components/frontend/* @home-assistant/core +homeassistant/components/freebox/* @snoof85 @Quentame +homeassistant/components/fronius/* @nielstron +homeassistant/components/frontend/* @home-assistant/frontend +homeassistant/components/garmin_connect/* @cyberjunky +homeassistant/components/gdacs/* @exxamalte homeassistant/components/gearbest/* @HerrHofrat homeassistant/components/geniushub/* @zxdavb +homeassistant/components/geo_rss_events/* @exxamalte +homeassistant/components/geonetnz_quakes/* @exxamalte +homeassistant/components/geonetnz_volcano/* @exxamalte +homeassistant/components/gios/* @bieniu homeassistant/components/gitter/* @fabaff -homeassistant/components/glances/* @fabaff +homeassistant/components/glances/* @fabaff @engrbm87 homeassistant/components/gntp/* @robbiet480 +homeassistant/components/google_assistant/* @home-assistant/cloud +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/greeneye_monitor/* @jkeljo +homeassistant/components/griddy/* @bdraco homeassistant/components/group/* @home-assistant/core +homeassistant/components/growatt_server/* @indykoning homeassistant/components/gtfs/* @robbiet480 -homeassistant/components/harmony/* @ehendrix23 +homeassistant/components/harmony/* @ehendrix23 @bramkragten @bdraco homeassistant/components/hassio/* @home-assistant/hass-io +homeassistant/components/heatmiser/* @andylockran homeassistant/components/heos/* @andrewsayre +homeassistant/components/here_travel_time/* @eifinger homeassistant/components/hikvision/* @mezz64 homeassistant/components/hikvisioncam/* @fbradyirl +homeassistant/components/hisense_aehw4a1/* @bannhead 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/* @bdraco homeassistant/components/homekit_controller/* @Jc2k homeassistant/components/homematic/* @pvizeli @danielperna84 +homeassistant/components/homematicip_cloud/* @SukramJ +homeassistant/components/honeywell/* @zxdavb 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/hunterdouglas_powerview/* @bdraco +homeassistant/components/iammeter/* @lewei50 +homeassistant/components/iaqualink/* @flz +homeassistant/components/icloud/* @Quentame homeassistant/components/ign_sismologia/* @exxamalte homeassistant/components/incomfort/* @zxdavb homeassistant/components/influxdb/* @fabaff @@ -121,159 +187,282 @@ 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/intent/* @home-assistant/core +homeassistant/components/intesishome/* @jnimmo homeassistant/components/ios/* @robbiet480 -homeassistant/components/ipma/* @dgomes +homeassistant/components/iperf3/* @rohankapoorcom +homeassistant/components/ipma/* @dgomes @abmantis +homeassistant/components/ipp/* @ctalkington homeassistant/components/iqvia/* @bachya homeassistant/components/irish_rail_transport/* @ttroy50 +homeassistant/components/islamic_prayer_times/* @engrbm87 +homeassistant/components/isy994/* @bdraco +homeassistant/components/izone/* @Swamp-Ig homeassistant/components/jewish_calendar/* @tsvi +homeassistant/components/juicenet/* @jesserockz +homeassistant/components/kaiterra/* @Michsior14 +homeassistant/components/keba/* @dannerph +homeassistant/components/keenetic_ndms2/* @foxel +homeassistant/components/kef/* @basnijholt +homeassistant/components/keyboard_remote/* @bendavid homeassistant/components/knx/* @Julius2342 homeassistant/components/kodi/* @armills -homeassistant/components/konnected/* @heythisisnate +homeassistant/components/konnected/* @heythisisnate @kit-klein homeassistant/components/lametric/* @robbiet480 homeassistant/components/launch_library/* @ludeeus -homeassistant/components/lifx/* @amelchio -homeassistant/components/lifx_cloud/* @amelchio -homeassistant/components/lifx_legacy/* @amelchio +homeassistant/components/lcn/* @alengwenus +homeassistant/components/life360/* @pnbruckner +homeassistant/components/linky/* @Quentame homeassistant/components/linux_battery/* @fabaff -homeassistant/components/liveboxplaytv/* @pschmitt +homeassistant/components/local_ip/* @issacg homeassistant/components/logger/* @home-assistant/core homeassistant/components/logi_circle/* @evanjd -homeassistant/components/lovelace/* @home-assistant/core -homeassistant/components/luci/* @fbradyirl +homeassistant/components/lovelace/* @home-assistant/frontend +homeassistant/components/luci/* @fbradyirl @mzdrale homeassistant/components/luftdaten/* @fabaff +homeassistant/components/lupusec/* @majuss +homeassistant/components/lutron/* @JonGilmore +homeassistant/components/lutron_caseta/* @swails homeassistant/components/mastodon/* @fabaff homeassistant/components/matrix/* @tinloaf +homeassistant/components/mcp23017/* @jardiamj homeassistant/components/mediaroom/* @dgomes +homeassistant/components/melcloud/* @vilppuvuorinen homeassistant/components/melissa/* @kennedyshead homeassistant/components/met/* @danielhiversen +homeassistant/components/meteo_france/* @victorcerutti @oncleben31 @Quentame homeassistant/components/meteoalarm/* @rolfberkenbosch homeassistant/components/miflora/* @danielhiversen @ChristianKuehnel +homeassistant/components/mikrotik/* @engrbm87 homeassistant/components/mill/* @danielhiversen homeassistant/components/min_max/* @fabaff +homeassistant/components/minecraft_server/* @elmurato +homeassistant/components/minio/* @tkislan homeassistant/components/mobile_app/* @robbiet480 +homeassistant/components/modbus/* @adamchengtkc @janiversen homeassistant/components/monoprice/* @etsinko homeassistant/components/moon/* @fabaff homeassistant/components/mpd/* @fabaff -homeassistant/components/mqtt/* @home-assistant/core +homeassistant/components/mqtt/* @home-assistant/core @emontnemery +homeassistant/components/msteams/* @peroyvind +homeassistant/components/myq/* @bdraco +homeassistant/components/mysensors/* @MartinHjelmare homeassistant/components/mystrom/* @fabaff +homeassistant/components/neato/* @dshokouhi @Santobert +homeassistant/components/nederlandse_spoorwegen/* @YarmoM homeassistant/components/nello/* @pschmitt homeassistant/components/ness_alarm/* @nickw444 homeassistant/components/nest/* @awarecan +homeassistant/components/netatmo/* @cgtobi homeassistant/components/netdata/* @fabaff +homeassistant/components/nexia/* @ryannazaretian @bdraco homeassistant/components/nextbus/* @vividboarder +homeassistant/components/nextcloud/* @meichthys +homeassistant/components/nilu/* @hfurubotten homeassistant/components/nissan_leaf/* @filcole homeassistant/components/nmbs/* @thibmaek homeassistant/components/no_ip/* @fabaff homeassistant/components/notify/* @home-assistant/core +homeassistant/components/notion/* @bachya homeassistant/components/nsw_fuel_station/* @nickw444 -homeassistant/components/nuki/* @pschmitt +homeassistant/components/nsw_rural_fire_service_feed/* @exxamalte +homeassistant/components/nuheat/* @bdraco +homeassistant/components/nuki/* @pvizeli +homeassistant/components/numato/* @clssn +homeassistant/components/nut/* @bdraco +homeassistant/components/nws/* @MatthewFlamm +homeassistant/components/nzbget/* @chriscla +homeassistant/components/obihai/* @dshokouhi homeassistant/components/ohmconnect/* @robbiet480 +homeassistant/components/ombi/* @larssont homeassistant/components/onboarding/* @home-assistant/core +homeassistant/components/onewire/* @garbled1 +homeassistant/components/onvif/* @hunterjm +homeassistant/components/openerz/* @misialq +homeassistant/components/opentherm_gw/* @mvn23 homeassistant/components/openuv/* @bachya homeassistant/components/openweathermap/* @fabaff +homeassistant/components/opnsense/* @mtreinish homeassistant/components/orangepi_gpio/* @pascallj -homeassistant/components/owlet/* @oblogic7 -homeassistant/components/panel_custom/* @home-assistant/core -homeassistant/components/panel_iframe/* @home-assistant/core +homeassistant/components/oru/* @bvlaicu +homeassistant/components/panasonic_viera/* @joogps +homeassistant/components/panel_custom/* @home-assistant/frontend +homeassistant/components/panel_iframe/* @home-assistant/frontend +homeassistant/components/pcal9535a/* @Shulyaka homeassistant/components/persistent_notification/* @home-assistant/core homeassistant/components/philips_js/* @elupus -homeassistant/components/pi_hole/* @fabaff +homeassistant/components/pi4ioe5v9xxxx/* @antonverburg +homeassistant/components/pi_hole/* @fabaff @johnluetke +homeassistant/components/pilight/* @trekky12 +homeassistant/components/plaato/* @JohNan homeassistant/components/plant/* @ChristianKuehnel +homeassistant/components/plex/* @jjlawren +homeassistant/components/plugwise/* @laetificat @CoMPaTech @bouwew +homeassistant/components/plum_lightpad/* @ColinHarrington homeassistant/components/point/* @fredrike +homeassistant/components/powerwall/* @bdraco @jrester +homeassistant/components/proxmoxve/* @k4ds3 homeassistant/components/ps4/* @ktnrg45 homeassistant/components/ptvsd/* @swamp-ig homeassistant/components/push/* @dgomes homeassistant/components/pvoutput/* @fabaff +homeassistant/components/pvpc_hourly_pricing/* @azogue +homeassistant/components/qld_bushfire/* @exxamalte homeassistant/components/qnap/* @colinodell homeassistant/components/quantum_gateway/* @cisasteelersfan +homeassistant/components/qvr_pro/* @oblogic7 homeassistant/components/qwikswitch/* @kellerza +homeassistant/components/rachio/* @bdraco +homeassistant/components/rainbird/* @konikvranik homeassistant/components/raincloud/* @vanstinator +homeassistant/components/rainforest_eagle/* @gtdiehl @jcalbert homeassistant/components/rainmachine/* @bachya homeassistant/components/random/* @fabaff +homeassistant/components/repetier/* @MTrab homeassistant/components/rfxtrx/* @danielhiversen +homeassistant/components/ring/* @balloob homeassistant/components/rmvtransport/* @cgtobi -homeassistant/components/roomba/* @pschmitt -homeassistant/components/ruter/* @ludeeus +homeassistant/components/roku/* @ctalkington +homeassistant/components/roomba/* @pschmitt @cyr-ius @shenxn +homeassistant/components/safe_mode/* @home-assistant/core +homeassistant/components/saj/* @fredericvl +homeassistant/components/salt/* @bjornorri +homeassistant/components/samsungtv/* @escoand homeassistant/components/scene/* @home-assistant/core +homeassistant/components/schluter/* @prairieapps homeassistant/components/scrape/* @fabaff homeassistant/components/script/* @home-assistant/core +homeassistant/components/search/* @home-assistant/core +homeassistant/components/sense/* @kbickar homeassistant/components/sensibo/* @andrey-git +homeassistant/components/sentry/* @dcramer homeassistant/components/serial/* @fabaff +homeassistant/components/seven_segments/* @fabaff homeassistant/components/seventeentrack/* @bachya homeassistant/components/shell_command/* @home-assistant/core homeassistant/components/shiftr/* @fabaff homeassistant/components/shodan/* @fabaff +homeassistant/components/sighthound/* @robmarkcole +homeassistant/components/signal_messenger/* @bbernhard homeassistant/components/simplisafe/* @bachya +homeassistant/components/sinch/* @bendikrb +homeassistant/components/sisyphus/* @jkeljo +homeassistant/components/slide/* @ualex73 homeassistant/components/sma/* @kellerza +homeassistant/components/smarthab/* @outadoc homeassistant/components/smartthings/* @andrewsayre +homeassistant/components/smarty/* @z0mbieprocess +homeassistant/components/sms/* @ocalvo homeassistant/components/smtp/* @fabaff +homeassistant/components/solaredge_local/* @drobtravels @scheric +homeassistant/components/solarlog/* @Ernst79 +homeassistant/components/solax/* @squishykid +homeassistant/components/soma/* @ratsept +homeassistant/components/somfy/* @tetienne +homeassistant/components/sonarr/* @ctalkington +homeassistant/components/songpal/* @rytilahti homeassistant/components/sonos/* @amelchio homeassistant/components/spaceapi/* @fabaff +homeassistant/components/speedtestdotnet/* @rohankapoorcom homeassistant/components/spider/* @peternijssen +homeassistant/components/spotify/* @frenck homeassistant/components/sql/* @dgomes +homeassistant/components/squeezebox/* @rajlaud +homeassistant/components/starline/* @anonym-tsk homeassistant/components/statistics/* @fabaff homeassistant/components/stiebel_eltron/* @fucm -homeassistant/components/sun/* @home-assistant/core +homeassistant/components/stookalert/* @fwestenberg +homeassistant/components/stream/* @hunterjm +homeassistant/components/stt/* @pvizeli +homeassistant/components/suez_water/* @ooii +homeassistant/components/sun/* @Swamp-Ig homeassistant/components/supla/* @mwegrzynek +homeassistant/components/surepetcare/* @benleb 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_dsm/* @ProtoThis @Quentame homeassistant/components/synology_srm/* @aerialls homeassistant/components/syslog/* @fabaff -homeassistant/components/sytadin/* @gautric +homeassistant/components/tado/* @michaelarnauts @bdraco homeassistant/components/tahoma/* @philklei +homeassistant/components/tankerkoenig/* @guillempages homeassistant/components/tautulli/* @ludeeus homeassistant/components/tellduslive/* @fredrike -homeassistant/components/template/* @PhracturedBlue -homeassistant/components/tesla/* @zabuldon +homeassistant/components/template/* @PhracturedBlue @tetienne +homeassistant/components/tesla/* @zabuldon @alandtse 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/tmb/* @alemuro +homeassistant/components/todoist/* @boralyl homeassistant/components/toon/* @frenck +homeassistant/components/totalconnect/* @austinmroczek homeassistant/components/tplink/* @rytilahti homeassistant/components/traccar/* @ludeeus homeassistant/components/tradfri/* @ggravlingen -homeassistant/components/tts/* @robbiet480 +homeassistant/components/trafikverket_train/* @endor-force +homeassistant/components/transmission/* @engrbm87 @JPHutchins +homeassistant/components/tts/* @pvizeli +homeassistant/components/twentemilieu/* @frenck homeassistant/components/twilio_call/* @robbiet480 homeassistant/components/twilio_sms/* @robbiet480 -homeassistant/components/uber/* @robbiet480 +homeassistant/components/ubee/* @mzdrale homeassistant/components/unifi/* @kane610 +homeassistant/components/unifiled/* @florisvdk +homeassistant/components/upc_connect/* @pvizeli homeassistant/components/upcloud/* @scop homeassistant/components/updater/* @home-assistant/core -homeassistant/components/upnp/* @robbiet480 +homeassistant/components/upnp/* @StevenLooman homeassistant/components/uptimerobot/* @ludeeus +homeassistant/components/usgs_earthquakes_feed/* @exxamalte homeassistant/components/utility_meter/* @dgomes +homeassistant/components/velbus/* @Cereal2nd @brefra homeassistant/components/velux/* @Julius2342 +homeassistant/components/vera/* @vangorra +homeassistant/components/versasense/* @flamm3blemuff1n homeassistant/components/version/* @fabaff +homeassistant/components/vesync/* @markperdue @webdjoe +homeassistant/components/vicare/* @oischinger +homeassistant/components/vilfo/* @ManneW +homeassistant/components/vivotek/* @HarlemSquirrel homeassistant/components/vizio/* @raman325 +homeassistant/components/vlc_telnet/* @rodripf homeassistant/components/waqi/* @andrey-git +homeassistant/components/watson_tts/* @rutkai homeassistant/components/weather/* @fabaff -homeassistant/components/weblink/* @home-assistant/core +homeassistant/components/webostv/* @bendavid homeassistant/components/websocket_api/* @home-assistant/core homeassistant/components/wemo/* @sqldiablo +homeassistant/components/withings/* @vangorra +homeassistant/components/wled/* @frenck +homeassistant/components/workday/* @fabaff homeassistant/components/worldclock/* @fabaff +homeassistant/components/wwlln/* @bachya +homeassistant/components/xbox_live/* @MartinHjelmare homeassistant/components/xfinity/* @cisasteelersfan homeassistant/components/xiaomi_aqara/* @danielhiversen @syssi homeassistant/components/xiaomi_miio/* @rytilahti @syssi -homeassistant/components/xiaomi_tv/* @fattdev +homeassistant/components/xiaomi_tv/* @simse homeassistant/components/xmpp/* @fabaff @flowolf homeassistant/components/yamaha_musiccast/* @jalmeroth +homeassistant/components/yandex_transport/* @rishatik92 homeassistant/components/yeelight/* @rytilahti @zewelor homeassistant/components/yeelightsunflower/* @lindsaymarkward homeassistant/components/yessssms/* @flowolf homeassistant/components/yi/* @bachya -homeassistant/components/zeroconf/* @robbiet480 +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/CONTRIBUTING.md b/CONTRIBUTING.md index fbe77c7756fd8..1921e5d38dd14 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,7 +4,7 @@ Everybody is invited and welcome to contribute to Home Assistant. There is a lot 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) + - 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 and 1) - 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. @@ -12,3 +12,7 @@ The process is straight-forward. Still interested? Then you should take a peek at the [developer documentation](https://developers.home-assistant.io/) to get more details. +## Feature suggestions + +If you want to suggest a new feature for Home Assistant (e.g., new integrations), please open a thread in our [Community Forum: Feature Requests](https://community.home-assistant.io/c/feature-requests). +We use [GitHub for tracking issues](https://github.com/home-assistant/home-assistant/issues), not for tracking feature requests. \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 98a45abf0ea16..4646e9f01f1a7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,35 +1,21 @@ -# 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. +ARG BUILD_FROM +FROM ${BUILD_FROM} -FROM python:3.7 -LABEL maintainer="Paulus Schoutsen " +ENV \ + S6_SERVICES_GRACETIME=60000 -# 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 +WORKDIR /usr/src -VOLUME /config +## Setup Home Assistant +COPY . homeassistant/ +RUN \ + pip3 install --no-cache-dir --no-index --only-binary=:all: --find-links "${WHEELS_LINKS}" \ + -r homeassistant/requirements_all.txt -c homeassistant/homeassistant/package_constraints.txt \ + && pip3 install --no-cache-dir --no-index --only-binary=:all: --find-links "${WHEELS_LINKS}" \ + -e ./homeassistant \ + && python3 -m compileall homeassistant/homeassistant -WORKDIR /usr/src/app +# Home Assistant S6-Overlay +COPY rootfs / -# Copy build scripts -COPY virtualization/Docker/ virtualization/Docker/ -RUN virtualization/Docker/setup_docker_prereqs - -# Install hass component dependencies -COPY requirements_all.txt requirements_all.txt -# Uninstall enum34 because some dependencies install it but breaks Python 3.4+. -# See PR #8103 for more info. -RUN pip3 install --no-cache-dir -r requirements_all.txt && \ - pip3 install --no-cache-dir mysqlclient psycopg2 uvloop==0.12.2 cchardet cython tensorflow - -# Copy source -COPY . . - -CMD [ "python", "-m", "homeassistant", "--config", "/config" ] +WORKDIR /config diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 0000000000000..be8e222339041 --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,31 @@ +FROM python:3.8 + +RUN \ + apt-get update && apt-get install -y --no-install-recommends \ + libudev-dev \ + libavformat-dev \ + libavcodec-dev \ + libavdevice-dev \ + libavutil-dev \ + libswscale-dev \ + libswresample-dev \ + libavfilter-dev \ + git \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /usr/src + +# Setup hass-release +RUN git clone --depth 1 https://github.com/home-assistant/hass-release \ + && pip3 install -e hass-release/ + +WORKDIR /workspaces + +# Install Python dependencies from requirements +COPY requirements_test.txt requirements_test_pre_commit.txt homeassistant/package_constraints.txt ./ +RUN pip3 install -r requirements_test.txt -c package_constraints.txt \ + && rm -f requirements_test.txt package_constraints.txt requirements_test_pre_commit.txt + +# Set the default shell to bash instead of sh +ENV SHELL /bin/bash diff --git a/README.rst b/README.rst index 941a463fb3741..0de30d43c650f 100644 --- a/README.rst +++ b/README.rst @@ -1,14 +1,7 @@ -Home Assistant |Build Status| |CI Status| |Coverage Status| |Chat Status| +Home Assistant |Chat Status| ================================================================================= -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: - -.. code:: bash - - python3 -m pip install homeassistant - hass --open-ui +Open source home automation that puts local control and privacy first. Powered by a worldwide community of tinkerers and DIY enthusiasts. Perfect to run on a Raspberry Pi or a local server. Check out `home-assistant.io `__ for `a demo `__, `installation instructions `__, @@ -27,15 +20,9 @@ components `__ of our website for further help and information. -.. |Build Status| image:: https://travis-ci.org/home-assistant/home-assistant.svg?branch=dev - :target: https://travis-ci.org/home-assistant/home-assistant -.. |CI Status| image:: https://circleci.com/gh/home-assistant/home-assistant.svg?style=shield - :target: https://circleci.com/gh/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 .. |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/ + :target: https://home-assistant.io/integrations/ diff --git a/azure-pipelines-ci.yml b/azure-pipelines-ci.yml new file mode 100644 index 0000000000000..da60db941dac2 --- /dev/null +++ b/azure-pipelines-ci.yml @@ -0,0 +1,242 @@ +# https://dev.azure.com/home-assistant + +trigger: + batch: true + branches: + include: + - rc + - dev + - master +pr: + - rc + - dev + - master + +resources: + containers: + - container: 37 + image: homeassistant/ci-azure:3.7 + - container: 38 + image: homeassistant/ci-azure:3.8 + repositories: + - repository: azure + type: github + name: "home-assistant/ci-azure" + endpoint: "home-assistant" +variables: + - name: PythonMain + value: "37" + - name: versionHadolint + value: "v1.17.6" + +stages: + - stage: "Overview" + jobs: + - job: "Lint" + pool: + vmImage: "ubuntu-latest" + container: $[ variables['PythonMain'] ] + steps: + - template: templates/azp-step-cache.yaml@azure + parameters: + keyfile: "requirements_test.txt | homeassistant/package_constraints.txt" + build: | + python -m venv venv + + . venv/bin/activate + pip install -r requirements_test.txt -c homeassistant/package_constraints.txt + pre-commit install-hooks + - script: | + . venv/bin/activate + pre-commit run --hook-stage manual check-executables-have-shebangs --all-files + displayName: "Run executables check" + - script: | + . venv/bin/activate + pre-commit run codespell --all-files + displayName: "Run codespell" + - script: | + . venv/bin/activate + pre-commit run flake8 --all-files + displayName: "Run flake8" + - script: | + . venv/bin/activate + pre-commit run bandit --all-files + displayName: "Run bandit" + - script: | + . venv/bin/activate + pre-commit run isort --all-files --show-diff-on-failure + displayName: "Run isort" + - script: | + . venv/bin/activate + pre-commit run check-json --all-files + displayName: "Run check-json" + - script: | + . venv/bin/activate + pre-commit run yamllint --all-files + displayName: "Run yamllint" + - script: | + . venv/bin/activate + pre-commit run pyupgrade --all-files --show-diff-on-failure + displayName: "Run pyupgrade" + # Prettier seems to hang on Azure, unknown why yet. + # Temporarily disable the check to no block PRs + # - script: | + # . venv/bin/activate + # pre-commit run prettier --all-files --show-diff-on-failure + # displayName: 'Run prettier' + - job: "Validate" + pool: + vmImage: "ubuntu-latest" + container: $[ variables['PythonMain'] ] + steps: + - template: templates/azp-step-cache.yaml@azure + parameters: + keyfile: "homeassistant/package_constraints.txt" + build: | + python -m venv venv + + . venv/bin/activate + pip install -e . + - script: | + . venv/bin/activate + python -m script.hassfest --action validate + displayName: "Validate manifests" + - script: | + . venv/bin/activate + ./script/gen_requirements_all.py validate + displayName: "requirements_all validate" + - job: "CheckFormat" + pool: + vmImage: "ubuntu-latest" + container: $[ variables['PythonMain'] ] + steps: + - template: templates/azp-step-cache.yaml@azure + parameters: + keyfile: "requirements_test.txt | homeassistant/package_constraints.txt" + build: | + python -m venv venv + + . venv/bin/activate + pip install -r requirements_test.txt -c homeassistant/package_constraints.txt + pre-commit install-hooks + - script: | + . venv/bin/activate + pre-commit run black --all-files --show-diff-on-failure + displayName: "Check Black formatting" + - job: "Docker" + pool: + vmImage: "ubuntu-latest" + steps: + - script: sudo docker pull hadolint/hadolint:$(versionHadolint) + displayName: "Install Hadolint" + - script: | + set -e + for dockerfile in Dockerfile Dockerfile.dev + do + echo "Linting: $dockerfile" + docker run --rm -i \ + -v "$(pwd)/.hadolint.yaml:/.hadolint.yaml:ro" \ + hadolint/hadolint:$(versionHadolint) < "$dockerfile" + done + displayName: "Run Hadolint" + + - stage: "Tests" + dependsOn: + - "Overview" + jobs: + - job: "PyTest" + pool: + vmImage: "ubuntu-latest" + strategy: + maxParallel: 3 + matrix: + Python37: + python.container: "37" + Python38: + python.container: "38" + container: $[ variables['python.container'] ] + steps: + - template: templates/azp-step-cache.yaml@azure + parameters: + keyfile: "requirements_test_all.txt | homeassistant/package_constraints.txt" + build: | + set -e + python -m venv venv + + . venv/bin/activate + pip install -U pip setuptools pytest-azurepipelines pytest-xdist -c homeassistant/package_constraints.txt + pip install -r requirements_test_all.txt -c homeassistant/package_constraints.txt + # This is a TEMP. Eventually we should make sure our 4 dependencies drop typing. + # Find offending deps with `pipdeptree -r -p typing` + pip uninstall -y typing + - script: | + . venv/bin/activate + pip install -e . + displayName: "Install Home Assistant" + - script: | + set -e + + . venv/bin/activate + pytest --timeout=9 --durations=10 -n auto --dist=loadfile -qq -o console_output_style=count -p no:sugar tests + script/check_dirty + displayName: "Run pytest for python $(python.container)" + condition: and(succeeded(), ne(variables['python.container'], variables['PythonMain'])) + - script: | + set -e + + . venv/bin/activate + pytest --timeout=9 --durations=10 -n auto --dist=loadfile --cov homeassistant --cov-report html -qq -o console_output_style=count -p no:sugar tests + codecov --token $(codecovToken) + script/check_dirty + displayName: "Run pytest for python $(python.container) / coverage" + condition: and(succeeded(), eq(variables['python.container'], variables['PythonMain'])) + + - stage: "FullCheck" + dependsOn: + - "Overview" + jobs: + - job: "Pylint" + pool: + vmImage: "ubuntu-latest" + container: $[ variables['PythonMain'] ] + steps: + - template: templates/azp-step-cache.yaml@azure + parameters: + keyfile: "requirements_all.txt | requirements_test.txt | homeassistant/package_constraints.txt" + build: | + set -e + python -m venv venv + + . venv/bin/activate + pip install -U pip setuptools wheel + pip install -r requirements_all.txt -c homeassistant/package_constraints.txt + pip install -r requirements_test.txt -c homeassistant/package_constraints.txt + # This is a TEMP. Eventually we should make sure our 4 dependencies drop typing. + # Find offending deps with `pipdeptree -r -p typing` + pip uninstall -y typing + - script: | + . venv/bin/activate + pip install -e . + displayName: "Install Home Assistant" + - script: | + . venv/bin/activate + pylint homeassistant + displayName: "Run pylint" + - job: "Mypy" + pool: + vmImage: "ubuntu-latest" + container: $[ variables['PythonMain'] ] + steps: + - template: templates/azp-step-cache.yaml@azure + parameters: + keyfile: "requirements_test.txt | setup.py | homeassistant/package_constraints.txt" + build: | + python -m venv venv + + . venv/bin/activate + pip install -e . -r requirements_test.txt -c homeassistant/package_constraints.txt + pre-commit install-hooks + - script: | + . venv/bin/activate + pre-commit run mypy --all-files + displayName: "Run mypy" diff --git a/azure-pipelines-release.yml b/azure-pipelines-release.yml new file mode 100644 index 0000000000000..7bf8e3ddfb23e --- /dev/null +++ b/azure-pipelines-release.yml @@ -0,0 +1,275 @@ +# https://dev.azure.com/home-assistant + +trigger: + tags: + include: + - '*' +pr: none +schedules: + - cron: "0 1 * * *" + displayName: "nightly builds" + branches: + include: + - dev + always: true +variables: + - name: versionBuilder + value: '7.2.0' + - group: docker + - group: github + - group: twine +resources: + repositories: + - repository: azure + type: github + name: 'home-assistant/ci-azure' + endpoint: 'home-assistant' + +stages: + +- stage: 'Validate' + jobs: + - template: templates/azp-job-version.yaml@azure + parameters: + ignoreDev: true + - job: 'Permission' + pool: + vmImage: 'ubuntu-latest' + steps: + - 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/core/releases/tags/${release} | jq --raw-output '.author.login')" + + if [[ "${created_by}" =~ ^(balloob|pvizeli|fabaff|robbiet480|bramkragten|frenck)$ ]]; then + exit 0 + fi + + echo "${created_by} is not allowed to create an release!" + exit 1 + displayName: 'Check rights' + condition: and(succeeded(), startsWith(variables['Build.SourceBranch'], 'refs/tags')) + +- stage: 'Build' + jobs: + - job: 'ReleasePython' + 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: 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' + 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,raspberrypi4,odroid-xu,tinker' + aarch64: + buildArch: 'aarch64' + buildMachine: 'qemuarm-64,raspberrypi3-64,raspberrypi4-64,odroid-c2,odroid-n2' + steps: + - template: templates/azp-step-ha-version.yaml@azure + - script: | + docker login -u $(dockerUser) -p $(dockerPassword) + displayName: 'Docker hub login' + - script: docker pull homeassistant/amd64-builder:$(versionBuilder) + displayName: 'Install Builder' + - script: | + set -e + + docker run --rm --privileged \ + -v ~/.docker:/root/.docker:rw \ + -v /run/docker.sock:/run/docker.sock:rw \ + -v $(pwd):/data:ro \ + homeassistant/amd64-builder:$(versionBuilder) \ + --generic $(homeassistantRelease) "--$(buildArch)" -t /data \ + + docker run --rm --privileged \ + -v ~/.docker:/root/.docker \ + -v /run/docker.sock:/run/docker.sock:rw \ + homeassistant/amd64-builder:$(versionBuilder) \ + --homeassistant-machine "$(homeassistantRelease)=$(buildMachine)" \ + -r https://github.com/home-assistant/hassio-homeassistant \ + -t machine --docker-hub homeassistant + displayName: 'Build Release' + +- stage: 'Publish' + jobs: + - job: 'ReleaseHassio' + pool: + vmImage: 'ubuntu-latest' + steps: + - template: templates/azp-step-ha-version.yaml@azure + - 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="$(homeassistantRelease)" + + 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" =~ d ]]; then + sed -i "s|$dev_version|$version|g" dev.json + elif [[ "$version" =~ b ]]; then + sed -i "s|$beta_version|$version|g" beta.json + else + 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" + - job: 'ReleaseDocker' + pool: + vmImage: 'ubuntu-latest' + steps: + - template: templates/azp-step-ha-version.yaml@azure + - script: | + docker login -u $(dockerUser) -p $(dockerPassword) + displayName: 'Docker login' + - script: | + set -e + export DOCKER_CLI_EXPERIMENTAL=enabled + + function create_manifest() { + local tag_l=$1 + local tag_r=$2 + + docker manifest create homeassistant/home-assistant:${tag_l} \ + homeassistant/amd64-homeassistant:${tag_r} \ + homeassistant/i386-homeassistant:${tag_r} \ + homeassistant/armhf-homeassistant:${tag_r} \ + homeassistant/armv7-homeassistant:${tag_r} \ + homeassistant/aarch64-homeassistant:${tag_r} + + docker manifest annotate homeassistant/home-assistant:${tag_l} \ + homeassistant/amd64-homeassistant:${tag_r} \ + --os linux --arch amd64 + + docker manifest annotate homeassistant/home-assistant:${tag_l} \ + homeassistant/i386-homeassistant:${tag_r} \ + --os linux --arch 386 + + docker manifest annotate homeassistant/home-assistant:${tag_l} \ + homeassistant/armhf-homeassistant:${tag_r} \ + --os linux --arch arm --variant=v6 + + docker manifest annotate homeassistant/home-assistant:${tag_l} \ + homeassistant/armv7-homeassistant:${tag_r} \ + --os linux --arch arm --variant=v7 + + docker manifest annotate homeassistant/home-assistant:${tag_l} \ + homeassistant/aarch64-homeassistant:${tag_r} \ + --os linux --arch arm64 --variant=v8 + + docker manifest push --purge homeassistant/home-assistant:${tag_l} + } + + docker pull homeassistant/amd64-homeassistant:$(homeassistantRelease) + docker pull homeassistant/i386-homeassistant:$(homeassistantRelease) + docker pull homeassistant/armhf-homeassistant:$(homeassistantRelease) + docker pull homeassistant/armv7-homeassistant:$(homeassistantRelease) + docker pull homeassistant/aarch64-homeassistant:$(homeassistantRelease) + + # Create version tag + create_manifest "$(homeassistantRelease)" "$(homeassistantRelease)" + + # Create general tags + if [[ "$(homeassistantRelease)" =~ d ]]; then + create_manifest "dev" "$(homeassistantRelease)" + elif [[ "$(homeassistantRelease)" =~ b ]]; then + create_manifest "beta" "$(homeassistantRelease)" + create_manifest "rc" "$(homeassistantRelease)" + else + create_manifest "stable" "$(homeassistantRelease)" + create_manifest "latest" "$(homeassistantRelease)" + create_manifest "beta" "$(homeassistantRelease)" + create_manifest "rc" "$(homeassistantRelease)" + fi + + displayName: 'Create Meta-Image' + +- stage: 'Addidional' + jobs: + - job: 'Updater' + pool: + vmImage: 'ubuntu-latest' + variables: + - group: gcloud + steps: + - template: templates/azp-step-ha-version.yaml@azure + - script: | + set -e + + export CLOUDSDK_CORE_DISABLE_PROMPTS=1 + + curl -o google-cloud-sdk.tar.gz https://dl.google.com/dl/cloudsdk/release/google-cloud-sdk.tar.gz + tar -C . -xvf google-cloud-sdk.tar.gz + rm -f google-cloud-sdk.tar.gz + ./google-cloud-sdk/install.sh + displayName: 'Setup gCloud' + condition: eq(variables['homeassistantReleaseStable'], 'true') + - script: | + set -e + + export CLOUDSDK_CORE_DISABLE_PROMPTS=1 + + echo "$(gcloudAnalytic)" > gcloud_auth.json + ./google-cloud-sdk/bin/gcloud auth activate-service-account --key-file gcloud_auth.json + rm -f gcloud_auth.json + displayName: 'Auth gCloud' + condition: eq(variables['homeassistantReleaseStable'], 'true') + - script: | + set -e + + export CLOUDSDK_CORE_DISABLE_PROMPTS=1 + + ./google-cloud-sdk/bin/gcloud functions deploy Analytics-Receiver \ + --project home-assistant-analytics \ + --update-env-vars VERSION=$(homeassistantRelease) \ + --source gs://analytics-src/function-source.zip + displayName: 'Push details to updater' + condition: eq(variables['homeassistantReleaseStable'], 'true') diff --git a/azure-pipelines-translation.yml b/azure-pipelines-translation.yml new file mode 100644 index 0000000000000..9f4db8d2005db --- /dev/null +++ b/azure-pipelines-translation.yml @@ -0,0 +1,65 @@ +# https://dev.azure.com/home-assistant + +trigger: + batch: true + branches: + include: + - dev +pr: none +schedules: + - cron: "0 0 * * *" + displayName: "translation update" + branches: + include: + - dev + always: true +variables: +- group: translation +resources: + repositories: + - repository: azure + type: github + name: 'home-assistant/ci-azure' + endpoint: 'home-assistant' + + +jobs: + +- job: 'Upload' + pool: + vmImage: 'ubuntu-latest' + steps: + - task: UsePythonVersion@0 + displayName: 'Use Python 3.7' + inputs: + versionSpec: '3.7' + - script: | + export LOKALISE_TOKEN="$(lokaliseToken)" + export AZURE_BRANCH="$(Build.SourceBranchName)" + + python3 -m script.translations upload + displayName: 'Upload Translation' + +- job: 'Download' + dependsOn: + - 'Upload' + condition: or(eq(variables['Build.Reason'], 'Schedule'), eq(variables['Build.Reason'], 'Manual')) + pool: + vmImage: 'ubuntu-latest' + steps: + - task: UsePythonVersion@0 + displayName: 'Use Python 3.7' + inputs: + versionSpec: '3.7' + - template: templates/azp-step-git-init.yaml@azure + - script: | + export LOKALISE_TOKEN="$(lokaliseToken)" + + python3 -m script.translations download + displayName: 'Download Translation' + - script: | + git checkout dev + git add homeassistant + git commit -am "[ci skip] Translation update" + git push + displayName: 'Update translation' diff --git a/azure-pipelines-wheels.yml b/azure-pipelines-wheels.yml new file mode 100644 index 0000000000000..9bc22ae668982 --- /dev/null +++ b/azure-pipelines-wheels.yml @@ -0,0 +1,74 @@ +# https://dev.azure.com/home-assistant + +trigger: + branches: + include: + - dev + - rc + paths: + include: + - requirements_all.txt +pr: none +schedules: +- cron: '0 */4 * * *' + displayName: 'daily builds' + branches: + include: + - dev +variables: + - name: versionWheels + value: '1.10.1-3.7-alpine3.11' +resources: + repositories: + - repository: azure + type: github + name: 'home-assistant/ci-azure' + endpoint: 'home-assistant' + +jobs: +- template: templates/azp-job-wheels.yaml@azure + parameters: + builderVersion: '$(versionWheels)' + builderApk: '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;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev' + builderPip: 'Cython;numpy' + skipBinary: 'aiohttp' + wheelsRequirement: 'requirements_wheels.txt' + wheelsRequirementDiff: 'requirements_diff.txt' + wheelsConstraint: 'homeassistant/package_constraints.txt' + preBuild: + - 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/core/master/requirements_all.txt + fi + + requirement_files="requirements_wheels.txt requirements_diff.txt" + for requirement_file in ${requirement_files}; do + 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|# avion|avion|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} + sed -i "s|# py_noaa|py_noaa|g" ${requirement_file} + sed -i "s|# bme680|bme680|g" ${requirement_file} + sed -i "s|# python-gammu|python-gammu|g" ${requirement_file} + done + displayName: 'Prepare requirements files for Home Assistant wheels' diff --git a/azure-pipelines.yml b/azure-pipelines.yml deleted file mode 100644 index 0ca9425d00256..0000000000000 --- a/azure-pipelines.yml +++ /dev/null @@ -1,143 +0,0 @@ -# https://dev.azure.com/home-assistant - -trigger: - batch: true - branches: - include: - - dev - tags: - include: - - '*' - -variables: - - name: versionBuilder - value: '3.2' - - name: versionWheels - value: '0.3' - - group: docker - - group: wheels - -jobs: - -- job: 'Wheels' - condition: eq(variables['Build.SourceBranchName'], 'dev') - timeoutInMinutes: 360 - pool: - vmImage: 'ubuntu-16.04' - strategy: - maxParallel: 3 - matrix: - amd64: - buildArch: 'amd64' - i386: - buildArch: 'i386' - armhf: - buildArch: 'armhf' - armv7: - buildArch: 'armv7' - aarch64: - buildArch: 'aarch64' - steps: - - script: | - sudo apt-get install -y --no-install-recommends \ - qemu-user-static \ - binfmt-support - - 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_hassio.txt - - # Enable because we can build it - sed -i "s|# pytradfri|pytradfri|g" requirements_hassio.txt - sed -i "s|# pybluez|pybluez|g" requirements_hassio.txt - sed -i "s|# bluepy|bluepy|g" requirements_hassio.txt - sed -i "s|# beacontools|beacontools|g" requirements_hassio.txt - sed -i "s|# RPi.GPIO|RPi.GPIO|g" requirements_hassio.txt - sed -i "s|# raspihats|raspihats|g" requirements_hassio.txt - sed -i "s|# rpi-rf|rpi-rf|g" requirements_hassio.txt - sed -i "s|# blinkt|blinkt|g" requirements_hassio.txt - sed -i "s|# fritzconnection|fritzconnection|g" requirements_hassio.txt - sed -i "s|# pyuserinput|pyuserinput|g" requirements_hassio.txt - sed -i "s|# evdev|evdev|g" requirements_hassio.txt - sed -i "s|# smbus-cffi|smbus-cffi|g" requirements_hassio.txt - sed -i "s|# i2csense|i2csense|g" requirements_hassio.txt - sed -i "s|# python-eq3bt|python-eq3bt|g" requirements_hassio.txt - sed -i "s|# pycups|pycups|g" requirements_hassio.txt - sed -i "s|# homekit|homekit|g" requirements_hassio.txt - sed -i "s|# decora_wifi|decora_wifi|g" requirements_hassio.txt - sed -i "s|# decora|decora|g" requirements_hassio.txt - sed -i "s|# PySwitchbot|PySwitchbot|g" requirements_hassio.txt - sed -i "s|# pySwitchmate|pySwitchmate|g" requirements_hassio.txt - - # Disable because of error - sed -i "s|insteonplm|# insteonplm|g" requirements_hassio.txt - 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 https://wheels.hass.io \ - --requirement requirements_hassio.txt \ - --upload rsync \ - --remote wheels@$(wheelsHost):/opt/wheels - displayName: 'Run wheels build' - - -- job: 'Release' - condition: startsWith(variables['Build.SourceBranch'], 'refs/tags') - timeoutInMinutes: 120 - pool: - vmImage: 'ubuntu-16.04' - 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' diff --git a/build.json b/build.json new file mode 100644 index 0000000000000..b2c3cedc378bc --- /dev/null +++ b/build.json @@ -0,0 +1,14 @@ +{ + "image": "homeassistant/{arch}-homeassistant", + "build_from": { + "aarch64": "homeassistant/aarch64-homeassistant-base:7.2.0", + "armhf": "homeassistant/armhf-homeassistant-base:7.2.0", + "armv7": "homeassistant/armv7-homeassistant-base:7.2.0", + "amd64": "homeassistant/amd64-homeassistant-base:7.2.0", + "i386": "homeassistant/i386-homeassistant-base:7.2.0" + }, + "labels": { + "io.hass.type": "core" + }, + "version_tag": true +} diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000000000..7a9eea730d8fe --- /dev/null +++ b/codecov.yml @@ -0,0 +1,9 @@ +codecov: + branch: dev +coverage: + status: + project: + default: + target: 90 + threshold: 0.09 +comment: false diff --git a/docs/screenshot-components.png b/docs/screenshot-components.png index a98b3d41ab9b0..44dc373e285e0 100644 Binary files a/docs/screenshot-components.png and b/docs/screenshot-components.png differ diff --git a/docs/source/_ext/edit_on_github.py b/docs/source/_ext/edit_on_github.py index eef249a3f019f..1d40bfc33abc1 100644 --- a/docs/source/_ext/edit_on_github.py +++ b/docs/source/_ext/edit_on_github.py @@ -8,20 +8,19 @@ import os import warnings - -__licence__ = 'BSD (3 clause)' +__licence__ = "BSD (3 clause)" def get_github_url(app, view, path): - github_fmt = 'https://github.com/{}/{}/{}/{}{}' return ( - github_fmt.format(app.config.edit_on_github_project, view, - app.config.edit_on_github_branch, - app.config.edit_on_github_src_path, path)) + f"https://github.com/{app.config.edit_on_github_project}/" + f"{view}/{app.config.edit_on_github_branch}/" + f"{app.config.edit_on_github_src_path}{path}" + ) def html_page_context(app, pagename, templatename, context, doctree): - if templatename != 'page.html': + if templatename != "page.html": return if not app.config.edit_on_github_project: @@ -30,16 +29,16 @@ def html_page_context(app, pagename, templatename, context, doctree): if not doctree: warnings.warn("doctree is None") return - path = os.path.relpath(doctree.get('source'), app.builder.srcdir) - show_url = get_github_url(app, 'blob', path) - edit_url = get_github_url(app, 'edit', path) + path = os.path.relpath(doctree.get("source"), app.builder.srcdir) + show_url = get_github_url(app, "blob", path) + edit_url = get_github_url(app, "edit", path) - context['show_on_github_url'] = show_url - context['edit_on_github_url'] = edit_url + context["show_on_github_url"] = show_url + context["edit_on_github_url"] = edit_url def setup(app): - app.add_config_value('edit_on_github_project', '', True) - app.add_config_value('edit_on_github_branch', 'master', True) - app.add_config_value('edit_on_github_src_path', '', True) # 'eg' "docs/" - app.connect('html-page-context', html_page_context) + app.add_config_value("edit_on_github_project", "", True) + app.add_config_value("edit_on_github_branch", "master", True) + app.add_config_value("edit_on_github_src_path", "", True) # 'eg' "docs/" + app.connect("html-page-context", html_page_context) diff --git a/docs/source/api/auth.rst b/docs/source/api/auth.rst new file mode 100644 index 0000000000000..16a1dc69b6b01 --- /dev/null +++ b/docs/source/api/auth.rst @@ -0,0 +1,29 @@ +:mod:`homeassistant.auth` +========================= + +.. automodule:: homeassistant.auth + :members: + +homeassistant.auth.auth\_store +------------------------------ + +.. automodule:: homeassistant.auth.auth_store + :members: + :undoc-members: + :show-inheritance: + +homeassistant.auth.const +------------------------ + +.. automodule:: homeassistant.auth.const + :members: + :undoc-members: + :show-inheritance: + +homeassistant.auth.models +------------------------- + +.. automodule:: homeassistant.auth.models + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/api/bootstrap.rst b/docs/source/api/bootstrap.rst index 363f796996165..fdc0b1c731d97 100644 --- a/docs/source/api/bootstrap.rst +++ b/docs/source/api/bootstrap.rst @@ -1,7 +1,7 @@ .. _bootstrap_module: :mod:`homeassistant.bootstrap` -------------------------- +------------------------------ .. automodule:: homeassistant.bootstrap :members: diff --git a/docs/source/api/components.rst b/docs/source/api/components.rst new file mode 100644 index 0000000000000..a27f93765b45d --- /dev/null +++ b/docs/source/api/components.rst @@ -0,0 +1,170 @@ +:mod:`homeassistant.components` +=============================== + +air\_quality +-------------------------------------------- + +.. automodule:: homeassistant.components.air_quality + :members: + :undoc-members: + :show-inheritance: + +alarm\_control\_panel +-------------------------------------------- + +.. automodule:: homeassistant.components.alarm_control_panel + :members: + :undoc-members: + :show-inheritance: + +binary\_sensor +-------------------------------------------- + +.. automodule:: homeassistant.components.binary_sensor + :members: + :undoc-members: + :show-inheritance: + +camera +--------------------------- + +.. automodule:: homeassistant.components.camera + :members: + :undoc-members: + :show-inheritance: + +calendar +--------------------------- + +.. automodule:: homeassistant.components.calendar + :members: + :undoc-members: + :show-inheritance: + +climate +--------------------------- + +.. automodule:: homeassistant.components.climate + :members: + :undoc-members: + :show-inheritance: + +conversation +--------------------------- + +.. automodule:: homeassistant.components.conversation + :members: + :undoc-members: + :show-inheritance: + +cover +--------------------------- + +.. automodule:: homeassistant.components.cover + :members: + :undoc-members: + :show-inheritance: + +device\_tracker +--------------------------- + +.. automodule:: homeassistant.components.device_tracker + :members: + :undoc-members: + :show-inheritance: + +fan +--------------------------- + +.. automodule:: homeassistant.components.fan + :members: + :undoc-members: + :show-inheritance: + +light +--------------------------- + +.. automodule:: homeassistant.components.light + :members: + :undoc-members: + :show-inheritance: + +lock +--------------------------- + +.. automodule:: homeassistant.components.lock + :members: + :undoc-members: + :show-inheritance: + +media\_player +--------------------------- + +.. automodule:: homeassistant.components.media_player + :members: + :undoc-members: + :show-inheritance: + +notify +--------------------------- + +.. automodule:: homeassistant.components.notify + :members: + :undoc-members: + :show-inheritance: + +remote +--------------------------- + +.. automodule:: homeassistant.components.remote + :members: + :undoc-members: + :show-inheritance: + +switch +--------------------------- + +.. automodule:: homeassistant.components.switch + :members: + :undoc-members: + :show-inheritance: + +sensor +------------------------------------- + +.. automodule:: homeassistant.components.sensor + :members: + :undoc-members: + :show-inheritance: + +vacuum +------------------------------------- + +.. automodule:: homeassistant.components.vacuum + :members: + :undoc-members: + :show-inheritance: + +water\_heater +------------------------------------- + +.. automodule:: homeassistant.components.water_heater + :members: + :undoc-members: + :show-inheritance: + +weather +--------------------------- + +.. automodule:: homeassistant.components.weather + :members: + :undoc-members: + :show-inheritance: + +webhook +--------------------------- + +.. automodule:: homeassistant.components.webhook + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/api/config_entries.rst b/docs/source/api/config_entries.rst new file mode 100644 index 0000000000000..4a207b82e1658 --- /dev/null +++ b/docs/source/api/config_entries.rst @@ -0,0 +1,7 @@ +.. _config_entries_module: + +:mod:`homeassistant.config_entries` +----------------------------------- + +.. automodule:: homeassistant.config_entries + :members: diff --git a/docs/source/api/core.rst b/docs/source/api/core.rst index bbaf591052ca0..7928655b8a1c6 100644 --- a/docs/source/api/core.rst +++ b/docs/source/api/core.rst @@ -4,35 +4,4 @@ ------------------------- .. automodule:: homeassistant.core - -.. autoclass:: Config - :members: - -.. autoclass:: Event - :members: - -.. autoclass:: EventBus - :members: - -.. autoclass:: HomeAssistant - :members: - -.. autoclass:: State - :members: - -.. autoclass:: StateMachine - :members: - -.. autoclass:: ServiceCall - :members: - -.. autoclass:: ServiceRegistry - :members: - -Module contents ---------------- - -.. automodule:: homeassistant.core - :members: - :undoc-members: - :show-inheritance: + :members: \ No newline at end of file diff --git a/docs/source/api/data_entry_flow.rst b/docs/source/api/data_entry_flow.rst new file mode 100644 index 0000000000000..7252780b870b6 --- /dev/null +++ b/docs/source/api/data_entry_flow.rst @@ -0,0 +1,7 @@ +.. _data_entry_flow_module: + +:mod:`homeassistant.data_entry_flow` +----------------------------- + +.. automodule:: homeassistant.data_entry_flow + :members: diff --git a/docs/source/api/device_tracker.rst b/docs/source/api/device_tracker.rst deleted file mode 100644 index e3d65174815b1..0000000000000 --- a/docs/source/api/device_tracker.rst +++ /dev/null @@ -1,10 +0,0 @@ -.. _components_device_tracker_module: - -:mod:`homeassistant.components.device_tracker` ----------------------------------------------- - -.. automodule:: homeassistant.components.device_tracker - :members: - -.. autoclass:: Device - :members: diff --git a/docs/source/api/entity.rst b/docs/source/api/entity.rst deleted file mode 100644 index 99ae43dc3ae2c..0000000000000 --- a/docs/source/api/entity.rst +++ /dev/null @@ -1,12 +0,0 @@ -.. _helpers_entity_module: - -:mod:`homeassistant.helpers.entity` ------------------------------------ - -.. automodule:: homeassistant.helpers.entity - -.. autoclass:: Entity - :members: - -.. autoclass:: ToggleEntity - :members: diff --git a/docs/source/api/event.rst b/docs/source/api/event.rst deleted file mode 100644 index b1295b814092f..0000000000000 --- a/docs/source/api/event.rst +++ /dev/null @@ -1,20 +0,0 @@ -.. _helpers_event_module: - -:mod:`homeassistant.helpers.event` ----------------------------------- - -.. automodule:: homeassistant.helpers.event - -.. autofunction:: track_state_change - -.. autofunction:: track_point_in_time - -.. autofunction:: track_point_in_utc_time - -.. autofunction:: track_sunrise - -.. autofunction:: track_sunset - -.. autofunction:: track_utc_time_change - -.. autofunction:: track_time_change diff --git a/docs/source/api/exceptions.rst b/docs/source/api/exceptions.rst new file mode 100644 index 0000000000000..e2977c51daef0 --- /dev/null +++ b/docs/source/api/exceptions.rst @@ -0,0 +1,7 @@ +.. _exceptions_module: + +:mod:`homeassistant.exceptions` +------------------------------- + +.. automodule:: homeassistant.exceptions + :members: diff --git a/docs/source/api/helpers.rst b/docs/source/api/helpers.rst index 28f4059d60da8..1b0b529c6558a 100644 --- a/docs/source/api/helpers.rst +++ b/docs/source/api/helpers.rst @@ -1,287 +1,335 @@ -homeassistant.helpers package -============================= +:mod:`homeassistant.helpers` +============================ -Submodules ----------- +.. automodule:: homeassistant.helpers + :members: + :undoc-members: + :show-inheritance: -homeassistant.helpers.aiohttp_client module -------------------------------------------- +homeassistant.helpers.aiohttp\_client +------------------------------------- .. automodule:: homeassistant.helpers.aiohttp_client - :members: - :undoc-members: - :show-inheritance: - + :members: + :undoc-members: + :show-inheritance: -homeassistant.helpers.area_registry module ------------------------------------------- +homeassistant.helpers.area\_registry +------------------------------------ .. automodule:: homeassistant.helpers.area_registry - :members: - :undoc-members: - :show-inheritance: + :members: + :undoc-members: + :show-inheritance: -homeassistant.helpers.condition module --------------------------------------- +homeassistant.helpers.check\_config +----------------------------------- + +.. automodule:: homeassistant.helpers.check_config + :members: + :undoc-members: + :show-inheritance: + +homeassistant.helpers.collection +-------------------------------- + +.. automodule:: homeassistant.helpers.collection + :members: + :undoc-members: + :show-inheritance: + +homeassistant.helpers.condition +------------------------------- .. automodule:: homeassistant.helpers.condition - :members: - :undoc-members: - :show-inheritance: + :members: + :undoc-members: + :show-inheritance: -homeassistant.helpers.config_entry_flow module ----------------------------------------------- +homeassistant.helpers.config\_entry\_flow +----------------------------------------- .. automodule:: homeassistant.helpers.config_entry_flow - :members: - :undoc-members: - :show-inheritance: + :members: + :undoc-members: + :show-inheritance: + +homeassistant.helpers.config\_entry\_oauth2\_flow +------------------------------------------------- -homeassistant.helpers.config_validation module ----------------------------------------------- +.. automodule:: homeassistant.helpers.config_entry_oauth2_flow + :members: + :undoc-members: + :show-inheritance: + +homeassistant.helpers.config\_validation +---------------------------------------- .. automodule:: homeassistant.helpers.config_validation - :members: - :undoc-members: - :show-inheritance: + :members: + :undoc-members: + :show-inheritance: -homeassistant.helpers.data_entry_flow module --------------------------------------------- +homeassistant.helpers.data\_entry\_flow +--------------------------------------- .. automodule:: homeassistant.helpers.data_entry_flow - :members: - :undoc-members: - :show-inheritance: + :members: + :undoc-members: + :show-inheritance: -homeassistant.helpers.deprecation module ----------------------------------------- +homeassistant.helpers.debounce +------------------------------ -.. automodule:: homeassistant.helpers.depracation - :members: - :undoc-members: - :show-inheritance: +.. automodule:: homeassistant.helpers.debounce + :members: + :undoc-members: + :show-inheritance: -homeassistant.helpers.device_registry module --------------------------------------------- +homeassistant.helpers.deprecation +--------------------------------- -.. automodule:: homeassistant.helpers.device_registry - :members: - :undoc-members: - :show-inheritance: +.. automodule:: homeassistant.helpers.deprecation + :members: + :undoc-members: + :show-inheritance: -homeassistant.helpers.discovery module +homeassistant.helpers.device\_registry -------------------------------------- +.. automodule:: homeassistant.helpers.device_registry + :members: + :undoc-members: + :show-inheritance: + +homeassistant.helpers.discovery +------------------------------- + .. automodule:: homeassistant.helpers.discovery - :members: - :undoc-members: - :show-inheritance: + :members: + :undoc-members: + :show-inheritance: -homeassistant.helpers.dispatcher module ---------------------------------------- +homeassistant.helpers.dispatcher +-------------------------------- .. automodule:: homeassistant.helpers.dispatcher - :members: - :undoc-members: - :show-inheritance: + :members: + :undoc-members: + :show-inheritance: -homeassistant.helpers.entity module ------------------------------------ +homeassistant.helpers.entity +---------------------------- .. automodule:: homeassistant.helpers.entity - :members: - :undoc-members: - :show-inheritance: + :members: + :undoc-members: + :show-inheritance: -homeassistant.helpers.entity_component module ---------------------------------------------- +homeassistant.helpers.entity\_component +--------------------------------------- .. automodule:: homeassistant.helpers.entity_component - :members: - :undoc-members: - :show-inheritance: + :members: + :undoc-members: + :show-inheritance: -homeassistant.helpers.entity_platform module --------------------------------------------- +homeassistant.helpers.entity\_platform +-------------------------------------- .. automodule:: homeassistant.helpers.entity_platform - :members: - :undoc-members: - :show-inheritance: + :members: + :undoc-members: + :show-inheritance: -homeassistant.helpers.entity_registry module --------------------------------------------- +homeassistant.helpers.entity\_registry +-------------------------------------- .. automodule:: homeassistant.helpers.entity_registry - :members: - :undoc-members: - :show-inheritance: + :members: + :undoc-members: + :show-inheritance: -homeassistant.helpers.entity_values module ------------------------------------------- +homeassistant.helpers.entity\_values +------------------------------------ .. automodule:: homeassistant.helpers.entity_values - :members: - :undoc-members: - :show-inheritance: + :members: + :undoc-members: + :show-inheritance: -homeassistant.helpers.entityfilter module ------------------------------------------ +homeassistant.helpers.entityfilter +---------------------------------- .. automodule:: homeassistant.helpers.entityfilter - :members: - :undoc-members: - :show-inheritance: + :members: + :undoc-members: + :show-inheritance: -homeassistant.helpers.event module ----------------------------------- +homeassistant.helpers.event +--------------------------- .. automodule:: homeassistant.helpers.event - :members: - :undoc-members: - :show-inheritance: + :members: + :undoc-members: + :show-inheritance: -homeassistant.helpers.icon module ---------------------------------- +homeassistant.helpers.icon +-------------------------- .. automodule:: homeassistant.helpers.icon - :members: - :undoc-members: - :show-inheritance: + :members: + :undoc-members: + :show-inheritance: -homeassistant.helpers.intent module ------------------------------------ +homeassistant.helpers.integration\_platform +------------------------------------------- + +.. automodule:: homeassistant.helpers.integration_platform + :members: + :undoc-members: + :show-inheritance: + +homeassistant.helpers.intent +---------------------------- .. automodule:: homeassistant.helpers.intent - :members: - :undoc-members: - :show-inheritance: + :members: + :undoc-members: + :show-inheritance: -homeassistant.helpers.json module ---------------------------------- +homeassistant.helpers.json +-------------------------- .. automodule:: homeassistant.helpers.json - :members: - :undoc-members: - :show-inheritance: + :members: + :undoc-members: + :show-inheritance: -homeassistant.helpers.location module -------------------------------------- +homeassistant.helpers.location +------------------------------ .. automodule:: homeassistant.helpers.location - :members: - :undoc-members: - :show-inheritance: + :members: + :undoc-members: + :show-inheritance: -homeassistant.helpers.logging module ------------------------------------- +homeassistant.helpers.logging +----------------------------- .. automodule:: homeassistant.helpers.logging - :members: - :undoc-members: - :show-inheritance: + :members: + :undoc-members: + :show-inheritance: -homeassistant.helpers.restore_state module ------------------------------------------- +homeassistant.helpers.network +----------------------------- + +.. automodule:: homeassistant.helpers.network + :members: + :undoc-members: + :show-inheritance: + +homeassistant.helpers.restore\_state +------------------------------------ .. automodule:: homeassistant.helpers.restore_state - :members: - :undoc-members: - :show-inheritance: + :members: + :undoc-members: + :show-inheritance: -homeassistant.helpers.script module ------------------------------------ +homeassistant.helpers.script +---------------------------- .. automodule:: homeassistant.helpers.script - :members: - :undoc-members: - :show-inheritance: + :members: + :undoc-members: + :show-inheritance: -homeassistant.helpers.service module ------------------------------------- +homeassistant.helpers.service +----------------------------- .. automodule:: homeassistant.helpers.service - :members: - :undoc-members: - :show-inheritance: + :members: + :undoc-members: + :show-inheritance: -homeassistant.helpers.signal module ------------------------------------ +homeassistant.helpers.signal +----------------------------- .. automodule:: homeassistant.helpers.signal - :members: - :undoc-members: - :show-inheritance: + :members: + :undoc-members: + :show-inheritance: -homeassistant.helpers.state module ----------------------------------- +homeassistant.helpers.state +--------------------------- .. automodule:: homeassistant.helpers.state - :members: - :undoc-members: - :show-inheritance: + :members: + :undoc-members: + :show-inheritance: -homeassistant.helpers.storage module ------------------------------------- +homeassistant.helpers.storage +----------------------------- .. automodule:: homeassistant.helpers.storage - :members: - :undoc-members: - :show-inheritance: + :members: + :undoc-members: + :show-inheritance: -homeassistant.helpers.sun module --------------------------------- +homeassistant.helpers.sun +------------------------- .. automodule:: homeassistant.helpers.sun - :members: - :undoc-members: - :show-inheritance: + :members: + :undoc-members: + :show-inheritance: -homeassistant.helpers.system_info module ----------------------------------------- +homeassistant.helpers.system\_info +---------------------------------- .. automodule:: homeassistant.helpers.system_info - :members: - :undoc-members: - :show-inheritance: + :members: + :undoc-members: + :show-inheritance: -homeassistant.helpers.temperature module ----------------------------------------- +homeassistant.helpers.temperature +--------------------------------- .. automodule:: homeassistant.helpers.temperature - :members: - :undoc-members: - :show-inheritance: + :members: + :undoc-members: + :show-inheritance: -homeassistant.helpers.template module -------------------------------------- +homeassistant.helpers.template +------------------------------ .. automodule:: homeassistant.helpers.template - :members: - :undoc-members: - :show-inheritance: + :members: + :undoc-members: + :show-inheritance: -homeassistant.helpers.translation module ------------------------------------------ +homeassistant.helpers.translation +--------------------------------- .. automodule:: homeassistant.helpers.translation - :members: - :undoc-members: - :show-inheritance: + :members: + :undoc-members: + :show-inheritance: -homeassistant.helpers.typing module ------------------------------------ +homeassistant.helpers.typing +---------------------------- .. automodule:: homeassistant.helpers.typing - :members: - :undoc-members: - :show-inheritance: + :members: + :undoc-members: + :show-inheritance: +homeassistant.helpers.update\_coordinator +----------------------------------------- -Module contents ---------------- - -.. automodule:: homeassistant.helpers - :members: - :undoc-members: - :show-inheritance: +.. automodule:: homeassistant.helpers.update_coordinator + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/api/homeassistant.rst b/docs/source/api/homeassistant.rst deleted file mode 100644 index 599f5fb801957..0000000000000 --- a/docs/source/api/homeassistant.rst +++ /dev/null @@ -1,70 +0,0 @@ -homeassistant package -===================== - -Subpackages ------------ - -.. toctree:: - - helpers - util - -Submodules ----------- - -bootstrap module ------------------------------- - -.. automodule:: homeassistant.bootstrap - :members: - :undoc-members: - :show-inheritance: - -config module ---------------------------- - -.. automodule:: homeassistant.config - :members: - :undoc-members: - :show-inheritance: - -const module --------------------------- - -.. automodule:: homeassistant.const - :members: - :undoc-members: - :show-inheritance: - -core module -------------------------- - -.. automodule:: homeassistant.core - :members: - :undoc-members: - :show-inheritance: - -exceptions module -------------------------------- - -.. automodule:: homeassistant.exceptions - :members: - :undoc-members: - :show-inheritance: - -loader module ---------------------------- - -.. automodule:: homeassistant.loader - :members: - :undoc-members: - :show-inheritance: - - -Module contents ---------------- - -.. automodule:: homeassistant - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/api/loader.rst b/docs/source/api/loader.rst new file mode 100644 index 0000000000000..91594a8a77424 --- /dev/null +++ b/docs/source/api/loader.rst @@ -0,0 +1,7 @@ +.. _loader_module: + +:mod:`homeassistant.loader` +--------------------------- + +.. automodule:: homeassistant.loader + :members: diff --git a/docs/source/api/util.rst b/docs/source/api/util.rst index fb61cd94fe622..52ae8eacdd3e0 100644 --- a/docs/source/api/util.rst +++ b/docs/source/api/util.rst @@ -1,86 +1,159 @@ -homeassistant.util package -========================== +:mod:`homeassistant.util` +========================= -Submodules ----------- +.. automodule:: homeassistant.util + :members: + :undoc-members: + :show-inheritance: -homeassistant.util.async_ module -------------------------------- +homeassistant.util.yaml +----------------------- + +.. automodule:: homeassistant.util.yaml + :members: + :undoc-members: + :show-inheritance: + +homeassistant.util.aiohttp +-------------------------- + +.. automodule:: homeassistant.util.aiohttp + :members: + :undoc-members: + :show-inheritance: + +homeassistant.util.async\_ +-------------------------- .. automodule:: homeassistant.util.async_ - :members: - :undoc-members: - :show-inheritance: + :members: + :undoc-members: + :show-inheritance: -homeassistant.util.color module -------------------------------- +homeassistant.util.color +------------------------ .. automodule:: homeassistant.util.color - :members: - :undoc-members: - :show-inheritance: + :members: + :undoc-members: + :show-inheritance: -homeassistant.util.distance module ----------------------------------- +homeassistant.util.decorator +---------------------------- + +.. automodule:: homeassistant.util.decorator + :members: + :undoc-members: + :show-inheritance: + +homeassistant.util.distance +--------------------------- .. automodule:: homeassistant.util.distance - :members: - :undoc-members: - :show-inheritance: + :members: + :undoc-members: + :show-inheritance: -homeassistant.util.dt module ----------------------------- +homeassistant.util.dt +--------------------- .. automodule:: homeassistant.util.dt - :members: - :undoc-members: - :show-inheritance: + :members: + :undoc-members: + :show-inheritance: -homeassistant.util.location module ----------------------------------- +homeassistant.util.json +----------------------- + +.. automodule:: homeassistant.util.json + :members: + :undoc-members: + :show-inheritance: + +homeassistant.util.location +--------------------------- .. automodule:: homeassistant.util.location - :members: - :undoc-members: - :show-inheritance: + :members: + :undoc-members: + :show-inheritance: + +homeassistant.util.logging +-------------------------- + +.. automodule:: homeassistant.util.logging + :members: + :undoc-members: + :show-inheritance: -homeassistant.util.package module ---------------------------------- +homeassistant.util.network +-------------------------- + +.. automodule:: homeassistant.util.network + :members: + :undoc-members: + :show-inheritance: + +homeassistant.util.package +-------------------------- .. automodule:: homeassistant.util.package - :members: - :undoc-members: - :show-inheritance: + :members: + :undoc-members: + :show-inheritance: -homeassistant.util.temperature module -------------------------------------- +homeassistant.util.pil +---------------------- -.. automodule:: homeassistant.util.temperature - :members: - :undoc-members: - :show-inheritance: +.. automodule:: homeassistant.util.pil + :members: + :undoc-members: + :show-inheritance: -homeassistant.util.unit_system module -------------------------------------- +homeassistant.util.pressure +--------------------------- -.. automodule:: homeassistant.util.unit_system - :members: - :undoc-members: - :show-inheritance: +.. automodule:: homeassistant.util.pressure + :members: + :undoc-members: + :show-inheritance: + +homeassistant.util.ruamel\_yaml +------------------------------- + +.. automodule:: homeassistant.util.ruamel_yaml + :members: + :undoc-members: + :show-inheritance: + +homeassistant.util.ssl +---------------------- -homeassistant.util.yaml module +.. automodule:: homeassistant.util.ssl + :members: + :undoc-members: + :show-inheritance: + +homeassistant.util.temperature ------------------------------ -.. automodule:: homeassistant.util.yaml - :members: - :undoc-members: - :show-inheritance: +.. automodule:: homeassistant.util.temperature + :members: + :undoc-members: + :show-inheritance: +homeassistant.util.unit\_system +------------------------------- -Module contents ---------------- +.. automodule:: homeassistant.util.unit_system + :members: + :undoc-members: + :show-inheritance: -.. automodule:: homeassistant.util - :members: - :undoc-members: - :show-inheritance: +homeassistant.util.volume +------------------------- + +.. automodule:: homeassistant.util.volume + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/conf.py b/docs/source/conf.py index b5428ede8fa10..242a90088b363 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -1,5 +1,4 @@ #!/usr/bin/env python3 -# -*- coding: utf-8 -*- # # Home-Assistant documentation build configuration file, created by # sphinx-quickstart on Sun Aug 28 13:13:10 2016. @@ -17,31 +16,32 @@ # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. # -import sys -import os import inspect +import os +import sys -from homeassistant.const import __version__, __short_version__ +from homeassistant.const import __short_version__, __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' +PROJECT_NAME = "Home Assistant" +PROJECT_PACKAGE_NAME = "homeassistant" +PROJECT_AUTHOR = "The Home Assistant Authors" +PROJECT_COPYRIGHT = f" 2013-2020, {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) +GITHUB_PATH = f"{PROJECT_GITHUB_USERNAME}/{PROJECT_GITHUB_REPOSITORY}" +GITHUB_URL = f"https://github.com/{GITHUB_PATH}" -sys.path.insert(0, os.path.abspath('_ext')) -sys.path.insert(0, os.path.abspath('../homeassistant')) +sys.path.insert(0, os.path.abspath("_ext")) +sys.path.insert(0, os.path.abspath("../homeassistant")) # -- General configuration ------------------------------------------------ @@ -53,27 +53,27 @@ # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ - 'sphinx.ext.autodoc', - 'sphinx.ext.linkcode', - 'sphinx_autodoc_annotation', - 'edit_on_github' + "sphinx.ext.autodoc", + "sphinx.ext.linkcode", + "sphinx_autodoc_annotation", + "edit_on_github", ] # Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] +templates_path = ["_templates"] # The suffix(es) of source filenames. # You can specify multiple suffix as a list of string: # # source_suffix = ['.rst', '.md'] -source_suffix = '.rst' +source_suffix = ".rst" # The encoding of source files. # # source_encoding = 'utf-8-sig' # The master toctree document. -master_doc = 'index' +master_doc = "index" # General information about the project. project = PROJECT_NAME @@ -89,25 +89,25 @@ # The full version, including alpha/beta/rc tags. release = __version__ -code_branch = 'dev' if 'dev' in __version__ else 'master' +code_branch = "dev" if "dev" in __version__ else "master" # Edit on Github config edit_on_github_project = GITHUB_PATH edit_on_github_branch = code_branch -edit_on_github_src_path = 'docs/source/' +edit_on_github_src_path = "docs/source/" def linkcode_resolve(domain, info): """Determine the URL corresponding to Python object.""" - if domain != 'py': + if domain != "py": return None - modname = info['module'] - fullname = info['fullname'] + modname = info["module"] + fullname = info["fullname"] submod = sys.modules.get(modname) if submod is None: return None obj = submod - for part in fullname.split('.'): + for part in fullname.split("."): try: obj = getattr(obj, part) except: @@ -132,7 +132,8 @@ def linkcode_resolve(domain, info): fn = fn[index:] - return '{}/blob/{}/{}{}'.format(GITHUB_URL, code_branch, fn, linespec) + return f"{GITHUB_URL}/blob/{code_branch}/{fn}{linespec}" + # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. @@ -175,7 +176,7 @@ def linkcode_resolve(domain, info): # show_authors = False # The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' +pygments_style = "sphinx" # A list of ignored prefixes for module index sorting. # modindex_common_prefix = [] @@ -192,22 +193,22 @@ def linkcode_resolve(domain, info): # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # -html_theme = 'alabaster' +html_theme = "alabaster" # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. # html_theme_options = { - 'logo': 'logo.png', - 'logo_name': PROJECT_NAME, - 'description': PROJECT_LONG_DESCRIPTION, - 'github_user': PROJECT_GITHUB_USERNAME, - 'github_repo': PROJECT_GITHUB_REPOSITORY, - 'github_type': 'star', - 'github_banner': True, - 'travis_button': True, - 'touch_icon': 'logo-apple.png', + "logo": "logo.png", + "logo_name": PROJECT_NAME, + "description": PROJECT_LONG_DESCRIPTION, + "github_user": PROJECT_GITHUB_USERNAME, + "github_repo": PROJECT_GITHUB_REPOSITORY, + "github_type": "star", + "github_banner": True, + "travis_button": True, + "touch_icon": "logo-apple.png", # 'fixed_sidebar': True, # Re-enable when we have more content } @@ -233,12 +234,12 @@ def linkcode_resolve(domain, info): # This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. # -html_favicon = '_static/favicon.ico' +html_favicon = "_static/favicon.ico" # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] +html_static_path = ["_static"] # Add any extra paths that contain custom files (such as robots.txt or # .htaccess) here, relative to this directory. These files are copied @@ -250,7 +251,7 @@ def linkcode_resolve(domain, info): # bottom, using the given strftime format. # The empty string is equivalent to '%b %d, %Y'. # -html_last_updated_fmt = '%b %d, %Y' +html_last_updated_fmt = "%b %d, %Y" # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. @@ -260,13 +261,13 @@ def linkcode_resolve(domain, info): # Custom sidebar templates, maps document names to template names. # html_sidebars = { - '**': [ - 'about.html', - 'links.html', - 'searchbox.html', - 'sourcelink.html', - 'navigation.html', - 'relations.html' + "**": [ + "about.html", + "links.html", + "searchbox.html", + "sourcelink.html", + "navigation.html", + "relations.html", ] } @@ -327,34 +328,36 @@ def linkcode_resolve(domain, info): # html_search_scorer = 'scorer.js' # Output file base name for HTML help builder. -htmlhelp_basename = 'Home-Assistantdoc' +htmlhelp_basename = "Home-Assistantdoc" # -- Options for LaTeX output --------------------------------------------- latex_elements = { - # The paper size ('letterpaper' or 'a4paper'). - # - # 'papersize': 'letterpaper', - - # The font size ('10pt', '11pt' or '12pt'). - # - # 'pointsize': '10pt', - - # Additional stuff for the LaTeX preamble. - # - # 'preamble': '', - - # Latex figure (float) alignment - # - # 'figure_align': 'htbp', + # The paper size ('letterpaper' or 'a4paper'). + # + # 'papersize': 'letterpaper', + # The font size ('10pt', '11pt' or '12pt'). + # + # 'pointsize': '10pt', + # Additional stuff for the LaTeX preamble. + # + # 'preamble': '', + # Latex figure (float) alignment + # + # 'figure_align': 'htbp', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ - (master_doc, 'home-assistant.tex', 'Home Assistant Documentation', - 'Home Assistant Team', 'manual'), + ( + master_doc, + "home-assistant.tex", + "Home Assistant Documentation", + "Home Assistant Team", + "manual", + ) ] # The name of an image file (relative to this directory) to place at the top of @@ -395,8 +398,7 @@ def linkcode_resolve(domain, info): # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ - (master_doc, 'home-assistant', 'Home Assistant Documentation', - [author], 1) + (master_doc, "home-assistant", "Home Assistant Documentation", [author], 1) ] # If true, show URL addresses after external links. @@ -410,9 +412,15 @@ def linkcode_resolve(domain, info): # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - (master_doc, 'Home-Assistant', 'Home Assistant Documentation', - author, 'Home Assistant', 'Open-source home automation platform.', - 'Miscellaneous'), + ( + master_doc, + "Home-Assistant", + "Home Assistant Documentation", + author, + "Home Assistant", + "Open-source home automation platform.", + "Miscellaneous", + ) ] # Documents to append as an appendix to all manuals. diff --git a/homeassistant/__main__.py b/homeassistant/__main__.py index 023faadef0c65..728ee3c5985ef 100644 --- a/homeassistant/__main__.py +++ b/homeassistant/__main__.py @@ -1,83 +1,70 @@ """Start Home Assistant.""" -from __future__ import print_function - import argparse +import asyncio import os import platform import subprocess import sys import threading -from typing import ( # noqa pylint: disable=unused-import - List, Dict, Any, TYPE_CHECKING -) - -from homeassistant import monkey_patch -from homeassistant.const import ( - __version__, - EVENT_HOMEASSISTANT_START, - REQUIRED_PYTHON_VER, - RESTART_EXIT_CODE, -) +from typing import List -if TYPE_CHECKING: - from homeassistant import core +from homeassistant.const import REQUIRED_PYTHON_VER, RESTART_EXIT_CODE, __version__ def set_loop() -> None: - """Attempt to use uvloop.""" - import asyncio + """Attempt to use different loop.""" + # pylint: disable=import-outside-toplevel from asyncio.events import BaseDefaultEventLoopPolicy - policy = None - - if sys.platform == 'win32': - if hasattr(asyncio, 'WindowsProactorEventLoopPolicy'): + 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.""" _loop_factory = asyncio.ProactorEventLoop policy = ProactorPolicy() - else: - try: - import uvloop - except ImportError: - pass - else: - policy = uvloop.EventLoopPolicy() - if policy is not None: asyncio.set_event_loop_policy(policy) def validate_python() -> None: """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)) + print( + "Home Assistant requires at least Python " + f"{REQUIRED_PYTHON_VER[0]}.{REQUIRED_PYTHON_VER[1]}.{REQUIRED_PYTHON_VER[2]}" + ) sys.exit(1) def ensure_config_path(config_dir: str) -> None: """Validate the configuration directory.""" + # pylint: disable=import-outside-toplevel import homeassistant.config as config_util - lib_dir = os.path.join(config_dir, 'deps') + + lib_dir = os.path.join(config_dir, "deps") # Test if configuration directory exists if not os.path.isdir(config_dir): if config_dir != config_util.get_default_config_dir(): - print(('Fatal Error: Specified configuration directory does ' - 'not exist {} ').format(config_dir)) + print( + f"Fatal Error: Specified configuration directory {config_dir} " + "does not exist" + ) sys.exit(1) try: os.mkdir(config_dir) except OSError: - print(('Fatal Error: Unable to create default configuration ' - 'directory {} ').format(config_dir)) + print( + "Fatal Error: Unable to create default configuration " + f"directory {config_dir}" + ) sys.exit(1) # Test if library directory exists @@ -85,93 +72,80 @@ def ensure_config_path(config_dir: str) -> None: try: os.mkdir(lib_dir) except OSError: - print(('Fatal Error: Unable to create library ' - 'directory {} ').format(lib_dir)) + print(f"Fatal Error: Unable to create library directory {lib_dir}") sys.exit(1) -async def ensure_config_file(hass: 'core.HomeAssistant', config_dir: str) \ - -> str: - """Ensure configuration file exists.""" - import homeassistant.config as config_util - config_path = await config_util.async_ensure_config_exists( - hass, config_dir) - - if config_path is None: - print('Error getting configuration path') - sys.exit(1) - - return config_path - - def get_arguments() -> argparse.Namespace: """Get parsed passed in arguments.""" + # pylint: disable=import-outside-toplevel import homeassistant.config as config_util + parser = argparse.ArgumentParser( - description="Home Assistant: Observe, Control, Automate.") - parser.add_argument('--version', action='version', version=__version__) + description="Home Assistant: Observe, Control, Automate." + ) + parser.add_argument("--version", action="version", version=__version__) parser.add_argument( - '-c', '--config', - metavar='path_to_config_dir', + "-c", + "--config", + metavar="path_to_config_dir", default=config_util.get_default_config_dir(), - help="Directory that contains the Home Assistant configuration") + help="Directory that contains the Home Assistant configuration", + ) parser.add_argument( - '--demo-mode', - action='store_true', - help='Start Home Assistant in demo mode') + "--safe-mode", action="store_true", help="Start Home Assistant in safe mode" + ) parser.add_argument( - '--debug', - action='store_true', - help='Start Home Assistant in debug mode') + "--debug", action="store_true", help="Start Home Assistant in debug mode" + ) parser.add_argument( - '--open-ui', - action='store_true', - help='Open the webinterface in a browser') + "--open-ui", action="store_true", help="Open the webinterface in a browser" + ) parser.add_argument( - '--skip-pip', - action='store_true', - help='Skips pip install of required packages on startup') + "--skip-pip", + action="store_true", + help="Skips pip install of required packages on startup", + ) parser.add_argument( - '-v', '--verbose', - action='store_true', - help="Enable verbose logging to file.") + "-v", "--verbose", action="store_true", help="Enable verbose logging to file." + ) parser.add_argument( - '--pid-file', - metavar='path_to_pid_file', + "--pid-file", + metavar="path_to_pid_file", default=None, - help='Path to PID file useful for running as daemon') + help="Path to PID file useful for running as daemon", + ) parser.add_argument( - '--log-rotate-days', + "--log-rotate-days", type=int, default=None, - help='Enables daily log rotation and keeps up to the specified days') + help="Enables daily log rotation and keeps up to the specified days", + ) parser.add_argument( - '--log-file', + "--log-file", type=str, default=None, - help='Log file to write to. If not set, CONFIG/home-assistant.log ' - 'is used') + 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") + "--log-no-color", action="store_true", help="Disable color logs" + ) parser.add_argument( - '--runner', - action='store_true', - help='On restart exit with code {}'.format(RESTART_EXIT_CODE)) + "--runner", + action="store_true", + help=f"On restart exit with code {RESTART_EXIT_CODE}", + ) parser.add_argument( - '--script', - nargs=argparse.REMAINDER, - help='Run one of the embedded scripts') + "--script", nargs=argparse.REMAINDER, help="Run one of the embedded scripts" + ) if os.name == "posix": parser.add_argument( - '--daemon', - action='store_true', - help='Run Home Assistant as daemon') + "--daemon", action="store_true", help="Run Home Assistant as daemon" + ) arguments = parser.parse_args() if os.name != "posix" or arguments.debug or arguments.runner: - setattr(arguments, 'daemon', False) + setattr(arguments, "daemon", False) return arguments @@ -192,8 +166,8 @@ def daemonize() -> None: sys.exit(0) # redirect standard file descriptors to devnull - infd = open(os.devnull, 'r') - outfd = open(os.devnull, 'a+') + infd = open(os.devnull) + outfd = open(os.devnull, "a+") sys.stdout.flush() sys.stderr.flush() os.dup2(infd.fileno(), sys.stdin.fileno()) @@ -205,9 +179,9 @@ def check_pid(pid_file: str) -> None: """Check that Home Assistant is not already running.""" # Check pid file try: - with open(pid_file, 'r') as file: + with open(pid_file) as file: pid = int(file.readline()) - except IOError: + except OSError: # PID File does not exist return @@ -220,7 +194,7 @@ def check_pid(pid_file: str) -> None: except OSError: # PID does not exist return - print('Fatal Error: HomeAssistant is already running.') + print("Fatal Error: Home Assistant is already running.") sys.exit(1) @@ -228,10 +202,10 @@ def write_pid(pid_file: str) -> None: """Create a PID File.""" pid = os.getpid() try: - with open(pid_file, 'w') as file: + with open(pid_file, "w") as file: file.write(str(pid)) - except IOError: - print('Fatal Error: Unable to write pid file {}'.format(pid_file)) + except OSError: + print(f"Fatal Error: Unable to write pid file {pid_file}") sys.exit(1) @@ -242,6 +216,7 @@ def closefds_osx(min_fd: int, max_fd: int) -> None: are guarded. But we can set the close-on-exec flag on everything we want to get rid of. """ + # pylint: disable=import-outside-toplevel from fcntl import fcntl, F_GETFD, F_SETFD, FD_CLOEXEC for _fd in range(min_fd, max_fd): @@ -249,61 +224,42 @@ def closefds_osx(min_fd: int, max_fd: int) -> None: val = fcntl(_fd, F_GETFD) if not val & FD_CLOEXEC: fcntl(_fd, F_SETFD, val | FD_CLOEXEC) - except IOError: + except OSError: pass def cmdline() -> List[str]: """Collect path and arguments to re-execute the current hass instance.""" - if os.path.basename(sys.argv[0]) == '__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 [arg for arg in sys.argv if arg != '--daemon'] - - -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': {} - } # 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 = await ensure_config_file(hass, config_dir) - print('Config directory:', config_dir) - 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: - # 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) - - run_callback_threadsafe( - hass.loop, - hass.bus.async_listen_once, - EVENT_HOMEASSISTANT_START, open_browser - ) + os.environ["PYTHONPATH"] = os.path.dirname(modulepath) + return [sys.executable] + [arg for arg in sys.argv if arg != "--daemon"] + + return [arg for arg in sys.argv if arg != "--daemon"] + + +async def setup_and_run_hass(config_dir: str, args: argparse.Namespace) -> int: + """Set up Home Assistant and run.""" + # pylint: disable=import-outside-toplevel + from homeassistant import bootstrap + + hass = await bootstrap.async_setup_hass( + config_dir=config_dir, + verbose=args.verbose, + log_rotate_days=args.log_rotate_days, + log_file=args.log_file, + log_no_color=args.log_no_color, + skip_pip=args.skip_pip, + safe_mode=args.safe_mode, + ) + + if hass is None: + return 1 + + if args.open_ui and hass.config.api is not None: + import webbrowser # pylint: disable=import-outside-toplevel + + hass.add_job(webbrowser.open, hass.config.api.base_url) return await hass.async_run() @@ -312,17 +268,17 @@ def try_to_restart() -> None: """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') + sys.stderr.write("Home Assistant attempting to restart.\n") # Count remaining threads, ideally there should only be one non-daemonized # thread left (which is us). Nothing we really do with it, but it might be # useful when debugging shutdown/restart issues. try: - nthreads = sum(thread.is_alive() and not thread.daemon - for thread in threading.enumerate()) + nthreads = sum( + thread.is_alive() and not thread.daemon for thread in threading.enumerate() + ) if nthreads > 1: - sys.stderr.write( - "Found {} non-daemonic threads.\n".format(nthreads)) + sys.stderr.write(f"Found {nthreads} non-daemonic threads.\n") # Somehow we sometimes seem to trigger an assertion in the python threading # module. It seems we find threads that have no associated OS level thread @@ -336,7 +292,7 @@ def try_to_restart() -> None: except ValueError: max_fd = 256 - if platform.system() == 'Darwin': + if platform.system() == "Darwin": closefds_osx(3, max_fd) else: os.closerange(3, max_fd) @@ -354,17 +310,11 @@ def main() -> int: """Start Home Assistant.""" 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'] + if os.name == "nt" and "--runner" not in sys.argv: + nt_args = cmdline() + ["--runner"] while True: try: subprocess.check_call(nt_args) @@ -378,10 +328,12 @@ def main() -> int: args = get_arguments() if args.script is not None: + # pylint: disable=import-outside-toplevel from homeassistant import scripts + return scripts.run(args.script) - config_dir = os.path.join(os.getcwd(), args.config) + config_dir = os.path.abspath(os.path.join(os.getcwd(), args.config)) ensure_config_path(config_dir) # Daemon functions @@ -392,12 +344,11 @@ def main() -> int: if args.pid_file: write_pid(args.pid_file) - from homeassistant.util.async_ import asyncio_run - exit_code = asyncio_run(setup_and_run_hass(config_dir, args)) + exit_code = asyncio.run(setup_and_run_hass(config_dir, args), debug=args.debug) if exit_code == RESTART_EXIT_CODE and not args.runner: try_to_restart() - return exit_code # type: ignore + return exit_code if __name__ == "__main__": diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index 9e4b9d09d7830..26bd10535d0ed 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -1,24 +1,24 @@ """Provide an authentication layer for Home Assistant.""" import asyncio -import logging from collections import OrderedDict from datetime import timedelta +import logging 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.core import HomeAssistant, callback 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 +from .mfa_modules import MultiFactorAuthModule, auth_mfa_module_from_config +from .providers import AuthProvider, LoginFlow, auth_provider_from_config -EVENT_USER_ADDED = 'user_added' -EVENT_USER_REMOVED = 'user_removed' +EVENT_USER_ADDED = "user_added" +EVENT_USER_REMOVED = "user_removed" _LOGGER = logging.getLogger(__name__) _MfaModuleDict = Dict[str, MultiFactorAuthModule] @@ -27,9 +27,10 @@ async def auth_manager_from_config( - hass: HomeAssistant, - provider_configs: List[Dict[str, Any]], - module_configs: List[Dict[str, Any]]) -> 'AuthManager': + 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 @@ -38,24 +39,27 @@ async def auth_manager_from_config( store = auth_store.AuthStore(hass) if provider_configs: providers = await asyncio.gather( - *[auth_provider_from_config(hass, store, config) - for config in provider_configs]) + *( + auth_provider_from_config(hass, store, config) + for config in provider_configs + ) + ) else: - providers = () + providers = [] # So returned auth providers are in same order as config - provider_hash = OrderedDict() # type: _ProviderDict + provider_hash: _ProviderDict = OrderedDict() 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]) + *(auth_mfa_module_from_config(hass, config) for config in module_configs) + ) else: - modules = () + modules = [] # So returned auth modules are in same order as config - module_hash = OrderedDict() # type: _MfaModuleDict + module_hash: _MfaModuleDict = OrderedDict() for module in modules: module_hash[module.id] = module @@ -63,32 +67,85 @@ async def auth_manager_from_config( return manager +class AuthManagerFlowManager(data_entry_flow.FlowManager): + """Manage authentication flows.""" + + def __init__(self, hass: HomeAssistant, auth_manager: "AuthManager"): + """Init auth manager flows.""" + super().__init__(hass) + self.auth_manager = auth_manager + + async def async_create_flow( + self, + handler_key: Any, + *, + context: Optional[Dict[str, Any]] = None, + data: Optional[Dict[str, Any]] = None, + ) -> data_entry_flow.FlowHandler: + """Create a login flow.""" + auth_provider = self.auth_manager.get_auth_provider(*handler_key) + if not auth_provider: + raise KeyError(f"Unknown auth provider {handler_key}") + return await auth_provider.async_login_flow(context) + + async def async_finish_flow( + self, flow: data_entry_flow.FlowHandler, result: Dict[str, Any] + ) -> Dict[str, Any]: + """Return a user as result of login flow.""" + flow = cast(LoginFlow, 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.auth_manager.get_auth_provider(*result["handler"]) + if not auth_provider: + raise KeyError(f"Unknown auth provider {result['handler']}") + + credentials = await auth_provider.async_get_or_create_credentials( + result["data"] + ) + + if 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.auth_manager.async_get_user_by_credentials(credentials) + if user is not None: + modules = await self.auth_manager.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.auth_manager.async_get_or_create_user(credentials) + return result + + class AuthManager: """Manage the authentication for Home Assistant.""" - def __init__(self, hass: HomeAssistant, store: auth_store.AuthStore, - providers: _ProviderDict, mfa_modules: _MfaModuleDict) \ - -> None: + 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 + self.login_flow = AuthManagerFlowManager(hass, self) @property def auth_providers(self) -> List[AuthProvider]: @@ -100,20 +157,21 @@ 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]: + 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]: + 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] + 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]: + 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) @@ -135,7 +193,8 @@ async def async_get_group(self, group_id: str) -> Optional[models.Group]: return await self._store.async_get_group(group_id) async def async_get_user_by_credentials( - self, credentials: models.Credentials) -> Optional[models.User]: + 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: @@ -145,57 +204,52 @@ async def async_get_user_by_credentials( return None async def async_create_system_user( - self, name: str, - group_ids: Optional[List[str]] = None) -> models.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 [], + 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 - }) + self.hass.bus.async_fire(EVENT_USER_ADDED, {"user_id": user.id}) return user - async def async_create_user(self, name: str) -> models.User: + async def async_create_user( + self, name: str, group_ids: Optional[List[str]] = None + ) -> models.User: """Create a user.""" - kwargs = { - 'name': name, - 'is_active': True, - 'group_ids': [GROUP_ID_ADMIN] - } # type: Dict[str, Any] + kwargs: Dict[str, Any] = { + "name": name, + "is_active": True, + "group_ids": group_ids or [], + } if await self._user_should_be_owner(): - kwargs['is_owner'] = True + kwargs["is_owner"] = True user = await self._store.async_create_user(**kwargs) - self.hass.bus.async_fire(EVENT_USER_ADDED, { - 'user_id': user.id - }) + 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: + 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.') + 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') + raise RuntimeError("Credential with unknown provider encountered") - info = await auth_provider.async_user_meta_for_credentials( - credentials) + info = await auth_provider.async_user_meta_for_credentials(credentials) user = await self._store.async_create_user( credentials=credentials, @@ -204,14 +258,13 @@ async def async_get_or_create_user(self, credentials: models.Credentials) \ group_ids=[GROUP_ID_ADMIN], ) - self.hass.bus.async_fire(EVENT_USER_ADDED, { - 'user_id': user.id - }) + 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: + 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) @@ -227,19 +280,20 @@ async def async_remove_user(self, user: models.User) -> None: await self._store.async_remove_user(user) - self.hass.bus.async_fire(EVENT_USER_REMOVED, { - 'user_id': user.id - }) + 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: + 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] + kwargs: Dict[str, Any] = {} if name is not None: - kwargs['name'] = name + kwargs["name"] = name if group_ids is not None: - kwargs['group_ids'] = group_ids + kwargs["group_ids"] = group_ids await self._store.async_update_user(user, **kwargs) async def async_activate_user(self, user: models.User) -> None: @@ -249,73 +303,77 @@ async def async_activate_user(self, user: models.User) -> None: async def async_deactivate_user(self, user: models.User) -> None: """Deactivate a user.""" if user.is_owner: - raise ValueError('Unable to deactive the owner') + raise ValueError("Unable to deactivate the owner") await self._store.async_deactivate_user(user) - async def async_remove_credentials( - self, credentials: models.Credentials) -> None: + 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')): + 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) + 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: + 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.') + 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)) + raise ValueError(f"Unable find multi-factor auth module: {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: + 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.') + 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)) + raise ValueError(f"Unable find multi-factor auth module: {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] + modules: Dict[str, str] = OrderedDict() 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: + 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') + 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.') + "System generated users cannot have refresh tokens connected " + "to a client." + ) if token_type is None: if user.system_generated: @@ -325,61 +383,76 @@ async def async_create_refresh_token( if user.system_generated != (token_type == models.TOKEN_TYPE_SYSTEM): raise ValueError( - 'System generated users can only have system type ' - 'refresh tokens') + "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.') + 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 + 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): + 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)) + raise ValueError(f"{client_name} already exists") return await self._store.async_create_refresh_token( - user, client_id, client_name, client_icon, - token_type, access_token_expiration) + 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]: + 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]: + 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: + 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: + 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() + 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]: + self, token: str + ) -> Optional[models.RefreshToken]: """Return refresh token if an access token is valid.""" try: unverif_claims = jwt.decode(token, verify=False) @@ -387,23 +460,18 @@ async def async_validate_access_token( return None refresh_token = await self.async_get_refresh_token( - cast(str, unverif_claims.get('iss'))) + cast(str, unverif_claims.get("iss")) + ) if refresh_token is None: - jwt_key = '' - issuer = '' + 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'] - ) + jwt.decode(token, jwt_key, leeway=10, issuer=issuer, algorithms=["HS256"]) except jwt.InvalidTokenError: return None @@ -412,55 +480,15 @@ async def async_validate_access_token( 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]: + 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) + 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: diff --git a/homeassistant/auth/auth_store.py b/homeassistant/auth/auth_store.py index a64c14454a6f9..57ec9ee63dcc4 100644 --- a/homeassistant/auth/auth_store.py +++ b/homeassistant/auth/auth_store.py @@ -4,22 +4,22 @@ from datetime import timedelta import hmac from logging import getLogger -from typing import Any, Dict, List, Optional # noqa: F401 +from typing import Any, Dict, List, Optional 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 .const import GROUP_ID_ADMIN, GROUP_ID_READ_ONLY, GROUP_ID_USER from .permissions import PermissionLookup, system_policies -from .permissions.types import PolicyType # noqa: F401 +from .permissions.types import PolicyType STORAGE_VERSION = 1 -STORAGE_KEY = 'auth' -GROUP_NAME_ADMIN = 'Administrators' +STORAGE_KEY = "auth" +GROUP_NAME_ADMIN = "Administrators" GROUP_NAME_USER = "Users" -GROUP_NAME_READ_ONLY = 'Read Only' +GROUP_NAME_READ_ONLY = "Read Only" class AuthStore: @@ -34,11 +34,12 @@ class AuthStore: 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._users: Optional[Dict[str, models.User]] = None + self._groups: Optional[Dict[str, models.Group]] = None + self._perm_lookup: Optional[PermissionLookup] = None + 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]: @@ -74,11 +75,14 @@ async def async_get_user(self, user_id: str) -> Optional[models.User]: 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: + 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() @@ -87,28 +91,28 @@ async def async_create_user( assert self._groups is not None groups = [] - for group_id in (group_ids or []): + 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)) + raise ValueError(f"Invalid group specified {group_id}") groups.append(group) - kwargs = { - 'name': name, + kwargs: Dict[str, Any] = { + "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] + "groups": groups, + "perm_lookup": self._perm_lookup, + } if is_owner is not None: - kwargs['is_owner'] = is_owner + kwargs["is_owner"] = is_owner if is_active is not None: - kwargs['is_active'] = is_active + kwargs["is_active"] = is_active if system_generated is not None: - kwargs['system_generated'] = system_generated + kwargs["system_generated"] = system_generated new_user = models.User(**kwargs) @@ -122,8 +126,9 @@ async def async_create_user( await self.async_link_user(new_user, credentials) return new_user - async def async_link_user(self, user: models.User, - credentials: models.Credentials) -> None: + 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() @@ -139,9 +144,12 @@ async def async_remove_user(self, user: models.User) -> None: 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: + 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 @@ -156,10 +164,7 @@ async def async_update_user( user.groups = groups user.invalidate_permission_cache() - for attr_name, value in ( - ('name', name), - ('is_active', is_active), - ): + for attr_name, value in (("name", name), ("is_active", is_active)): if value is not None: setattr(user, attr_name, value) @@ -175,8 +180,7 @@ async def async_deactivate_user(self, user: models.User) -> None: user.is_active = False self._async_schedule_save() - async def async_remove_credentials( - self, credentials: models.Credentials) -> None: + async def async_remove_credentials(self, credentials: models.Credentials) -> None: """Remove credentials.""" if self._users is None: await self._async_load() @@ -197,23 +201,25 @@ async def async_remove_credentials( 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: + 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] + kwargs: Dict[str, Any] = { + "user": user, + "client_id": client_id, + "token_type": token_type, + "access_token_expiration": access_token_expiration, + } if client_name: - kwargs['client_name'] = client_name + kwargs["client_name"] = client_name if client_icon: - kwargs['client_icon'] = client_icon + kwargs["client_icon"] = client_icon refresh_token = models.RefreshToken(**kwargs) user.refresh_tokens[refresh_token.id] = refresh_token @@ -222,7 +228,8 @@ async def async_create_refresh_token( return refresh_token async def async_remove_refresh_token( - self, refresh_token: models.RefreshToken) -> None: + self, refresh_token: models.RefreshToken + ) -> None: """Remove a refresh token.""" if self._users is None: await self._async_load() @@ -234,7 +241,8 @@ async def async_remove_refresh_token( break async def async_get_refresh_token( - self, token_id: str) -> Optional[models.RefreshToken]: + self, token_id: str + ) -> Optional[models.RefreshToken]: """Get refresh token by id.""" if self._users is None: await self._async_load() @@ -248,7 +256,8 @@ async def async_get_refresh_token( return None async def async_get_refresh_token_by_token( - self, token: str) -> Optional[models.RefreshToken]: + self, token: str + ) -> Optional[models.RefreshToken]: """Get refresh token by token.""" if self._users is None: await self._async_load() @@ -265,8 +274,8 @@ async def async_get_refresh_token_by_token( @callback def async_log_refresh_token_usage( - self, refresh_token: models.RefreshToken, - remote_ip: Optional[str] = None) -> None: + 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 @@ -292,16 +301,14 @@ async def _async_load_task(self) -> None: if self._users is not None: return - self._perm_lookup = perm_lookup = PermissionLookup( - ent_reg, dev_reg - ) + 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] + users: Dict[str, models.User] = OrderedDict() + groups: Dict[str, models.Group] = OrderedDict() # 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 @@ -317,24 +324,24 @@ async def _async_load_task(self) -> None: # 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] + for group_dict in data.get("groups", []): + policy: Optional[PolicyType] = None - if group_dict['id'] == GROUP_ID_ADMIN: + 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: + 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: + elif group_dict["id"] == GROUP_ID_READ_ONLY: has_read_only_group = True name = GROUP_NAME_READ_ONLY @@ -342,18 +349,18 @@ async def _async_load_task(self) -> None: system_generated = True else: - name = group_dict['name'] - policy = group_dict.get('policy') + 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'] + group_without_policy = group_dict["id"] continue - groups[group_dict['id']] = models.Group( - id=group_dict['id'], + groups[group_dict["id"]] = models.Group( + id=group_dict["id"], name=name, policy=policy, system_generated=system_generated, @@ -361,8 +368,7 @@ async def _async_load_task(self) -> None: # 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) + 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 @@ -385,82 +391,86 @@ async def _async_load_task(self) -> None: user_group = _system_user_group() groups[user_group.id] = user_group - for user_dict in data['users']: + for user_dict in data["users"]: # Collect the users group. user_groups = [] - for group_id in user_dict.get('group_ids', []): + 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): + 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'], + 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'], + 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 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']: + 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: + if "jwt_key" not in rt_dict: continue - created_at = dt_util.parse_datetime(rt_dict['created_at']) + 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) + "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') + token_type = rt_dict.get("token_type") if token_type is None: - if rt_dict['client_id'] 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') + 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'], + 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'), + 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'], + 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'), + last_used_ip=rt_dict.get("last_used_ip"), ) - users[rt_dict['user_id']].refresh_tokens[token.id] = token + users[rt_dict["user_id"]].refresh_tokens[token.id] = token self._groups = groups self._users = users @@ -481,36 +491,36 @@ def _data_to_save(self) -> Dict: 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, + "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, + g_dict: Dict[str, Any] = { + "id": group.id, # Name not read for sys groups. Kept here for backwards compat - 'name': group.name - } # type: Dict[str, Any] + "name": group.name, + } if not group.system_generated: - g_dict['policy'] = group.policy + 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, + "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 @@ -518,38 +528,37 @@ def _data_to_save(self) -> Dict: 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, + "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, + "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] + self._users = OrderedDict() - groups = OrderedDict() # type: Dict[str, models.Group] + groups: Dict[str, models.Group] = OrderedDict() admin_group = _system_admin_group() groups[admin_group.id] = admin_group user_group = _system_user_group() diff --git a/homeassistant/auth/const.py b/homeassistant/auth/const.py index ef2d54ccbabe2..5e17e752bdd82 100644 --- a/homeassistant/auth/const.py +++ b/homeassistant/auth/const.py @@ -4,6 +4,6 @@ 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' +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 index 3313063679dd0..c2ec2260cf224 100644 --- a/homeassistant/auth/mfa_modules/__init__.py +++ b/homeassistant/auth/mfa_modules/__init__.py @@ -1,4 +1,4 @@ -"""Plugable auth modules for Home Assistant.""" +"""Pluggable auth modules for Home Assistant.""" import importlib import logging import types @@ -7,7 +7,7 @@ import voluptuous as vol from voluptuous.humanize import humanize_error -from homeassistant import requirements, data_entry_flow +from homeassistant import data_entry_flow, requirements from homeassistant.const import CONF_ID, CONF_NAME, CONF_TYPE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -15,14 +15,17 @@ 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) +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' +DATA_REQS = "mfa_auth_module_reqs_processed" _LOGGER = logging.getLogger(__name__) @@ -30,7 +33,7 @@ class MultiFactorAuthModule: """Multi-factor Auth Module of validation function.""" - DEFAULT_TITLE = 'Unnamed auth module' + DEFAULT_TITLE = "Unnamed auth module" MAX_RETRY_TIME = 3 def __init__(self, hass: HomeAssistant, config: Dict[str, Any]) -> None: @@ -39,7 +42,7 @@ def __init__(self, hass: HomeAssistant, config: Dict[str, Any]) -> None: self.config = config @property - def id(self) -> str: # pylint: disable=invalid-name + def id(self) -> str: """Return id of the auth module. Default is same as type @@ -63,7 +66,7 @@ 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': + async def async_setup_flow(self, user_id: str) -> "SetupFlow": """Return a data entry flow handler for setup module. Mfa module should extend SetupFlow @@ -82,8 +85,7 @@ 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: + async def async_validate(self, user_id: str, user_input: Dict[str, Any]) -> bool: """Return True if validation passed.""" raise NotImplementedError @@ -91,42 +93,38 @@ async def async_validate( 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: + 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]: + 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] + errors: Dict[str, str] = {} if user_input: - result = await self._auth_module.async_setup_user( - self._user_id, 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} + title=self._auth_module.name, data={"result": result} ) return self.async_show_form( - step_id='init', - data_schema=self._setup_schema, - errors=errors + step_id="init", data_schema=self._setup_schema, errors=errors ) async def auth_mfa_module_from_config( - hass: HomeAssistant, config: Dict[str, Any]) \ - -> MultiFactorAuthModule: + 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) @@ -134,26 +132,27 @@ async def auth_mfa_module_from_config( 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)) + _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: +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) + module_path = f"homeassistant.auth.mfa_modules.{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)) + _LOGGER.error("Unable to load mfa module %s: %s", module_name, err) + raise HomeAssistantError(f"Unable to load mfa module {module_name}: {err}") - if hass.config.skip_pip or not hasattr(module, 'REQUIREMENTS'): + if hass.config.skip_pip or not hasattr(module, "REQUIREMENTS"): return module processed = hass.data.get(DATA_REQS) @@ -163,13 +162,9 @@ async def _load_mfa_module(hass: HomeAssistant, module_name: str) \ 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)) + await requirements.async_process_requirements( + hass, module_path, module.REQUIREMENTS # type: ignore + ) processed.add(module_name) return module diff --git a/homeassistant/auth/mfa_modules/insecure_example.py b/homeassistant/auth/mfa_modules/insecure_example.py index 9804cbcf63588..45cc07ae5810c 100644 --- a/homeassistant/auth/mfa_modules/insecure_example.py +++ b/homeassistant/auth/mfa_modules/insecure_example.py @@ -6,39 +6,45 @@ 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) +from . import ( + MULTI_FACTOR_AUTH_MODULE_SCHEMA, + MULTI_FACTOR_AUTH_MODULES, + MultiFactorAuthModule, + 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') +@MULTI_FACTOR_AUTH_MODULES.register("insecure_example") class InsecureExampleModule(MultiFactorAuthModule): """Example auth module validate pin.""" - DEFAULT_TITLE = 'Insecure Personal Identify Number' + 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'] + self._data = config["data"] @property def input_schema(self) -> vol.Schema: """Validate login flow input data.""" - return vol.Schema({'pin': str}) + return vol.Schema({"pin": str}) @property def setup_schema(self) -> vol.Schema: """Validate async_setup_user input data.""" - return vol.Schema({'pin': str}) + return vol.Schema({"pin": str}) async def async_setup_flow(self, user_id: str) -> SetupFlow: """Return a data entry flow handler for setup module. @@ -50,21 +56,21 @@ async def async_setup_flow(self, user_id: str) -> SetupFlow: 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'] + pin = setup_data["pin"] for data in self._data: - if data['user_id'] == user_id: + if data["user_id"] == user_id: # already setup, override - data['pin'] = pin + data["pin"] = pin return - self._data.append({'user_id': user_id, 'pin': pin}) + 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: + if data["user_id"] == user_id: found = data break if found: @@ -73,17 +79,16 @@ async def async_depose_user(self, user_id: str) -> None: 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: + if data["user_id"] == user_id: return True return False - async def async_validate( - self, user_id: str, user_input: Dict[str, Any]) -> bool: + 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: + if data["user_id"] == user_id: # user_input has been validate in caller - if data['pin'] == user_input['pin']: + 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 index 396a0fb8d3f2e..d8c28409b2d1e 100644 --- a/homeassistant/auth/mfa_modules/notify.py +++ b/homeassistant/auth/mfa_modules/notify.py @@ -3,9 +3,9 @@ Sending HOTP through notify service """ import asyncio -import logging from collections import OrderedDict -from typing import Any, Dict, Optional, List +import logging +from typing import Any, Dict, List, Optional import attr import voluptuous as vol @@ -15,51 +15,61 @@ 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 +from . import ( + MULTI_FACTOR_AUTH_MODULE_SCHEMA, + MULTI_FACTOR_AUTH_MODULES, + MultiFactorAuthModule, + SetupFlow, +) -REQUIREMENTS = ['pyotp==2.2.7'] +REQUIREMENTS = ["pyotp==2.3.0"] -CONF_MESSAGE = 'message' +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) +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' +STORAGE_KEY = "auth_module.notify" +STORAGE_USERS = "users" +STORAGE_USER_ID = "user_id" -INPUT_FIELD_CODE = 'code' +INPUT_FIELD_CODE = "code" _LOGGER = logging.getLogger(__name__) def _generate_secret() -> str: """Generate a secret.""" - import pyotp + import pyotp # pylint: disable=import-outside-toplevel + 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'))) + import pyotp # pylint: disable=import-outside-toplevel + + return int(pyotp.random_base32(length=8, chars=list("1234567890"))) def _generate_otp(secret: str, count: int) -> str: """Generate one time password.""" - import pyotp + import pyotp # pylint: disable=import-outside-toplevel + return str(pyotp.HOTP(secret).at(count)) def _verify_otp(secret: str, otp: str, count: int) -> bool: """Verify one time password.""" - import pyotp + import pyotp # pylint: disable=import-outside-toplevel + return bool(pyotp.HOTP(secret).verify(otp, count)) @@ -67,7 +77,7 @@ def _verify_otp(secret: str, otp: str, count: int) -> bool: class NotifySetting: """Store notify setting for one user.""" - secret = attr.ib(type=str, factory=_generate_secret) # not persistent + 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) @@ -76,18 +86,19 @@ class NotifySetting: _UsersDict = Dict[str, NotifySetting] -@MULTI_FACTOR_AUTH_MODULES.register('notify') +@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' + 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_settings: Optional[_UsersDict] = None self._user_store = hass.helpers.storage.Store( - STORAGE_VERSION, STORAGE_KEY, private=True) + STORAGE_VERSION, STORAGE_KEY, private=True + ) self._include = config.get(CONF_INCLUDE, []) self._exclude = config.get(CONF_EXCLUDE, []) self._message_template = config[CONF_MESSAGE] @@ -119,22 +130,27 @@ async def _async_save(self) -> None: 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() - }}) + 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', {}): + for service in self.hass.services.async_services().get("notify", {}): if service not in self._exclude: unordered_services.add(service) @@ -149,8 +165,8 @@ async def async_setup_flow(self, user_id: str) -> SetupFlow: Mfa module should extend SetupFlow """ return NotifySetupFlow( - self, self.input_schema, user_id, - self.aync_get_available_notify_services()) + 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.""" @@ -159,8 +175,8 @@ async def async_setup_user(self, user_id: str, setup_data: Any) -> Any: 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'), + notify_service=setup_data.get("notify_service"), + target=setup_data.get("target"), ) await self._async_save() @@ -182,22 +198,23 @@ async def async_is_user_setup(self, user_id: str) -> bool: return user_id in self._user_settings - async def async_validate( - self, user_id: str, user_input: Dict[str, Any]) -> bool: + 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) + notify_setting = self._user_settings.get(user_id) 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) + _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.""" @@ -205,9 +222,9 @@ async def async_initialize_login_mfa_step(self, user_id: str) -> None: await self._async_load() assert self._user_settings is not None - notify_setting = self._user_settings.get(user_id, None) + notify_setting = self._user_settings.get(user_id) if notify_setting is None: - raise ValueError('Cannot find user_id') + raise ValueError("Cannot find user_id") def generate_secret_and_one_time_password() -> str: """Generate and send one time password.""" @@ -215,11 +232,11 @@ def generate_secret_and_one_time_password() -> str: # 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) + return _generate_otp(notify_setting.secret, notify_setting.counter) code = await self.hass.async_add_executor_job( - generate_secret_and_one_time_password) + generate_secret_and_one_time_password + ) await self.async_notify_user(user_id, code) @@ -229,107 +246,111 @@ async def async_notify_user(self, user_id: str, code: str) -> None: await self._async_load() assert self._user_settings is not None - notify_setting = self._user_settings.get(user_id, None) + notify_setting = self._user_settings.get(user_id) if notify_setting is None: - _LOGGER.error('Cannot find user %s', user_id) + _LOGGER.error("Cannot find user %s", user_id) return - await self.async_notify( # type: ignore - code, notify_setting.notify_service, notify_setting.target) + await self.async_notify( + code, + notify_setting.notify_service, # type: ignore + notify_setting.target, + ) - async def async_notify(self, code: str, notify_service: str, - target: Optional[str] = None) -> None: + 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)} + data = {"message": self._message_template.format(code)} if target: - data['target'] = [target] + data["target"] = [target] - await self.hass.services.async_call('notify', notify_service, data) + 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: + 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._auth_module: NotifyAuthModule = auth_module 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] + self._secret: Optional[str] = None + self._count: Optional[int] = None + self._notify_service: Optional[str] = None + self._target: Optional[str] = None async def async_step_init( - self, user_input: Optional[Dict[str, str]] = None) \ - -> Dict[str, Any]: + self, user_input: Optional[Dict[str, str]] = None + ) -> Dict[str, Any]: """Let user select available notify services.""" - errors = {} # type: Dict[str, str] + errors: 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._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') + 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) + schema: Dict[str, Any] = OrderedDict() + 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 + 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] + self, user_input: Optional[Dict[str, str]] = None + ) -> Dict[str, Any]: + """Verify user can receive one-time password.""" + errors: 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) + _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={} + 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' + 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) + _generate_otp, self._secret, self._count + ) assert self._notify_service try: await self._auth_module.async_notify( - code, self._notify_service, self._target) + code, self._notify_service, self._target + ) except ServiceNotFound: - return self.async_abort(reason='notify_service_not_exist') + return self.async_abort(reason="notify_service_not_exist") return self.async_show_form( - step_id='setup', + step_id="setup", data_schema=self._setup_schema, - description_placeholders={'notify_service': self._notify_service}, + 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 index bb07d9e479f26..d35f237f424be 100644 --- a/homeassistant/auth/mfa_modules/totp.py +++ b/homeassistant/auth/mfa_modules/totp.py @@ -1,74 +1,84 @@ """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 logging +from typing import Any, Dict, Optional, Tuple 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 +from . import ( + MULTI_FACTOR_AUTH_MODULE_SCHEMA, + MULTI_FACTOR_AUTH_MODULES, + MultiFactorAuthModule, + SetupFlow, +) -REQUIREMENTS = ['pyotp==2.2.7', 'PyQRCode==1.2.1'] +REQUIREMENTS = ["pyotp==2.3.0", "PyQRCode==1.2.1"] -CONFIG_SCHEMA = MULTI_FACTOR_AUTH_MODULE_SCHEMA.extend({ -}, extra=vol.PREVENT_EXTRA) +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' +STORAGE_KEY = "auth_module.totp" +STORAGE_USERS = "users" +STORAGE_USER_ID = "user_id" +STORAGE_OTA_SECRET = "ota_secret" -INPUT_FIELD_CODE = 'code' +INPUT_FIELD_CODE = "code" -DUMMY_SECRET = 'FPPTH34D4E3MI2HG' +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 + import pyqrcode # pylint: disable=import-outside-toplevel 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 + import pyotp # pylint: disable=import-outside-toplevel ota_secret = pyotp.random_base32() url = pyotp.totp.TOTP(ota_secret).provisioning_uri( - username, issuer_name="Home Assistant") + username, issuer_name="Home Assistant" + ) image = _generate_qr_code(url) return ota_secret, url, image -@MULTI_FACTOR_AUTH_MODULES.register('totp') +@MULTI_FACTOR_AUTH_MODULES.register("totp") class TotpAuthModule(MultiFactorAuthModule): """Auth module validate time-based one time password.""" - DEFAULT_TITLE = '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._users: Optional[Dict[str, str]] = None self._user_store = hass.helpers.storage.Store( - STORAGE_VERSION, STORAGE_KEY, private=True) + STORAGE_VERSION, STORAGE_KEY, private=True + ) self._init_lock = asyncio.Lock() @property @@ -93,14 +103,13 @@ 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: + def _add_ota_secret(self, user_id: str, secret: Optional[str] = None) -> str: """Create a ota_secret for user.""" - import pyotp + import pyotp # pylint: disable=import-outside-toplevel - ota_secret = secret or pyotp.random_base32() # type: str + ota_secret: str = secret or pyotp.random_base32() - self._users[user_id] = ota_secret # type: ignore + self._users[user_id] = ota_secret # type: ignore return ota_secret async def async_setup_flow(self, user_id: str) -> SetupFlow: @@ -108,7 +117,7 @@ async def async_setup_flow(self, user_id: str) -> SetupFlow: Mfa module should extend SetupFlow """ - user = await self.hass.auth.async_get_user(user_id) # type: ignore + 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: @@ -117,7 +126,8 @@ async def async_setup_user(self, user_id: str, setup_data: Any) -> str: await self._async_load() result = await self.hass.async_add_executor_job( - self._add_ota_secret, user_id, setup_data.get('secret')) + self._add_ota_secret, user_id, setup_data.get("secret") + ) await self._async_save() return result @@ -127,7 +137,7 @@ async def async_depose_user(self, user_id: str) -> None: if self._users is None: await self._async_load() - if self._users.pop(user_id, None): # type: ignore + if self._users.pop(user_id, None): # type: ignore await self._async_save() async def async_is_user_setup(self, user_id: str) -> bool: @@ -135,10 +145,9 @@ async def async_is_user_setup(self, user_id: str) -> bool: if self._users is None: await self._async_load() - return user_id in self._users # type: ignore + return user_id in self._users # type: ignore - async def async_validate( - self, user_id: str, user_input: Dict[str, Any]) -> bool: + 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() @@ -146,11 +155,12 @@ async def async_validate( # 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, '')) + 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 + import pyotp # pylint: disable=import-outside-toplevel ota_secret = self._users.get(user_id) # type: ignore if ota_secret is None: @@ -165,56 +175,62 @@ def _validate_2fa(self, user_id: str, code: str) -> bool: class TotpSetupFlow(SetupFlow): """Handler for the setup flow.""" - def __init__(self, auth_module: TotpAuthModule, - setup_schema: vol.Schema, - user: User) -> None: + 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._auth_module: TotpAuthModule = auth_module self._user = user - self._ota_secret = None # type: Optional[str] + self._ota_secret: Optional[str] = None 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]: + 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 + import pyotp # pylint: disable=import-outside-toplevel - errors = {} # type: Dict[str, str] + errors: 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']) + 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}) + self._user_id, {"secret": self._ota_secret} + ) return self.async_create_entry( - title=self._auth_module.name, - data={'result': result} + title=self._auth_module.name, data={"result": result} ) - errors['base'] = 'invalid_code' + 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)) + ( + self._ota_secret, + self._url, + self._image, + ) = await hass.async_add_executor_job( + _generate_secret_and_qr_code, # type: ignore + str(self._user.name), + ) return self.async_show_form( - step_id='init', + step_id="init", data_schema=self._setup_schema, description_placeholders={ - 'code': self._ota_secret, - 'url': self._url, - 'qr_code': self._image + "code": self._ota_secret, + "url": self._url, + "qr_code": self._image, }, - errors=errors + errors=errors, ) diff --git a/homeassistant/auth/models.py b/homeassistant/auth/models.py index 588d80047bedd..502155df129c0 100644 --- a/homeassistant/auth/models.py +++ b/homeassistant/auth/models.py @@ -1,6 +1,7 @@ """Auth models.""" from datetime import datetime, timedelta -from typing import Dict, List, NamedTuple, Optional # noqa: F401 +import secrets +from typing import Dict, List, NamedTuple, Optional import uuid import attr @@ -9,18 +10,17 @@ 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' +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] + name = attr.ib(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) @@ -30,31 +30,28 @@ class Group: 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 + name = attr.ib(type=Optional[str]) + perm_lookup = attr.ib(type=perm_mdl.PermissionLookup, eq=False, order=False) 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] + groups = attr.ib(type=List[Group], factory=list, eq=False, order=False) # List of credentials of a user. - credentials = attr.ib( - type=list, factory=list, cmp=False - ) # type: List[Credentials] + credentials = attr.ib(type=List["Credentials"], factory=list, eq=False, order=False) # Tokens associated with a user. refresh_tokens = attr.ib( - type=dict, factory=dict, cmp=False - ) # type: Dict[str, RefreshToken] + type=Dict[str, "RefreshToken"], factory=dict, eq=False, order=False + ) _permissions = attr.ib( type=Optional[perm_mdl.PolicyPermissions], init=False, - cmp=False, + eq=False, + order=False, default=None, ) @@ -68,9 +65,9 @@ def permissions(self) -> perm_mdl.AbstractPermissions: return self._permissions self._permissions = perm_mdl.PolicyPermissions( - perm_mdl.merge_policies([ - group.policy for group in self.groups]), - self.perm_lookup) + perm_mdl.merge_policies([group.policy for group in self.groups]), + self.perm_lookup, + ) return self._permissions @@ -80,8 +77,7 @@ def is_admin(self) -> bool: if self.is_owner: return True - return self.is_active and any( - gr.id == GROUP_ID_ADMIN for gr in self.groups) + 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.""" @@ -97,14 +93,17 @@ class RefreshToken: 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))) + 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)) + token = attr.ib(type=str, factory=lambda: secrets.token_hex(64)) + jwt_key = attr.ib(type=str, factory=lambda: secrets.token_hex(64)) last_used_at = attr.ib(type=Optional[datetime], default=None) last_used_ip = attr.ib(type=Optional[str], default=None) @@ -124,5 +123,8 @@ class Credentials: is_new = attr.ib(type=bool, default=True) -UserMeta = NamedTuple("UserMeta", - [('name', Optional[str]), ('is_active', bool)]) +class UserMeta(NamedTuple): + """User metadata.""" + + name: Optional[str] + is_active: bool diff --git a/homeassistant/auth/permissions/__init__.py b/homeassistant/auth/permissions/__init__.py index 0079f11447b88..92d02c75b91be 100644 --- a/homeassistant/auth/permissions/__init__.py +++ b/homeassistant/auth/permissions/__init__.py @@ -1,22 +1,17 @@ """Permissions for Home Assistant.""" import logging -from typing import ( # noqa: F401 - cast, Any, Callable, Dict, List, Mapping, Set, Tuple, Union, - TYPE_CHECKING) +from typing import Any, Callable, Optional import voluptuous as vol from .const import CAT_ENTITIES +from .entities import ENTITY_POLICY_SCHEMA, compile_entities +from .merge import merge_policies # noqa: F401 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 -}) +POLICY_SCHEMA = vol.Schema({vol.Optional(CAT_ENTITIES): ENTITY_POLICY_SCHEMA}) _LOGGER = logging.getLogger(__name__) @@ -24,7 +19,7 @@ class AbstractPermissions: """Default permissions class.""" - _cached_entity_func = None + _cached_entity_func: Optional[Callable[[str, str], bool]] = None def _entity_func(self) -> Callable[[str, str], bool]: """Return a function that can test entity access.""" @@ -47,8 +42,7 @@ def check_entity(self, entity_id: str, key: str) -> bool: class PolicyPermissions(AbstractPermissions): """Handle permissions.""" - def __init__(self, policy: PolicyType, - perm_lookup: PermissionLookup) -> None: + def __init__(self, policy: PolicyType, perm_lookup: PermissionLookup) -> None: """Initialize the permission class.""" self._policy = policy self._perm_lookup = perm_lookup @@ -59,21 +53,16 @@ def access_all_entities(self, key: str) -> bool: 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) + 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) + 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 diff --git a/homeassistant/auth/permissions/const.py b/homeassistant/auth/permissions/const.py index d390d010deea4..e6c44036a7efa 100644 --- a/homeassistant/auth/permissions/const.py +++ b/homeassistant/auth/permissions/const.py @@ -1,8 +1,8 @@ """Permission constants.""" -CAT_ENTITIES = 'entities' -CAT_CONFIG_ENTRIES = 'config_entries' -SUBCAT_ALL = 'all' +CAT_ENTITIES = "entities" +CAT_CONFIG_ENTRIES = "config_entries" +SUBCAT_ALL = "all" -POLICY_READ = 'read' -POLICY_CONTROL = 'control' -POLICY_EDIT = 'edit' +POLICY_READ = "read" +POLICY_CONTROL = "control" +POLICY_EDIT = "edit" diff --git a/homeassistant/auth/permissions/entities.py b/homeassistant/auth/permissions/entities.py index 3d7fc80307ec3..be30c7bf69aef 100644 --- a/homeassistant/auth/permissions/entities.py +++ b/homeassistant/auth/permissions/entities.py @@ -1,57 +1,63 @@ """Entity permissions.""" from collections import OrderedDict -from typing import Callable, Optional # noqa: F401 +from typing import Callable, Optional import voluptuous as vol -from .const import SUBCAT_ALL, POLICY_READ, POLICY_CONTROL, POLICY_EDIT +from .const import POLICY_CONTROL, POLICY_EDIT, POLICY_READ, SUBCAT_ALL 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]: +from .util import SubCatLookupType, compile_policy, lookup_all + +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]: +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 - ) + 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 @@ -59,9 +65,9 @@ def _lookup_area(perm_lookup: PermissionLookup, area_dict: SubCategoryDict, return area_dict.get(device_entry.area_id) -def _lookup_device(perm_lookup: PermissionLookup, - devices_dict: SubCategoryDict, - entity_id: str) -> Optional[ValueType]: +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) @@ -71,17 +77,18 @@ def _lookup_device(perm_lookup: PermissionLookup, return devices_dict.get(entity_entry.device_id) -def _lookup_entity_id(perm_lookup: PermissionLookup, - entities_dict: SubCategoryDict, - entity_id: str) -> Optional[ValueType]: +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]: +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: SubCatLookupType = OrderedDict() subcategories[ENTITY_ENTITY_IDS] = _lookup_entity_id subcategories[ENTITY_DEVICE_IDS] = _lookup_device subcategories[ENTITY_AREAS] = _lookup_area diff --git a/homeassistant/auth/permissions/merge.py b/homeassistant/auth/permissions/merge.py index ec6375a0e3d3a..fad98b3f22a3f 100644 --- a/homeassistant/auth/permissions/merge.py +++ b/homeassistant/auth/permissions/merge.py @@ -1,21 +1,21 @@ """Merging of policies.""" -from typing import ( # noqa: F401 - cast, Dict, List, Set) +from typing import Dict, List, Set, cast -from .types import PolicyType, CategoryType +from .types import CategoryType, PolicyType def merge_policies(policies: List[PolicyType]) -> PolicyType: """Merge policies.""" - new_policy = {} # type: Dict[str, CategoryType] - seen = set() # type: Set[str] + new_policy: Dict[str, CategoryType] = {} + seen: Set[str] = set() 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]) + new_policy[category] = _merge_policies( + [policy.get(category) for policy in policies] + ) cast(PolicyType, new_policy) return new_policy @@ -33,8 +33,8 @@ def _merge_policies(sources: List[CategoryType]) -> CategoryType: # 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] + policy: CategoryType = None + seen: Set[str] = set() for source in sources: if source is None: continue diff --git a/homeassistant/auth/permissions/models.py b/homeassistant/auth/permissions/models.py index 10a76a4ec73b0..1224ea07b23a7 100644 --- a/homeassistant/auth/permissions/models.py +++ b/homeassistant/auth/permissions/models.py @@ -5,17 +5,13 @@ 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, - ) + from homeassistant.helpers import entity_registry as ent_reg # noqa: F401 + from homeassistant.helpers import device_registry as dev_reg # noqa: F401 @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') + 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 index bf65c0a85a6cc..b45984653fb37 100644 --- a/homeassistant/auth/permissions/system_policies.py +++ b/homeassistant/auth/permissions/system_policies.py @@ -1,18 +1,8 @@ """System policies.""" -from .const import CAT_ENTITIES, SUBCAT_ALL, POLICY_READ +from .const import CAT_ENTITIES, POLICY_READ, SUBCAT_ALL -ADMIN_POLICY = { - CAT_ENTITIES: True, -} +ADMIN_POLICY = {CAT_ENTITIES: True} -USER_POLICY = { - CAT_ENTITIES: True, -} +USER_POLICY = {CAT_ENTITIES: True} -READ_ONLY_POLICY = { - CAT_ENTITIES: { - SUBCAT_ALL: { - POLICY_READ: 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 index 5479e59dcb6ab..6ce394ebb9263 100644 --- a/homeassistant/auth/permissions/types.py +++ b/homeassistant/auth/permissions/types.py @@ -7,17 +7,13 @@ # Example: entities.all = { read: true, control: true } Mapping[str, bool], bool, - None + None, ] # Example: entities.domains = { light: … } SubCategoryDict = Mapping[str, ValueType] -SubCategoryType = Union[ - SubCategoryDict, - bool, - None -] +SubCategoryType = Union[SubCategoryDict, bool, None] CategoryType = Union[ # Example: entities.domains @@ -25,7 +21,7 @@ # Example: entities.all Mapping[str, ValueType], bool, - None + None, ] # Example: { entities: … } diff --git a/homeassistant/auth/permissions/util.py b/homeassistant/auth/permissions/util.py index 0d334c4a3ba89..11bbd878eb23d 100644 --- a/homeassistant/auth/permissions/util.py +++ b/homeassistant/auth/permissions/util.py @@ -1,34 +1,34 @@ """Helpers to deal with permissions.""" from functools import wraps - -from typing import Callable, Dict, List, Optional, Union, cast # noqa: F401 +from typing import Callable, Dict, List, Optional, cast from .const import SUBCAT_ALL from .models import PermissionLookup from .types import CategoryType, SubCategoryDict, ValueType -LookupFunc = Callable[[PermissionLookup, SubCategoryDict, str], - Optional[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: +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 + policy: CategoryType, subcategories: SubCatLookupType, perm_lookup: PermissionLookup +) -> Callable[[str, str], bool]: """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 @@ -36,6 +36,7 @@ def apply_policy_deny_all(entity_id: str, key: str) -> bool: return apply_policy_deny_all if policy is True: + def apply_policy_allow_all(entity_id: str, key: str) -> bool: """Approve all.""" return True @@ -44,7 +45,7 @@ def apply_policy_allow_all(entity_id: str, key: str) -> bool: assert isinstance(policy, dict) - funcs = [] # type: List[Callable[[str, str], Union[None, bool]]] + funcs: List[Callable[[str, str], Optional[bool]]] = [] for key, lookup_func in subcategories.items(): lookup_value = policy.get(key) @@ -54,8 +55,7 @@ def apply_policy_allow_all(entity_id: str, key: str) -> 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)) + funcs.append(_gen_dict_test_func(perm_lookup, lookup_func, lookup_value)) if len(funcs) == 1: func = funcs[0] @@ -79,15 +79,13 @@ def apply_policy_funcs(object_id: str, key: str) -> bool: def _gen_dict_test_func( - perm_lookup: PermissionLookup, - lookup_func: LookupFunc, - lookup_dict: SubCategoryDict - ) -> Callable[[str, str], Optional[bool]]: # noqa + perm_lookup: PermissionLookup, lookup_func: LookupFunc, lookup_dict: SubCategoryDict +) -> Callable[[str, str], Optional[bool]]: """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 + schema: ValueType = lookup_func(perm_lookup, lookup_dict, object_id) if schema is None or isinstance(schema, bool): return schema diff --git a/homeassistant/auth/providers/__init__.py b/homeassistant/auth/providers/__init__.py index 8828782c886e9..1fa70e42b3fe3 100644 --- a/homeassistant/auth/providers/__init__.py +++ b/homeassistant/auth/providers/__init__.py @@ -8,43 +8,47 @@ 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.core import HomeAssistant, callback 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 +from ..models import Credentials, User, UserMeta _LOGGER = logging.getLogger(__name__) -DATA_REQS = 'auth_prov_reqs_processed' +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) +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' + DEFAULT_TITLE = "Unnamed auth provider" - def __init__(self, hass: HomeAssistant, store: AuthStore, - config: Dict[str, Any]) -> None: + 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 + def id(self) -> Optional[str]: """Return id of the auth provider. Optional, can be None. @@ -73,22 +77,22 @@ async def async_credentials(self) -> List[Credentials]: credentials for user in users for credentials in user.credentials - if (credentials.auth_provider_type == self.type and - credentials.auth_provider_id == self.id) + 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, + 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': + 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. @@ -96,22 +100,27 @@ async def async_login_flow(self, context: Optional[Dict]) -> 'LoginFlow': raise NotImplementedError async def async_get_or_create_credentials( - self, flow_result: Dict[str, str]) -> 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: + 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 async_initialize(self) -> None: + """Initialize the auth provider.""" + async def auth_provider_from_config( - hass: HomeAssistant, store: AuthStore, - config: Dict[str, Any]) -> AuthProvider: + 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) @@ -119,25 +128,27 @@ async def auth_provider_from_config( 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)) + _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: + hass: HomeAssistant, provider: str +) -> types.ModuleType: """Load an auth provider.""" try: - module = importlib.import_module( - 'homeassistant.auth.providers.{}'.format(provider)) + module = importlib.import_module(f"homeassistant.auth.providers.{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)) + _LOGGER.error("Unable to load auth provider %s: %s", provider, err) + raise HomeAssistantError(f"Unable to load auth provider {provider}: {err}") - if hass.config.skip_pip or not hasattr(module, 'REQUIREMENTS'): + if hass.config.skip_pip or not hasattr(module, "REQUIREMENTS"): return module processed = hass.data.get(DATA_REQS) @@ -149,13 +160,9 @@ async def load_auth_provider_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)) + await requirements.async_process_requirements( + hass, f"auth provider {provider}", reqs + ) processed.add(provider) return module @@ -167,16 +174,16 @@ class LoginFlow(data_entry_flow.FlowHandler): 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_module_id: Optional[str] = None self._auth_manager = auth_provider.hass.auth # type: ignore - self.available_mfa_modules = {} # type: Dict[str, str] + self.available_mfa_modules: Dict[str, str] = {} self.created_at = dt_util.utcnow() self.invalid_mfa_times = 0 - self.user = None # type: Optional[User] + self.user: Optional[User] = None async def async_step_init( - self, user_input: Optional[Dict[str, str]] = None) \ - -> Dict[str, Any]: + 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. @@ -185,80 +192,75 @@ async def async_step_init( raise NotImplementedError async def async_step_select_mfa_module( - self, user_input: Optional[Dict[str, str]] = None) \ - -> Dict[str, Any]: + 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') + 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' + 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) - }), + 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]: + 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) + 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'): + 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') + _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' - ) + return self.async_abort(reason="login_expired") - result = await auth_module.async_validate( - self.user.id, user_input) + result = await auth_module.async_validate(self.user.id, user_input) if not result: - errors['base'] = 'invalid_code' + 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' - ) + 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]] + description_placeholders: Dict[str, Optional[str]] = { + "mfa_module_name": auth_module.name, + "mfa_module_id": auth_module.id, + } return self.async_show_form( - step_id='mfa', + step_id="mfa", data_schema=auth_module.input_schema, description_placeholders=description_placeholders, errors=errors, @@ -266,7 +268,4 @@ async def async_step_mfa( 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 - ) + 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 index 9cec34c134079..12e27c015049a 100644 --- a/homeassistant/auth/providers/command_line.py +++ b/homeassistant/auth/providers/command_line.py @@ -1,33 +1,32 @@ """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 +from typing import Any, Dict, Optional, cast import voluptuous as vol from homeassistant.exceptions import HomeAssistantError -from . import AuthProvider, AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, LoginFlow +from . import AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, AuthProvider, 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) +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__) @@ -52,7 +51,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: attributes provided by external programs. """ super().__init__(*args, **kwargs) - self._user_meta = {} # type: Dict[str, Dict[str, Any]] + self._user_meta: Dict[str, Dict[str, Any]] = {} async def async_login_flow(self, context: Optional[dict]) -> LoginFlow: """Return a flow to login.""" @@ -60,33 +59,31 @@ async def async_login_flow(self, context: Optional[dict]) -> LoginFlow: async def async_validate_login(self, username: str, password: str) -> None: """Validate a username and password.""" - env = { - "username": username, - "password": 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], + self.config[CONF_COMMAND], + *self.config[CONF_ARGS], env=env, - stdout=asyncio.subprocess.PIPE - if self.config[CONF_META] else None, + stdout=asyncio.subprocess.PIPE if self.config[CONF_META] else None, ) - stdout, _ = (await process.communicate()) + 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) + _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) + _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] + meta: Dict[str, str] = {} for _line in stdout.splitlines(): try: line = _line.decode().lstrip() @@ -103,7 +100,7 @@ async def async_validate_login(self, username: str, password: str) -> None: self._user_meta[username] = meta async def async_get_or_create_credentials( - self, flow_result: Dict[str, str] + self, flow_result: Dict[str, str] ) -> Credentials: """Get credentials based on the flow result.""" username = flow_result["username"] @@ -112,29 +109,24 @@ async def async_get_or_create_credentials( return credential # Create new credentials. - return self.async_create_credentials({ - "username": username, - }) + return self.async_create_credentials({"username": username}) async def async_user_meta_for_credentials( - self, credentials: 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, - ) + 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 + self, user_input: Optional[Dict[str, str]] = None ) -> Dict[str, Any]: """Handle the step of the form.""" errors = {} @@ -142,10 +134,9 @@ async def async_step_init( 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"] - ) + await cast( + CommandLineAuthProvider, self._auth_provider + ).async_validate_login(user_input["username"], user_input["password"]) except InvalidAuthError: errors["base"] = "invalid_auth" @@ -153,12 +144,10 @@ async def async_step_init( user_input.pop("password") return await self.async_finish(user_input) - schema = collections.OrderedDict() # type: Dict[str, type] + schema: Dict[str, type] = collections.OrderedDict() schema["username"] = str schema["password"] = str return self.async_show_form( - step_id="init", - data_schema=vol.Schema(schema), - errors=errors, + step_id="init", data_schema=vol.Schema(schema), errors=errors ) diff --git a/homeassistant/auth/providers/homeassistant.py b/homeassistant/auth/providers/homeassistant.py index 2187d2728004d..b3acaaa63520c 100644 --- a/homeassistant/auth/providers/homeassistant.py +++ b/homeassistant/auth/providers/homeassistant.py @@ -3,30 +3,26 @@ import base64 from collections import OrderedDict import logging - -from typing import Any, Dict, List, Optional, Set, cast # noqa: F401 +from typing import Any, Dict, List, Optional, Set, cast import bcrypt import voluptuous as vol from homeassistant.const import CONF_ID -from homeassistant.core import callback, HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from . import AuthProvider, AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, LoginFlow - +from . import AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, AuthProvider, LoginFlow from ..models import Credentials, UserMeta - STORAGE_VERSION = 1 -STORAGE_KEY = 'auth_provider.homeassistant' +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.') + raise vol.Invalid("ID is not allowed for the homeassistant auth provider.") return conf @@ -51,9 +47,10 @@ class 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]] + self._store = hass.helpers.storage.Store( + STORAGE_VERSION, STORAGE_KEY, private=True + ) + self._data: Optional[Dict[str, Any]] = None # 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. @@ -72,14 +69,12 @@ async def async_load(self) -> None: data = await self._store.async_load() if data is None: - data = { - 'users': [] - } + data = {"users": []} - seen = set() # type: Set[str] + seen: Set[str] = set() - for user in data['users']: - username = user['username'] + for user in data["users"]: + username = user["username"] # check if we have duplicates folded = username.casefold() @@ -90,7 +85,9 @@ async def async_load(self) -> None: 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) + "equivalent. Please change the username: '%s'.", + username, + ) break @@ -103,7 +100,9 @@ async def async_load(self) -> None: 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) + "space. Please change the username: '%s'.", + username, + ) break @@ -112,7 +111,7 @@ async def async_load(self) -> None: @property def users(self) -> List[Dict[str, str]]: """Return users.""" - return self._data['users'] # type: ignore + return self._data["users"] # type: ignore def validate_login(self, username: str, password: str) -> None: """Validate a username and password. @@ -120,32 +119,30 @@ def validate_login(self, username: str, password: str) -> None: Raises InvalidAuth if auth invalid. """ username = self.normalize_username(username) - dummy = b'$2b$12$CiuFGszHx9eNHxPuQcwBWez4CwDTOcLTX5CbOpV6gef2nYuXkY7BO' + 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: + 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) + bcrypt.checkpw(b"foo", dummy) raise InvalidAuth - user_hash = base64.b64decode(found['password']) + user_hash = base64.b64decode(found["password"]) # bcrypt.checkpw is timing-safe - if not bcrypt.checkpw(password.encode(), - user_hash): + 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 + hashed: bytes = bcrypt.hashpw(password.encode(), bcrypt.gensalt(rounds=12)) + if for_storage: hashed = base64.b64encode(hashed) return hashed @@ -154,14 +151,17 @@ 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): + 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(), - }) + self.users.append( + { + "username": username, + "password": self.hash_password(password, True).decode(), + } + ) @callback def async_remove_auth(self, username: str) -> None: @@ -170,7 +170,7 @@ def async_remove_auth(self, username: str) -> None: index = None for i, user in enumerate(self.users): - if self.normalize_username(user['username']) == username: + if self.normalize_username(user["username"]) == username: index = i break @@ -187,9 +187,8 @@ def change_password(self, username: str, new_password: str) -> None: 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() + if self.normalize_username(user["username"]) == username: + user["password"] = self.hash_password(new_password, True).decode() break else: raise InvalidUser @@ -199,16 +198,16 @@ async def async_save(self) -> None: await self._store.async_save(self._data) -@AUTH_PROVIDERS.register('homeassistant') +@AUTH_PROVIDERS.register("homeassistant") class HassAuthProvider(AuthProvider): - """Auth provider based on a local storage of users in HASS config dir.""" + """Auth provider based on a local storage of users in Home Assistant config dir.""" - DEFAULT_TITLE = 'Home Assistant Local' + 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.data: Optional[Data] = None self._init_lock = asyncio.Lock() async def async_initialize(self) -> None: @@ -221,8 +220,7 @@ async def async_initialize(self) -> None: await data.async_load() self.data = data - async def async_login_flow( - self, context: Optional[Dict]) -> LoginFlow: + async def async_login_flow(self, context: Optional[Dict]) -> LoginFlow: """Return a flow to login.""" return HassLoginFlow(self) @@ -233,41 +231,41 @@ async def async_validate_login(self, username: str, password: str) -> None: assert self.data is not None await self.hass.async_add_executor_job( - self.data.validate_login, username, password) + self.data.validate_login, username, password + ) async def async_get_or_create_credentials( - self, flow_result: Dict[str, str]) -> 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']) + username = norm_username(flow_result["username"]) for credential in await self.async_credentials(): - if norm_username(credential.data['username']) == username: + if norm_username(credential.data["username"]) == username: return credential # Create new credentials. - return self.async_create_credentials({ - 'username': username - }) + return self.async_create_credentials({"username": username}) async def async_user_meta_for_credentials( - self, credentials: Credentials) -> UserMeta: + self, credentials: Credentials + ) -> UserMeta: """Get extra info for this credential.""" - return UserMeta(name=credentials.data['username'], is_active=True) + return UserMeta(name=credentials.data["username"], is_active=True) - async def async_will_remove_credentials( - self, credentials: Credentials) -> None: + 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']) + 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 @@ -278,29 +276,27 @@ class HassLoginFlow(LoginFlow): """Handler for the login flow.""" async def async_step_init( - self, user_input: Optional[Dict[str, str]] = None) \ - -> Dict[str, Any]: + 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']) + await cast(HassAuthProvider, self._auth_provider).async_validate_login( + user_input["username"], user_input["password"] + ) except InvalidAuth: - errors['base'] = 'invalid_auth' + errors["base"] = "invalid_auth" if not errors: - user_input.pop('password') + user_input.pop("password") return await self.async_finish(user_input) - schema = OrderedDict() # type: Dict[str, type] - schema['username'] = str - schema['password'] = str + schema: Dict[str, type] = OrderedDict() + schema["username"] = str + schema["password"] = str return self.async_show_form( - step_id='init', - data_schema=vol.Schema(schema), - errors=errors, + 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 index 72e3dfe140ac0..70014a236cdae 100644 --- a/homeassistant/auth/providers/insecure_example.py +++ b/homeassistant/auth/providers/insecure_example.py @@ -5,30 +5,31 @@ import voluptuous as vol -from homeassistant.exceptions import HomeAssistantError from homeassistant.core import callback +from homeassistant.exceptions import HomeAssistantError -from . import AuthProvider, AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, LoginFlow +from . import AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, AuthProvider, LoginFlow from ..models import Credentials, UserMeta - -USER_SCHEMA = vol.Schema({ - vol.Required('username'): str, - vol.Required('password'): str, - vol.Optional('name'): str, -}) +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) +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') +@AUTH_PROVIDERS.register("insecure_example") class ExampleAuthProvider(AuthProvider): """Example auth provider based on hardcoded usernames and passwords.""" @@ -42,47 +43,48 @@ def async_validate_login(self, username: str, password: str) -> None: 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')): + 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')) + 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')): + 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: + self, flow_result: Dict[str, str] + ) -> Credentials: """Get credentials based on the flow result.""" - username = flow_result['username'] + username = flow_result["username"] for credential in await self.async_credentials(): - if credential.data['username'] == username: + if credential.data["username"] == username: return credential # Create new credentials. - return self.async_create_credentials({ - 'username': username - }) + return self.async_create_credentials({"username": username}) async def async_user_meta_for_credentials( - self, credentials: Credentials) -> UserMeta: + 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'] + username = credentials.data["username"] name = None - for user in self.config['users']: - if user['username'] == username: - name = user.get('name') + for user in self.config["users"]: + if user["username"] == username: + name = user.get("name") break return UserMeta(name=name, is_active=True) @@ -92,29 +94,27 @@ class ExampleLoginFlow(LoginFlow): """Handler for the login flow.""" async def async_step_init( - self, user_input: Optional[Dict[str, str]] = None) \ - -> Dict[str, Any]: + 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']) + cast(ExampleAuthProvider, self._auth_provider).async_validate_login( + user_input["username"], user_input["password"] + ) except InvalidAuthError: - errors['base'] = 'invalid_auth' + errors["base"] = "invalid_auth" if not errors: - user_input.pop('password') + user_input.pop("password") return await self.async_finish(user_input) - schema = OrderedDict() # type: Dict[str, type] - schema['username'] = str - schema['password'] = str + schema: Dict[str, type] = OrderedDict() + schema["username"] = str + schema["password"] = str return self.async_show_form( - step_id='init', - data_schema=vol.Schema(schema), - errors=errors, + 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 index e85d831a325e4..15ba1dfc14c5c 100644 --- a/homeassistant/auth/providers/legacy_api_password.py +++ b/homeassistant/auth/providers/legacy_api_password.py @@ -12,31 +12,30 @@ from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv -from . import AuthProvider, AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, LoginFlow +from . import AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, AuthProvider, LoginFlow from .. import AuthManager -from ..models import Credentials, UserMeta, User +from ..models import Credentials, User, UserMeta -AUTH_PROVIDER_TYPE = 'legacy_api_password' -CONF_API_PASSWORD = 'api_password' +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) +CONFIG_SCHEMA = AUTH_PROVIDER_SCHEMA.extend( + {vol.Required(CONF_API_PASSWORD): cv.string}, extra=vol.PREVENT_EXTRA +) -LEGACY_USER_NAME = 'Legacy API password user' +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]: +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') + raise ValueError("Legacy API password provider not found") try: provider = cast(LegacyApiPasswordAuthProvider, providers[0]) @@ -52,7 +51,7 @@ async def async_validate_password(hass: HomeAssistant, password: str)\ class LegacyApiPasswordAuthProvider(AuthProvider): """An auth provider support legacy api_password.""" - DEFAULT_TITLE = 'Legacy API Password' + DEFAULT_TITLE = "Legacy API Password" @property def api_password(self) -> str: @@ -68,12 +67,14 @@ 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')): + 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: + self, flow_result: Dict[str, str] + ) -> Credentials: """Return credentials for this login.""" credentials = await self.async_credentials() if credentials: @@ -82,7 +83,8 @@ async def async_get_or_create_credentials( return self.async_create_credentials({}) async def async_user_meta_for_credentials( - self, credentials: Credentials) -> UserMeta: + self, credentials: Credentials + ) -> UserMeta: """ Return info for the user. @@ -95,23 +97,22 @@ class LegacyLoginFlow(LoginFlow): """Handler for the login flow.""" async def async_step_init( - self, user_input: Optional[Dict[str, str]] = None) \ - -> Dict[str, Any]: + 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']) + cast( + LegacyApiPasswordAuthProvider, self._auth_provider + ).async_validate_login(user_input["password"]) except InvalidAuthError: - errors['base'] = 'invalid_auth' + 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, + 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 index e8161a2bfb64d..bc995368fec8f 100644 --- a/homeassistant/auth/providers/trusted_networks.py +++ b/homeassistant/auth/providers/trusted_networks.py @@ -3,42 +3,47 @@ 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 ipaddress import IPv4Address, IPv4Network, IPv6Address, IPv6Network, ip_network 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 +import homeassistant.helpers.config_validation as cv + +from . import AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, AuthProvider, 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) +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): @@ -49,14 +54,14 @@ class InvalidUserError(HomeAssistantError): """Raised when try to login as invalid user.""" -@AUTH_PROVIDERS.register('trusted_networks') +@AUTH_PROVIDERS.register("trusted_networks") class TrustedNetworksAuthProvider(AuthProvider): """Trusted Networks auth provider. Allow passwordless access from trusted network. """ - DEFAULT_TITLE = 'Trusted Networks' + DEFAULT_TITLE = "Trusted Networks" @property def trusted_networks(self) -> List[IPNetwork]: @@ -76,49 +81,58 @@ def support_mfa(self) -> bool: 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')) + 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] + 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] + 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])) + 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 - }, + {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: + self, flow_result: Dict[str, str] + ) -> Credentials: """Get credentials based on the flow result.""" - user_id = flow_result['user'] + 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): + 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: + if credential.data["user_id"] == user_id: return credential - cred = self.async_create_credentials({'user_id': user_id}) + cred = self.async_create_credentials({"user_id": user_id}) await self.store.async_link_user(user, cred) return cred @@ -126,7 +140,8 @@ async def async_get_or_create_credentials( raise InvalidUserError async def async_user_meta_for_credentials( - self, credentials: Credentials) -> UserMeta: + self, credentials: Credentials + ) -> UserMeta: """Return extra user metadata for credentials. Trusted network auth provider should never create new user. @@ -141,20 +156,24 @@ def async_validate_access(self, ip_addr: IPAddress) -> None: Raise InvalidAuthError if trusted_networks is not configured. """ if not self.trusted_networks: - raise InvalidAuthError('trusted_networks is not configured') + 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') + 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: + 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 @@ -162,27 +181,26 @@ def __init__(self, auth_provider: TrustedNetworksAuthProvider, self._allow_bypass_login = allow_bypass_login async def async_step_init( - self, user_input: Optional[Dict[str, str]] = None) \ - -> Dict[str, Any]: + 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) + cast( + TrustedNetworksAuthProvider, self._auth_provider + ).async_validate_access(self._ip_address) except InvalidAuthError: - return self.async_abort( - reason='not_whitelisted' - ) + 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 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)}), + 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 deleted file mode 100644 index 402caae4618d0..0000000000000 --- a/homeassistant/auth/util.py +++ /dev/null @@ -1,13 +0,0 @@ -"""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/block_async_io.py b/homeassistant/block_async_io.py new file mode 100644 index 0000000000000..cd33a4207a885 --- /dev/null +++ b/homeassistant/block_async_io.py @@ -0,0 +1,14 @@ +"""Block I/O being done in asyncio.""" +from http.client import HTTPConnection + +from homeassistant.util.async_ import protect_loop + + +def enable() -> None: + """Enable the detection of I/O in the event loop.""" + # Prevent urllib3 and requests doing I/O in event loop + HTTPConnection.putrequest = protect_loop(HTTPConnection.putrequest) + + # Currently disabled. pytz doing I/O when getting timezone. + # Prevent files being opened inside the event loop + # builtins.open = protect_loop(builtins.open) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index d63caf9e76f40..d53d86f528c4e 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -1,231 +1,257 @@ """Provide methods to bootstrap a Home Assistant instance.""" import asyncio +import contextlib import logging import logging.handlers import os import sys -from time import time -from collections import OrderedDict -from typing import Any, Optional, Dict, Set +from time import monotonic +from typing import Any, Dict, Optional, Set +from async_timeout import timeout import voluptuous as vol -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 import config as conf_util, config_entries, core, loader +from homeassistant.components import http +from homeassistant.const import ( + EVENT_HOMEASSISTANT_CLOSE, + EVENT_HOMEASSISTANT_STOP, + REQUIRED_NEXT_PYTHON_DATE, + REQUIRED_NEXT_PYTHON_VER, +) +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.typing import ConfigType +from homeassistant.setup import DATA_SETUP, 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.exceptions import HomeAssistantError -from homeassistant.helpers import config_validation as cv _LOGGER = logging.getLogger(__name__) -ERROR_LOG_FILENAME = 'home-assistant.log' +ERROR_LOG_FILENAME = "home-assistant.log" # hass.data key for logging information. -DATA_LOGGING = 'logging' +DATA_LOGGING = "logging" -DEBUGGER_INTEGRATIONS = {'ptvsd', } -CORE_INTEGRATIONS = ('homeassistant', 'persistent_notification') -LOGGING_INTEGRATIONS = {'logger', 'system_log'} +DEBUGGER_INTEGRATIONS = {"ptvsd"} +CORE_INTEGRATIONS = ("homeassistant", "persistent_notification") +LOGGING_INTEGRATIONS = {"logger", "system_log", "sentry"} STAGE_1_INTEGRATIONS = { # To record data - 'recorder', + "recorder", # To make sure we forward data to other instances - 'mqtt_eventstream', + "mqtt_eventstream", + # To provide account link implementations + "cloud", } -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 configuration dictionary. - - Dynamically loads required components and its dependencies. - This method is a coroutine. - """ - start = time() +async def async_setup_hass( + *, + config_dir: str, + verbose: bool, + log_rotate_days: int, + log_file: str, + log_no_color: bool, + skip_pip: bool, + safe_mode: bool, +) -> Optional[core.HomeAssistant]: + """Set up Home Assistant.""" + hass = core.HomeAssistant() + hass.config.config_dir = config_dir - if enable_log: - async_enable_logging(hass, verbose, log_rotate_days, log_file, - log_no_color) + 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") - - core_config = config.get(core.DOMAIN, {}) - api_password = config.get('http', {}).get('api_password') - trusted_networks = config.get('http', {}).get('trusted_networks') + _LOGGER.warning( + "Skipping pip installation of required modules. This may cause issues" + ) - 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) + if not await conf_util.async_ensure_config_exists(hass): + _LOGGER.error("Error getting configuration path") return None - except HomeAssistantError: - _LOGGER.error("Home Assistant core failed to initialize. " - "Further initialization aborted") - return None - - # Make a copy because we are mutating it. - config = OrderedDict(config) - - # Merge packages - await conf_util.merge_packages_config( - hass, config, core_config.get(conf_util.CONF_PACKAGES, {})) - - hass.config_entries = config_entries.ConfigEntries(hass, config) - await hass.config_entries.async_initialize() - await _async_set_up_integrations(hass, config) + _LOGGER.info("Config directory: %s", config_dir) - stop = time() - _LOGGER.info("Home Assistant initialized in %.2fs", stop-start) + config_dict = None + basic_setup_success = False - # TEMP: warn users for invalid slugs - # Remove after 0.94 or 1.0 - if cv.INVALID_SLUGS_FOUND or cv.INVALID_ENTITY_IDS_FOUND: - msg = [] + if not safe_mode: + await hass.async_add_executor_job(conf_util.process_ha_config_upgrade, hass) - if cv.INVALID_ENTITY_IDS_FOUND: - msg.append( - "Your configuration contains invalid entity ID references. " - "Please find and update the following. " - "This will become a breaking change." - ) - msg.append('\n'.join('- {} -> {}'.format(*item) - for item - in cv.INVALID_ENTITY_IDS_FOUND.items())) - - if cv.INVALID_SLUGS_FOUND: - msg.append( - "Your configuration contains invalid slugs. " - "Please find and update the following. " - "This will become a breaking change." + try: + config_dict = await conf_util.async_hass_config_yaml(hass) + except HomeAssistantError as err: + _LOGGER.error( + "Failed to parse configuration.yaml: %s. Activating safe mode", err, ) - msg.append('\n'.join('- {} -> {}'.format(*item) - for item in cv.INVALID_SLUGS_FOUND.items())) - - hass.components.persistent_notification.async_create( - '\n\n'.join(msg), "Config Warning", "config_warning" - ) - - # TEMP: warn users of invalid extra keys - # Remove after 0.92 - if cv.INVALID_EXTRA_KEYS_FOUND: - msg = [] - msg.append( - "Your configuration contains extra keys " - "that the platform does not support (but were silently " - "accepted before 0.88). Please find and remove the following." - "This will become a breaking change." - ) - msg.append('\n'.join('- {}'.format(it) - for it in cv.INVALID_EXTRA_KEYS_FOUND)) + else: + if not is_virtual_env(): + await async_mount_local_lib_path(config_dir) - hass.components.persistent_notification.async_create( - '\n\n'.join(msg), "Config Warning", "config_warning" + basic_setup_success = ( + await async_from_config_dict(config_dict, hass) is not None + ) + finally: + clear_secret_cache() + + if config_dict is None: + safe_mode = True + + elif not basic_setup_success: + _LOGGER.warning("Unable to set up core integrations. Activating safe mode") + safe_mode = True + + elif ( + "frontend" in hass.data.get(DATA_SETUP, {}) + and "frontend" not in hass.config.components + ): + _LOGGER.warning("Detected that frontend did not load. Activating safe mode") + # Ask integrations to shut down. It's messy but we can't + # do a clean stop without knowing what is broken + hass.async_track_tasks() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP, {}) + with contextlib.suppress(asyncio.TimeoutError): + async with timeout(10): + await hass.async_block_till_done() + + safe_mode = True + hass = core.HomeAssistant() + hass.config.config_dir = config_dir + + if safe_mode: + _LOGGER.info("Starting in safe mode") + hass.config.safe_mode = True + + http_conf = (await http.async_get_last_config(hass)) or {} + + await async_from_config_dict( + {"safe_mode": {}, "http": http_conf}, hass, ) return hass -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. +async def async_from_config_dict( + config: ConfigType, hass: core.HomeAssistant +) -> Optional[core.HomeAssistant]: + """Try to configure Home Assistant from a configuration dictionary. - Will add functionality to 'hass' parameter. + Dynamically loads required components and its dependencies. This method is a coroutine. """ - # Set config dir to directory holding config file - config_dir = os.path.abspath(os.path.dirname(config_path)) - hass.config.config_dir = config_dir + start = monotonic() + + hass.config_entries = config_entries.ConfigEntries(hass, config) + await hass.config_entries.async_initialize() - if not is_virtual_env(): - await async_mount_local_lib_path(config_dir) + # Set up core. + _LOGGER.debug("Setting up %s", CORE_INTEGRATIONS) - async_enable_logging(hass, verbose, log_rotate_days, log_file, - log_no_color) + 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. ") + return None + + _LOGGER.debug("Home Assistant core initialized") - await hass.async_add_executor_job( - conf_util.process_ha_config_upgrade, hass) + core_config = config.get(core.DOMAIN, {}) try: - 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) + await conf_util.async_process_ha_core_config(hass, core_config) + 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 - finally: - clear_secret_cache() - return await async_from_config_dict( - config_dict, hass, enable_log=False, skip_pip=skip_pip) + await _async_set_up_integrations(hass, config) + + stop = monotonic() + _LOGGER.info("Home Assistant initialized in %.2fs", stop - start) + + if REQUIRED_NEXT_PYTHON_DATE and sys.version_info[:3] < REQUIRED_NEXT_PYTHON_VER: + msg = ( + "Support for the running Python version " + f"{'.'.join(str(x) for x in sys.version_info[:3])} is deprecated and will " + f"be removed in the first release after {REQUIRED_NEXT_PYTHON_DATE}. " + "Please upgrade Python to " + f"{'.'.join(str(x) for x in REQUIRED_NEXT_PYTHON_VER)} or " + "higher." + ) + _LOGGER.warning(msg) + hass.components.persistent_notification.async_create( + msg, "Python version", "python_version" + ) + + return hass @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: +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. This method must be run in the event loop. """ - fmt = ("%(asctime)s %(levelname)s (%(threadName)s) " - "[%(name)s] %(message)s") - datefmt = '%Y-%m-%d %H:%M:%S' + fmt = "%(asctime)s %(levelname)s (%(threadName)s) [%(name)s] %(message)s" + datefmt = "%Y-%m-%d %H:%M:%S" if not log_no_color: try: + # pylint: disable=import-outside-toplevel 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', - } - )) + colorfmt = f"%(log_color)s{fmt}%(reset)s" + 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. + # formatting. If the above succeeds, this will 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) + logging.getLogger("requests").setLevel(logging.WARNING) + logging.getLogger("urllib3").setLevel(logging.WARNING) + logging.getLogger("aiohttp.access").setLevel(logging.WARNING) + + sys.excepthook = lambda *args: logging.getLogger(None).exception( + "Uncaught exception", exc_info=args # type: ignore + ) # Log errors to a file if we have write access to file or config dir if log_file is None: @@ -238,16 +264,16 @@ def async_enable_logging(hass: core.HomeAssistant, # 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(err_dir, os.W_OK)): + if (err_path_exists and os.access(err_log_path, os.W_OK)) or ( + 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) # type: logging.FileHandler + err_handler: logging.FileHandler = logging.handlers.TimedRotatingFileHandler( + err_log_path, when="midnight", backupCount=log_rotate_days + ) else: - err_handler = logging.FileHandler( - err_log_path, mode='w', delay=True) + 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(fmt, datefmt=datefmt)) @@ -256,21 +282,19 @@ def async_enable_logging(hass: core.HomeAssistant, async def async_stop_async_handler(_: Any) -> None: """Cleanup async handler.""" - logging.getLogger('').removeHandler(async_handler) # type: ignore + logging.getLogger("").removeHandler(async_handler) # type: ignore await async_handler.async_close(blocking=True) - hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_CLOSE, async_stop_async_handler) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_CLOSE, async_stop_async_handler) - logger = logging.getLogger('') + logger = logging.getLogger("") logger.addHandler(async_handler) # type: ignore logger.setLevel(logging.INFO) # Save the log file location for access by other components. hass.data[DATA_LOGGING] = err_log_path else: - _LOGGER.error( - "Unable to set up error log %s (access denied)", err_log_path) + _LOGGER.error("Unable to set up error log %s (access denied)", err_log_path) async def async_mount_local_lib_path(config_dir: str) -> str: @@ -278,7 +302,7 @@ async def async_mount_local_lib_path(config_dir: str) -> str: This function is a coroutine. """ - deps_dir = os.path.join(config_dir, 'deps') + deps_dir = 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) @@ -289,52 +313,55 @@ async def async_mount_local_lib_path(config_dir: str) -> str: 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) + domains = {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 + if not hass.config.safe_mode: + domains.update(hass.config_entries.async_domains()) # Make sure the Hass.io component is loaded - if 'HASSIO' in os.environ: - domains.add('hassio') + if "HASSIO" in os.environ: + domains.add("hassio") return domains async def _async_set_up_integrations( - hass: core.HomeAssistant, config: Dict[str, Any]) -> None: + hass: core.HomeAssistant, config: Dict[str, Any] +) -> None: """Set up all the integrations.""" + + async def async_setup_multi_components(domains: Set[str]) -> None: + """Set up multiple domains. Log on failure.""" + futures = { + domain: hass.async_create_task(async_setup_component(hass, domain, config)) + for domain in domains + } + await asyncio.wait(futures.values()) + errors = [domain for domain in domains if futures[domain].exception()] + for domain in errors: + exception = futures[domain].exception() + _LOGGER.error( + "Error setting up integration %s - received exception", + domain, + exc_info=(type(exception), exception, exception.__traceback__), + ) + 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]) + await async_setup_multi_components(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") + resolved_domains_task = asyncio.gather( + *(loader.async_component_dependencies(hass, domain) for domain in domains), + return_exceptions=True, + ) # Finish resolving domains for dep_domains in await resolved_domains_task: @@ -351,36 +378,28 @@ async def _async_set_up_integrations( if logging_domains: _LOGGER.info("Setting up %s", logging_domains) - await asyncio.gather(*[ - async_setup_component(hass, domain, config) - for domain in logging_domains - ]) + await async_setup_multi_components(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()) + 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 - ]) + await async_setup_multi_components(stage_1_domains) # Load all integrations - after_dependencies = {} # type: Dict[str, Set[str]] + after_dependencies: 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): + 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 - ) + 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: @@ -390,8 +409,7 @@ async def _async_set_up_integrations( 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): + 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: @@ -399,10 +417,7 @@ async def _async_set_up_integrations( _LOGGER.debug("Setting up %s", domains_to_load) - await asyncio.gather(*[ - async_setup_component(hass, domain, config) - for domain in domains_to_load - ]) + await async_setup_multi_components(domains_to_load) last_load = domains_to_load stage_2_domains -= domains_to_load @@ -412,10 +427,7 @@ async def _async_set_up_integrations( 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 - ]) + await async_setup_multi_components(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 88cd44f4bf276..90e0f32226c33 100644 --- a/homeassistant/components/__init__.py +++ b/homeassistant/components/__init__.py @@ -11,6 +11,8 @@ from homeassistant.core import split_entity_id +# mypy: allow-untyped-defs + _LOGGER = logging.getLogger(__name__) @@ -31,12 +33,11 @@ def is_on(hass, entity_id=None): component = getattr(hass.components, domain) except ImportError: - _LOGGER.error('Failed to call %s.is_on: component not found', - domain) + _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) + if not hasattr(component, "is_on"): + _LOGGER.warning("Integration %s has no is_on method.", domain) continue if component.is_on(ent_id): diff --git a/homeassistant/components/abode/__init__.py b/homeassistant/components/abode/__init__.py index 3a64a5e31f010..85e05e89cc176 100644 --- a/homeassistant/components/abode/__init__.py +++ b/homeassistant/components/abode/__init__.py @@ -1,156 +1,165 @@ -"""Support for Abode Home Security system.""" -import logging +"""Support for the Abode Security System.""" +from asyncio import gather +from copy import deepcopy from functools import partial -from requests.exceptions import HTTPError, ConnectTimeout +from abodepy import Abode +from abodepy.exceptions import AbodeException +import abodepy.helpers.timeline as TIMELINE +from requests.exceptions import ConnectTimeout, HTTPError import voluptuous as vol +from homeassistant.config_entries import SOURCE_IMPORT 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) + ATTR_ATTRIBUTION, + ATTR_DATE, + ATTR_ENTITY_ID, + ATTR_TIME, + CONF_PASSWORD, + CONF_USERNAME, + EVENT_HOMEASSISTANT_STOP, +) +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv -from homeassistant.helpers import discovery +from homeassistant.helpers.dispatcher import dispatcher_send 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, -}) +from .const import ATTRIBUTION, DEFAULT_CACHEDB, DOMAIN, LOGGER + +CONF_POLLING = "polling" + +SERVICE_SETTINGS = "change_setting" +SERVICE_CAPTURE_IMAGE = "capture_image" +SERVICE_TRIGGER_AUTOMATION = "trigger_automation" + +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_APP_TYPE = "app_type" +ATTR_EVENT_BY = "event_by" +ATTR_VALUE = "value" + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_POLLING, default=False): cv.boolean, + } + ) + }, + 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}) + +AUTOMATION_SCHEMA = vol.Schema({ATTR_ENTITY_ID: cv.entity_ids}) ABODE_PLATFORMS = [ - 'alarm_control_panel', 'binary_sensor', 'lock', 'switch', 'cover', - 'camera', 'light', 'sensor' + "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): + def __init__(self, abode, polling): """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.abode = abode self.polling = polling - self.exclude = exclude - self.lights = lights - self.devices = [] + self.entity_ids = set() + self.logout_listener = None - 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 +async def async_setup(hass, config): + """Set up Abode integration.""" + if DOMAIN not in config: + return True - def is_light(self, device): - """Check if a switch device is configured as a light.""" - import abodepy.helpers.constants as CONST + conf = config[DOMAIN] - return (device.generic_type == CONST.TYPE_LIGHT or - (device.generic_type == CONST.TYPE_SWITCH and - device.device_id in self.lights)) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=deepcopy(conf) + ) + ) + return True -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) +async def async_setup_entry(hass, config_entry): + """Set up Abode integration from a config entry.""" + username = config_entry.data.get(CONF_USERNAME) + password = config_entry.data.get(CONF_PASSWORD) + polling = config_entry.data.get(CONF_POLLING) try: cache = hass.config.path(DEFAULT_CACHEDB) - hass.data[DOMAIN] = AbodeSystem( - username, password, cache, name, polling, exclude, lights) + abode = await hass.async_add_executor_job( + Abode, username, password, True, True, True, cache + ) + hass.data[DOMAIN] = AbodeSystem(abode, polling) + except (AbodeException, ConnectTimeout, HTTPError) as ex: - _LOGGER.error("Unable to connect to Abode: %s", str(ex)) + LOGGER.error("Unable to connect to Abode: %s", str(ex)) + raise ConfigEntryNotReady - hass.components.persistent_notification.create( - 'Error: {}
' - 'You will need to restart hass after fixing.' - ''.format(ex), - title=NOTIFICATION_TITLE, - notification_id=NOTIFICATION_ID) - return False + for platform in ABODE_PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(config_entry, platform) + ) - setup_hass_services(hass) - setup_hass_events(hass) - setup_abode_events(hass) + await setup_hass_events(hass) + await hass.async_add_executor_job(setup_hass_services, hass) + await hass.async_add_executor_job(setup_abode_events, hass) + + return True + + +async def async_unload_entry(hass, config_entry): + """Unload a config entry.""" + hass.services.async_remove(DOMAIN, SERVICE_SETTINGS) + hass.services.async_remove(DOMAIN, SERVICE_CAPTURE_IMAGE) + hass.services.async_remove(DOMAIN, SERVICE_TRIGGER_AUTOMATION) + + tasks = [] for platform in ABODE_PLATFORMS: - discovery.load_platform(hass, platform, DOMAIN, {}, config) + tasks.append( + hass.config_entries.async_forward_entry_unload(config_entry, platform) + ) + + await gather(*tasks) + + await hass.async_add_executor_job(hass.data[DOMAIN].abode.events.stop) + await hass.async_add_executor_job(hass.data[DOMAIN].abode.logout) + + hass.data[DOMAIN].logout_listener() + hass.data.pop(DOMAIN) return True def setup_hass_services(hass): - """Home assistant services.""" - from abodepy.exceptions import AbodeException + """Home Assistant services.""" def change_setting(call): """Change an Abode system setting.""" @@ -160,46 +169,51 @@ def change_setting(call): try: hass.data[DOMAIN].abode.set_setting(setting, value) except AbodeException as ex: - _LOGGER.warning(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] + target_entities = [ + entity_id + for entity_id in hass.data[DOMAIN].entity_ids + if entity_id in entity_ids + ] - for device in target_devices: - device.capture() + for entity_id in target_entities: + signal = f"abode_camera_capture_{entity_id}" + dispatcher_send(hass, signal) - def trigger_quick_action(call): - """Trigger a quick action.""" - entity_ids = call.data.get(ATTR_ENTITY_ID, None) + def trigger_automation(call): + """Trigger an Abode automation.""" + 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] + target_entities = [ + entity_id + for entity_id in hass.data[DOMAIN].entity_ids + if entity_id in entity_ids + ] - for device in target_devices: - device.trigger() + for entity_id in target_entities: + signal = f"abode_trigger_automation_{entity_id}" + dispatcher_send(hass, signal) hass.services.register( - DOMAIN, SERVICE_SETTINGS, change_setting, - schema=CHANGE_SETTING_SCHEMA) + DOMAIN, SERVICE_SETTINGS, change_setting, schema=CHANGE_SETTING_SCHEMA + ) hass.services.register( - DOMAIN, SERVICE_CAPTURE_IMAGE, capture_image, - schema=CAPTURE_IMAGE_SCHEMA) + DOMAIN, SERVICE_CAPTURE_IMAGE, capture_image, schema=CAPTURE_IMAGE_SCHEMA + ) hass.services.register( - DOMAIN, SERVICE_TRIGGER, trigger_quick_action, - schema=TRIGGER_SCHEMA) + DOMAIN, SERVICE_TRIGGER_AUTOMATION, trigger_automation, schema=AUTOMATION_SCHEMA + ) -def setup_hass_events(hass): +async 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.""" @@ -207,72 +221,128 @@ def logout(event): hass.data[DOMAIN].abode.events.stop() hass.data[DOMAIN].abode.logout() - _LOGGER.info("Logged out of Abode") + LOGGER.info("Logged out of Abode") if not hass.data[DOMAIN].polling: - hass.bus.listen_once(EVENT_HOMEASSISTANT_START, startup) + await hass.async_add_executor_job(hass.data[DOMAIN].abode.events.start) - hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, logout) + hass.data[DOMAIN].logout_listener = hass.bus.async_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, ''), + 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_APP_TYPE: event_json.get(ATTR_APP_TYPE, ""), + ATTR_EVENT_BY: event_json.get(ATTR_EVENT_BY, ""), + 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] + events = [ + TIMELINE.ALARM_GROUP, + TIMELINE.ALARM_END_GROUP, + TIMELINE.PANEL_FAULT_GROUP, + TIMELINE.PANEL_RESTORE_GROUP, + TIMELINE.AUTOMATION_GROUP, + TIMELINE.DISARM_GROUP, + TIMELINE.ARM_GROUP, + TIMELINE.TEST_GROUP, + TIMELINE.CAPTURE_GROUP, + TIMELINE.DEVICE_GROUP, + ] for event in events: hass.data[DOMAIN].abode.events.add_event_callback( - event, - partial(event_callback, event)) + event, partial(event_callback, event) + ) + + +class AbodeEntity(Entity): + """Representation of an Abode entity.""" + + def __init__(self, data): + """Initialize Abode entity.""" + self._data = data + self._available = True + @property + def available(self): + """Return the available state.""" + return self._available + + @property + def should_poll(self): + """Return the polling state.""" + return self._data.polling + + async def async_added_to_hass(self): + """Subscribe to Abode connection status updates.""" + await self.hass.async_add_executor_job( + self._data.abode.events.add_connection_status_callback, + self.unique_id, + self._update_connection_status, + ) -class AbodeDevice(Entity): + self.hass.data[DOMAIN].entity_ids.add(self.entity_id) + + async def async_will_remove_from_hass(self): + """Unsubscribe from Abode connection status updates.""" + await self.hass.async_add_executor_job( + self._data.abode.events.remove_connection_status_callback, self.unique_id + ) + + def _update_connection_status(self): + """Update the entity available property.""" + self._available = self._data.abode.events.connected + self.schedule_update_ha_state() + + +class AbodeDevice(AbodeEntity): """Representation of an Abode device.""" def __init__(self, data, device): - """Initialize a sensor for Abode device.""" - self._data = data + """Initialize Abode device.""" + super().__init__(data) self._device = device async def async_added_to_hass(self): - """Subscribe Abode events.""" - self.hass.async_add_job( + """Subscribe to device events.""" + await super().async_added_to_hass() + await self.hass.async_add_executor_job( self._data.abode.events.add_device_callback, - self._device.device_id, self._update_callback + self._device.device_id, + self._update_callback, ) - @property - def should_poll(self): - """Return the polling state.""" - return self._data.polling + async def async_will_remove_from_hass(self): + """Unsubscribe from device events.""" + await super().async_will_remove_from_hass() + await self.hass.async_add_executor_job( + self._data.abode.events.remove_all_device_callbacks, self._device.device_id + ) def update(self): - """Update automation state.""" + """Update device state.""" self._device.refresh() @property def name(self): - """Return the name of the sensor.""" + """Return the name of the device.""" return self._device.name @property @@ -280,10 +350,25 @@ 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 + "device_id": self._device.device_id, + "battery_low": self._device.battery_low, + "no_response": self._device.no_response, + "device_type": self._device.type, + } + + @property + def unique_id(self): + """Return a unique ID to use for this device.""" + return self._device.device_uuid + + @property + def device_info(self): + """Return device registry information for this entity.""" + return { + "identifiers": {(DOMAIN, self._device.device_id)}, + "manufacturer": "Abode", + "name": self._device.name, + "device_type": self._device.type, } def _update_callback(self, device): @@ -291,27 +376,13 @@ def _update_callback(self, device): self.schedule_update_ha_state() -class AbodeAutomation(Entity): +class AbodeAutomation(AbodeEntity): """Representation of an Abode automation.""" - def __init__(self, data, automation, event=None): + def __init__(self, data, automation): """Initialize for Abode automation.""" - self._data = data + super().__init__(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.""" @@ -319,20 +390,15 @@ def update(self): @property def name(self): - """Return the name of the sensor.""" + """Return the name of the automation.""" 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 - } + return {ATTR_ATTRIBUTION: ATTRIBUTION, "type": "CUE automation"} - def _update_callback(self, device): - """Update the device state.""" - self._automation.refresh() - self.schedule_update_ha_state() + @property + def unique_id(self): + """Return a unique ID to use for this automation.""" + return self._automation.automation_id diff --git a/homeassistant/components/abode/alarm_control_panel.py b/homeassistant/components/abode/alarm_control_panel.py index d1d75b7417ea4..c508d0f02408d 100644 --- a/homeassistant/components/abode/alarm_control_panel.py +++ b/homeassistant/components/abode/alarm_control_panel.py @@ -1,37 +1,33 @@ """Support for Abode Security System alarm control panels.""" -import logging - import homeassistant.components.alarm_control_panel as alarm +from homeassistant.components.alarm_control_panel.const import ( + SUPPORT_ALARM_ARM_AWAY, + SUPPORT_ALARM_ARM_HOME, +) 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__) + ATTR_ATTRIBUTION, + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_HOME, + STATE_ALARM_DISARMED, +) -ICON = 'mdi:security' +from . import AbodeDevice +from .const import ATTRIBUTION, DOMAIN +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)] +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Abode alarm control panel device.""" + data = hass.data[DOMAIN] + async_add_entities( + [AbodeAlarm(data, await hass.async_add_executor_job(data.abode.get_alarm))] + ) - data.devices.extend(alarm_devices) - add_entities(alarm_devices) - - -class AbodeAlarm(AbodeDevice, alarm.AlarmControlPanel): +class AbodeAlarm(AbodeDevice, alarm.AlarmControlPanelEntity): """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.""" @@ -50,6 +46,16 @@ def state(self): state = None return state + @property + def code_arm_required(self): + """Whether the code is required for arm actions.""" + return False + + @property + def supported_features(self) -> int: + """Return the list of supported features.""" + return SUPPORT_ALARM_ARM_HOME | SUPPORT_ALARM_ARM_AWAY + def alarm_disarm(self, code=None): """Send disarm command.""" self._device.set_standby() @@ -62,17 +68,12 @@ 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, + "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 index e3f74e9f4ec12..7175fbc550a95 100644 --- a/homeassistant/components/abode/binary_sensor.py +++ b/homeassistant/components/abode/binary_sensor.py @@ -1,45 +1,36 @@ """Support for Abode Security System binary sensors.""" -import logging +import abodepy.helpers.constants as CONST -from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_WINDOW, + BinarySensorEntity, +) -from . import DOMAIN as ABODE_DOMAIN, AbodeAutomation, AbodeDevice +from . import AbodeDevice +from .const import DOMAIN -_LOGGER = logging.getLogger(__name__) +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Abode binary sensor devices.""" + data = hass.data[DOMAIN] -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 + device_types = [ + CONST.TYPE_CONNECTIVITY, + CONST.TYPE_MOISTURE, + CONST.TYPE_MOTION, + CONST.TYPE_OCCUPANCY, + CONST.TYPE_OPENING, + ] - data = hass.data[ABODE_DOMAIN] + entities = [] - 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 + entities.append(AbodeBinarySensor(data, device)) - devices.append(AbodeQuickActionBinarySensor( - data, automation, TIMELINE.AUTOMATION_EDIT_GROUP)) + async_add_entities(entities) - data.devices.extend(devices) - add_entities(devices) - - -class AbodeBinarySensor(AbodeDevice, BinarySensorDevice): +class AbodeBinarySensor(AbodeDevice, BinarySensorEntity): """A binary sensor implementation for Abode device.""" @property @@ -50,17 +41,6 @@ def is_on(self): @property def device_class(self): """Return the class of the binary sensor.""" + if self._device.get_value("is_window") == "1": + return DEVICE_CLASS_WINDOW 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 index d0e4e833029fc..b7d5f1dbe4cab 100644 --- a/homeassistant/components/abode/camera.py +++ b/homeassistant/components/abode/camera.py @@ -1,36 +1,30 @@ """Support for Abode Security System cameras.""" from datetime import timedelta -import logging +import abodepy.helpers.constants as CONST +import abodepy.helpers.timeline as TIMELINE import requests from homeassistant.components.camera import Camera +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.util import Throttle -from . import DOMAIN as ABODE_DOMAIN, AbodeDevice +from . import AbodeDevice +from .const import DOMAIN, LOGGER MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=90) -_LOGGER = logging.getLogger(__name__) - -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_entry(hass, config_entry, async_add_entities): """Set up Abode camera devices.""" - import abodepy.helpers.constants as CONST - import abodepy.helpers.timeline as TIMELINE + data = hass.data[DOMAIN] - data = hass.data[ABODE_DOMAIN] + entities = [] - 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)) + entities.append(AbodeCamera(data, device, TIMELINE.CAPTURE_IMAGE)) - data.devices.extend(devices) - - add_entities(devices) + async_add_entities(entities) class AbodeCamera(AbodeDevice, Camera): @@ -47,11 +41,15 @@ async def async_added_to_hass(self): """Subscribe Abode events.""" await super().async_added_to_hass() - self.hass.async_add_job( + self.hass.async_add_executor_job( self._data.abode.events.add_timeline_callback, - self._event, self._capture_callback + self._event, + self._capture_callback, ) + signal = f"abode_camera_capture_{self.entity_id}" + self.async_on_remove(async_dispatcher_connect(self.hass, signal, self.capture)) + def capture(self): """Request a new image capture.""" return self._device.capture() @@ -66,12 +64,11 @@ 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 = 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) + LOGGER.warning("Failed to get camera image: %s", err) self._response = None else: self._response = None diff --git a/homeassistant/components/abode/config_flow.py b/homeassistant/components/abode/config_flow.py new file mode 100644 index 0000000000000..18146551a5693 --- /dev/null +++ b/homeassistant/components/abode/config_flow.py @@ -0,0 +1,77 @@ +"""Config flow for the Abode Security System component.""" +from abodepy import Abode +from abodepy.exceptions import AbodeException +from requests.exceptions import ConnectTimeout, HTTPError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, HTTP_BAD_REQUEST +from homeassistant.core import callback + +from .const import DEFAULT_CACHEDB, DOMAIN, LOGGER # pylint: disable=unused-import + +CONF_POLLING = "polling" + + +class AbodeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Config flow for Abode.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + def __init__(self): + """Initialize.""" + self.data_schema = { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } + + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + + if not user_input: + return self._show_form() + + username = user_input[CONF_USERNAME] + password = user_input[CONF_PASSWORD] + polling = user_input.get(CONF_POLLING, False) + cache = self.hass.config.path(DEFAULT_CACHEDB) + + try: + await self.hass.async_add_executor_job( + Abode, username, password, True, True, True, cache + ) + + except (AbodeException, ConnectTimeout, HTTPError) as ex: + LOGGER.error("Unable to connect to Abode: %s", str(ex)) + if ex.errcode == HTTP_BAD_REQUEST: + return self._show_form({"base": "invalid_credentials"}) + return self._show_form({"base": "connection_error"}) + + return self.async_create_entry( + title=user_input[CONF_USERNAME], + data={ + CONF_USERNAME: username, + CONF_PASSWORD: password, + CONF_POLLING: polling, + }, + ) + + @callback + def _show_form(self, errors=None): + """Show the form to the user.""" + return self.async_show_form( + step_id="user", + data_schema=vol.Schema(self.data_schema), + errors=errors if errors else {}, + ) + + async def async_step_import(self, import_config): + """Import a config entry from configuration.yaml.""" + if self._async_current_entries(): + LOGGER.warning("Only one configuration of abode is allowed.") + return self.async_abort(reason="single_instance_allowed") + + return await self.async_step_user(import_config) diff --git a/homeassistant/components/abode/const.py b/homeassistant/components/abode/const.py new file mode 100644 index 0000000000000..b509984876b25 --- /dev/null +++ b/homeassistant/components/abode/const.py @@ -0,0 +1,9 @@ +"""Constants for the Abode Security System component.""" +import logging + +LOGGER = logging.getLogger(__package__) + +DOMAIN = "abode" +ATTRIBUTION = "Data provided by goabode.com" + +DEFAULT_CACHEDB = "abodepy_cache.pickle" diff --git a/homeassistant/components/abode/cover.py b/homeassistant/components/abode/cover.py index 4c868daf4ba93..d88c2fdd4048f 100644 --- a/homeassistant/components/abode/cover.py +++ b/homeassistant/components/abode/cover.py @@ -1,32 +1,25 @@ """Support for Abode Security System covers.""" -import logging +import abodepy.helpers.constants as CONST -from homeassistant.components.cover import CoverDevice +from homeassistant.components.cover import CoverEntity -from . import DOMAIN as ABODE_DOMAIN, AbodeDevice +from . import AbodeDevice +from .const import DOMAIN -_LOGGER = logging.getLogger(__name__) - -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_entry(hass, config_entry, async_add_entities): """Set up Abode cover devices.""" - import abodepy.helpers.constants as CONST + data = hass.data[DOMAIN] - data = hass.data[ABODE_DOMAIN] + entities = [] - 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) + entities.append(AbodeCover(data, device)) - add_entities(devices) + async_add_entities(entities) -class AbodeCover(AbodeDevice, CoverDevice): +class AbodeCover(AbodeDevice, CoverEntity): """Representation of an Abode cover.""" @property diff --git a/homeassistant/components/abode/light.py b/homeassistant/components/abode/light.py index 6b3e5025c5140..b756c79d9decb 100644 --- a/homeassistant/components/abode/light.py +++ b/homeassistant/components/abode/light.py @@ -1,59 +1,60 @@ """Support for Abode Security System lights.""" -import logging from math import ceil +import abodepy.helpers.constants as CONST + from homeassistant.components.light import ( - ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_HS_COLOR, SUPPORT_BRIGHTNESS, - SUPPORT_COLOR, SUPPORT_COLOR_TEMP, Light) + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP, + ATTR_HS_COLOR, + SUPPORT_BRIGHTNESS, + SUPPORT_COLOR, + SUPPORT_COLOR_TEMP, + LightEntity, +) from homeassistant.util.color import ( - color_temperature_kelvin_to_mired, color_temperature_mired_to_kelvin) - -from . import DOMAIN as ABODE_DOMAIN, AbodeDevice + color_temperature_kelvin_to_mired, + color_temperature_mired_to_kelvin, +) -_LOGGER = logging.getLogger(__name__) +from . import AbodeDevice +from .const import DOMAIN -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_entry(hass, config_entry, async_add_entities): """Set up Abode light devices.""" - import abodepy.helpers.constants as CONST - - data = hass.data[ABODE_DOMAIN] - - device_types = [CONST.TYPE_LIGHT, CONST.TYPE_SWITCH] + data = hass.data[DOMAIN] - devices = [] + entities = [] - # 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 + for device in data.abode.get_devices(generic_type=CONST.TYPE_LIGHT): + entities.append(AbodeLight(data, device)) - devices.append(AbodeLight(data, device)) + async_add_entities(entities) - data.devices.extend(devices) - add_entities(devices) - - -class AbodeLight(AbodeDevice, Light): +class AbodeLight(AbodeDevice, LightEntity): """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]))) + int(color_temperature_mired_to_kelvin(kwargs[ATTR_COLOR_TEMP])) + ) + return if ATTR_HS_COLOR in kwargs and self._device.is_color_capable: self._device.set_color(kwargs[ATTR_HS_COLOR]) + return if ATTR_BRIGHTNESS in kwargs and self._device.is_dimmable: - # Convert HASS brightness (0-255) to Abode brightness (0-99) + # Convert Home Assistant 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() + return + + self._device.switch_on() def turn_off(self, **kwargs): """Turn off the light.""" @@ -72,7 +73,7 @@ def brightness(self): # Abode returns 100 during device initialization and device refresh if brightness == 100: return 255 - # Convert Abode brightness (0-99) to HASS brightness (0-255) + # Convert Abode brightness (0-99) to Home Assistant brightness (0-255) return ceil(brightness * 255 / 99.0) @property diff --git a/homeassistant/components/abode/lock.py b/homeassistant/components/abode/lock.py index c1272a3de5f40..2a52663c0e7b5 100644 --- a/homeassistant/components/abode/lock.py +++ b/homeassistant/components/abode/lock.py @@ -1,32 +1,25 @@ -"""Support for Abode Security System locks.""" -import logging +"""Support for the Abode Security System locks.""" +import abodepy.helpers.constants as CONST -from homeassistant.components.lock import LockDevice +from homeassistant.components.lock import LockEntity -from . import DOMAIN as ABODE_DOMAIN, AbodeDevice +from . import AbodeDevice +from .const import DOMAIN -_LOGGER = logging.getLogger(__name__) - -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_entry(hass, config_entry, async_add_entities): """Set up Abode lock devices.""" - import abodepy.helpers.constants as CONST + data = hass.data[DOMAIN] - data = hass.data[ABODE_DOMAIN] + entities = [] - 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) + entities.append(AbodeLock(data, device)) - add_entities(devices) + async_add_entities(entities) -class AbodeLock(AbodeDevice, LockDevice): +class AbodeLock(AbodeDevice, LockEntity): """Representation of an Abode lock.""" def lock(self, **kwargs): diff --git a/homeassistant/components/abode/manifest.json b/homeassistant/components/abode/manifest.json index 49e0c46fd553b..c8dace4e87bb2 100644 --- a/homeassistant/components/abode/manifest.json +++ b/homeassistant/components/abode/manifest.json @@ -1,10 +1,8 @@ { "domain": "abode", "name": "Abode", - "documentation": "https://www.home-assistant.io/components/abode", - "requirements": [ - "abodepy==0.15.0" - ], - "dependencies": [], - "codeowners": [] + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/abode", + "requirements": ["abodepy==0.19.0"], + "codeowners": ["@shred86"] } diff --git a/homeassistant/components/abode/sensor.py b/homeassistant/components/abode/sensor.py index b7e8fc1a118b0..6ecc5c871cd09 100644 --- a/homeassistant/components/abode/sensor.py +++ b/homeassistant/components/abode/sensor.py @@ -1,38 +1,36 @@ """Support for Abode Security System sensors.""" -import logging +import abodepy.helpers.constants as CONST from homeassistant.const import ( - DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, DEVICE_CLASS_TEMPERATURE) + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_ILLUMINANCE, + DEVICE_CLASS_TEMPERATURE, +) -from . import DOMAIN as ABODE_DOMAIN, AbodeDevice - -_LOGGER = logging.getLogger(__name__) +from . import AbodeDevice +from .const import DOMAIN # Sensor types: Name, icon SENSOR_TYPES = { - 'temp': ['Temperature', DEVICE_CLASS_TEMPERATURE], - 'humidity': ['Humidity', DEVICE_CLASS_HUMIDITY], - 'lux': ['Lux', DEVICE_CLASS_ILLUMINANCE], + CONST.TEMP_STATUS_KEY: ["Temperature", DEVICE_CLASS_TEMPERATURE], + CONST.HUMI_STATUS_KEY: ["Humidity", DEVICE_CLASS_HUMIDITY], + CONST.LUX_STATUS_KEY: ["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 +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Abode sensor devices.""" + data = hass.data[DOMAIN] - data = hass.data[ABODE_DOMAIN] + entities = [] - 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) + if sensor_type not in device.get_value(CONST.STATUSES_KEY): + continue + entities.append(AbodeSensor(data, device, sensor_type)) - add_entities(devices) + async_add_entities(entities) class AbodeSensor(AbodeDevice): @@ -42,8 +40,7 @@ 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._name = f"{self._device.name} {SENSOR_TYPES[self._sensor_type][0]}" self._device_class = SENSOR_TYPES[self._sensor_type][1] @property @@ -56,22 +53,27 @@ def device_class(self): """Return the device class.""" return self._device_class + @property + def unique_id(self): + """Return a unique ID to use for this device.""" + return f"{self._device.device_uuid}-{self._sensor_type}" + @property def state(self): """Return the state of the sensor.""" - if self._sensor_type == 'temp': + if self._sensor_type == CONST.TEMP_STATUS_KEY: return self._device.temp - if self._sensor_type == 'humidity': + if self._sensor_type == CONST.HUMI_STATUS_KEY: return self._device.humidity - if self._sensor_type == 'lux': + if self._sensor_type == CONST.LUX_STATUS_KEY: return self._device.lux @property def unit_of_measurement(self): """Return the units of measurement.""" - if self._sensor_type == 'temp': + if self._sensor_type == CONST.TEMP_STATUS_KEY: return self._device.temp_unit - if self._sensor_type == 'humidity': + if self._sensor_type == CONST.HUMI_STATUS_KEY: return self._device.humidity_unit - if self._sensor_type == 'lux': + if self._sensor_type == CONST.LUX_STATUS_KEY: return self._device.lux_unit diff --git a/homeassistant/components/abode/services.yaml b/homeassistant/components/abode/services.yaml index ad0bb076d90b4..f694afc029834 100644 --- a/homeassistant/components/abode/services.yaml +++ b/homeassistant/components/abode/services.yaml @@ -1,13 +1,21 @@ 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} + 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. + setting: + description: Setting to change. + example: beeper_mute + value: + description: Value of the setting. + example: "1" +trigger_automation: + description: Trigger an Abode automation. fields: - entity_id: {description: Entity id of the quick action to trigger., example: binary_sensor.home_quick_action} + entity_id: + description: Entity id of the automation to trigger. + example: switch.my_automation diff --git a/homeassistant/components/abode/strings.json b/homeassistant/components/abode/strings.json new file mode 100644 index 0000000000000..f6e7039a90899 --- /dev/null +++ b/homeassistant/components/abode/strings.json @@ -0,0 +1,18 @@ +{ + "config": { + "step": { + "user": { + "title": "Fill in your Abode login information", + "data": { "username": "Email Address", "password": "Password" } + } + }, + "error": { + "identifier_exists": "Account already registered.", + "invalid_credentials": "Invalid credentials.", + "connection_error": "Unable to connect to Abode." + }, + "abort": { + "single_instance_allowed": "Only a single configuration of Abode is allowed." + } + } +} diff --git a/homeassistant/components/abode/switch.py b/homeassistant/components/abode/switch.py index 74d1ea57bad44..0985ce5ce2a30 100644 --- a/homeassistant/components/abode/switch.py +++ b/homeassistant/components/abode/switch.py @@ -1,44 +1,34 @@ """Support for Abode Security System switches.""" -import logging +import abodepy.helpers.constants as CONST -from homeassistant.components.switch import SwitchDevice +from homeassistant.components.switch import SwitchEntity +from homeassistant.helpers.dispatcher import async_dispatcher_connect -from . import DOMAIN as ABODE_DOMAIN, AbodeAutomation, AbodeDevice +from . import AbodeAutomation, AbodeDevice +from .const import DOMAIN -_LOGGER = logging.getLogger(__name__) +DEVICE_TYPES = [CONST.TYPE_SWITCH, CONST.TYPE_VALVE] +ICON = "mdi:robot" -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)) +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Abode switch devices.""" + data = hass.data[DOMAIN] - # 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 + entities = [] - devices.append(AbodeAutomationSwitch( - data, automation, TIMELINE.AUTOMATION_EDIT_GROUP)) + for device_type in DEVICE_TYPES: + for device in data.abode.get_devices(generic_type=device_type): + entities.append(AbodeSwitch(data, device)) - data.devices.extend(devices) + for automation in data.abode.get_automations(): + entities.append(AbodeAutomationSwitch(data, automation)) - add_entities(devices) + async_add_entities(entities) -class AbodeSwitch(AbodeDevice, SwitchDevice): +class AbodeSwitch(AbodeDevice, SwitchEntity): """Representation of an Abode switch.""" def turn_on(self, **kwargs): @@ -55,18 +45,36 @@ def is_on(self): return self._device.is_on -class AbodeAutomationSwitch(AbodeAutomation, SwitchDevice): +class AbodeAutomationSwitch(AbodeAutomation, SwitchEntity): """A switch implementation for Abode automations.""" + async def async_added_to_hass(self): + """Set up trigger automation service.""" + await super().async_added_to_hass() + + signal = f"abode_trigger_automation_{self.entity_id}" + self.async_on_remove(async_dispatcher_connect(self.hass, signal, self.trigger)) + def turn_on(self, **kwargs): - """Turn on the device.""" - self._automation.set_active(True) + """Enable the automation.""" + if self._automation.enable(True): + self.schedule_update_ha_state() def turn_off(self, **kwargs): - """Turn off the device.""" - self._automation.set_active(False) + """Disable the automation.""" + if self._automation.enable(False): + self.schedule_update_ha_state() + + def trigger(self): + """Trigger the automation.""" + self._automation.trigger() @property def is_on(self): - """Return True if the binary sensor is on.""" - return self._automation.is_active + """Return True if the automation is enabled.""" + return self._automation.is_enabled + + @property + def icon(self): + """Return the robot icon to match Home Assistant automations.""" + return ICON diff --git a/homeassistant/components/abode/translations/bg.json b/homeassistant/components/abode/translations/bg.json new file mode 100644 index 0000000000000..3489c8bc8660e --- /dev/null +++ b/homeassistant/components/abode/translations/bg.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u0420\u0430\u0437\u0440\u0435\u0448\u0435\u043d\u0430 \u0435 \u0441\u0430\u043c\u043e \u0435\u0434\u043d\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f \u043d\u0430 Abode." + }, + "error": { + "connection_error": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435 \u0441 Abode.", + "identifier_exists": "\u041f\u0440\u043e\u0444\u0438\u043b\u044a\u0442 \u0435 \u0432\u0435\u0447\u0435 \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u0430\u043d.", + "invalid_credentials": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u0438 \u0438\u0434\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u043e\u043d\u043d\u0438 \u0434\u0430\u043d\u043d\u0438." + }, + "step": { + "user": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u0430", + "username": "E-mail \u0430\u0434\u0440\u0435\u0441" + }, + "title": "\u041f\u043e\u043f\u044a\u043b\u043d\u0435\u0442\u0435 \u0412\u0430\u0448\u0430\u0442\u0430 \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044f \u0437\u0430 \u0432\u0445\u043e\u0434 \u0432 Abode" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/abode/translations/ca.json b/homeassistant/components/abode/translations/ca.json new file mode 100644 index 0000000000000..5a1552700d990 --- /dev/null +++ b/homeassistant/components/abode/translations/ca.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Nom\u00e9s es permet una \u00fanica configuraci\u00f3 d'Abode." + }, + "error": { + "connection_error": "No es pot connectar amb Abode.", + "identifier_exists": "Compte ja registrat.", + "invalid_credentials": "Credencials inv\u00e0lides." + }, + "step": { + "user": { + "data": { + "password": "Contrasenya", + "username": "Correu electr\u00f2nic" + }, + "title": "Introducci\u00f3 de la informaci\u00f3 d'inici de sessi\u00f3 a Abode." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/abode/translations/cs.json b/homeassistant/components/abode/translations/cs.json new file mode 100644 index 0000000000000..e482cce526f9e --- /dev/null +++ b/homeassistant/components/abode/translations/cs.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Je povolena pouze jedna konfigurace Abode." + }, + "error": { + "connection_error": "Nelze se p\u0159ipojit k Abode.", + "identifier_exists": "\u00da\u010det je ji\u017e zaregistrov\u00e1n.", + "invalid_credentials": "Neplatn\u00e9 p\u0159ihla\u0161ovac\u00ed \u00fadaje." + }, + "step": { + "user": { + "data": { + "password": "Heslo", + "username": "E-mailov\u00e1 adresa" + }, + "title": "Vypl\u0148te p\u0159ihla\u0161ovac\u00ed \u00fadaje Abode" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/abode/translations/da.json b/homeassistant/components/abode/translations/da.json new file mode 100644 index 0000000000000..c00fd6ad5af69 --- /dev/null +++ b/homeassistant/components/abode/translations/da.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Det er kun n\u00f8dvendigt med en ops\u00e6tning af Abode." + }, + "error": { + "connection_error": "Kunne ikke oprette forbindelse til Abode.", + "identifier_exists": "Konto er allerede registreret.", + "invalid_credentials": "Ugyldige legitimationsoplysninger." + }, + "step": { + "user": { + "data": { + "password": "Adgangskode", + "username": "Email-adresse" + }, + "title": "Udfyld dine Abode-loginoplysninger" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/abode/translations/de.json b/homeassistant/components/abode/translations/de.json new file mode 100644 index 0000000000000..abbac44f2e315 --- /dev/null +++ b/homeassistant/components/abode/translations/de.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Es ist nur eine einzige Konfiguration von Abode erlaubt." + }, + "error": { + "connection_error": "Es kann keine Verbindung zu Abode hergestellt werden.", + "identifier_exists": "Das Konto ist bereits registriert.", + "invalid_credentials": "Ung\u00fcltige Anmeldeinformationen" + }, + "step": { + "user": { + "data": { + "password": "Passwort", + "username": "E-Mail-Adresse" + }, + "title": "Gib deine Abode-Anmeldeinformationen ein" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/abode/translations/en.json b/homeassistant/components/abode/translations/en.json new file mode 100644 index 0000000000000..feaef16fdffc4 --- /dev/null +++ b/homeassistant/components/abode/translations/en.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Only a single configuration of Abode is allowed." + }, + "error": { + "connection_error": "Unable to connect to Abode.", + "identifier_exists": "Account already registered.", + "invalid_credentials": "Invalid credentials." + }, + "step": { + "user": { + "data": { + "password": "Password", + "username": "Email Address" + }, + "title": "Fill in your Abode login information" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/abode/translations/es-419.json b/homeassistant/components/abode/translations/es-419.json new file mode 100644 index 0000000000000..ced57c4fdbd8d --- /dev/null +++ b/homeassistant/components/abode/translations/es-419.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Solo se permite una \u00fanica configuraci\u00f3n de Abode." + }, + "error": { + "connection_error": "No se puede conectar a Abode.", + "identifier_exists": "Cuenta ya registrada.", + "invalid_credentials": "Credenciales inv\u00e1lidas." + }, + "step": { + "user": { + "data": { + "password": "Contrase\u00f1a", + "username": "Direcci\u00f3n de correo electr\u00f3nico" + }, + "title": "Complete su informaci\u00f3n de inicio de sesi\u00f3n de Abode" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/abode/translations/es.json b/homeassistant/components/abode/translations/es.json new file mode 100644 index 0000000000000..76f06de9b8548 --- /dev/null +++ b/homeassistant/components/abode/translations/es.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Solo se permite una \u00fanica configuraci\u00f3n de Abode." + }, + "error": { + "connection_error": "No se puede conectar a Abode.", + "identifier_exists": "Cuenta ya registrada.", + "invalid_credentials": "Credenciales inv\u00e1lidas." + }, + "step": { + "user": { + "data": { + "password": "Contrase\u00f1a", + "username": "Direcci\u00f3n de correo electr\u00f3nico" + }, + "title": "Rellene la informaci\u00f3n de acceso Abode" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/abode/translations/fr.json b/homeassistant/components/abode/translations/fr.json new file mode 100644 index 0000000000000..1c4cfe0087256 --- /dev/null +++ b/homeassistant/components/abode/translations/fr.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Une seule configuration d'Abode est autoris\u00e9e." + }, + "error": { + "connection_error": "Impossible de se connecter \u00e0 Abode.", + "identifier_exists": "Compte d\u00e9j\u00e0 enregistr\u00e9.", + "invalid_credentials": "Informations d'identification invalides." + }, + "step": { + "user": { + "data": { + "password": "Mot de passe", + "username": "Adresse e-mail" + }, + "title": "Remplissez vos informations de connexion Abode" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/abode/translations/hu.json b/homeassistant/components/abode/translations/hu.json new file mode 100644 index 0000000000000..89b695da7d91a --- /dev/null +++ b/homeassistant/components/abode/translations/hu.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Csak egyetlen Abode konfigur\u00e1ci\u00f3 enged\u00e9lyezett." + }, + "error": { + "connection_error": "Nem lehet csatlakozni az Abode-hez.", + "identifier_exists": "Fi\u00f3k m\u00e1r regisztr\u00e1lva van", + "invalid_credentials": "\u00c9rv\u00e9nytelen hiteles\u00edt\u0151 adatok" + }, + "step": { + "user": { + "data": { + "password": "Jelsz\u00f3", + "username": "Email c\u00edm" + }, + "title": "T\u00f6ltse ki az Abode bejelentkez\u00e9si adatait" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/abode/translations/it.json b/homeassistant/components/abode/translations/it.json new file mode 100644 index 0000000000000..414ffb92ef494 --- /dev/null +++ b/homeassistant/components/abode/translations/it.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u00c8 consentita una sola configurazione di Abode." + }, + "error": { + "connection_error": "Impossibile connettersi ad Abode.", + "identifier_exists": "Account gi\u00e0 registrato", + "invalid_credentials": "Credenziali non valide" + }, + "step": { + "user": { + "data": { + "password": "Password", + "username": "Indirizzo email" + }, + "title": "Inserisci le tue informazioni di accesso Abode" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/abode/translations/ko.json b/homeassistant/components/abode/translations/ko.json new file mode 100644 index 0000000000000..0c18044045deb --- /dev/null +++ b/homeassistant/components/abode/translations/ko.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\ud558\ub098\uc758 Abode \ub9cc \uad6c\uc131\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4." + }, + "error": { + "connection_error": "Abode \uc5d0 \uc5f0\uacb0\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4.", + "identifier_exists": "\uacc4\uc815\uc774 \uc774\ubbf8 \ub4f1\ub85d\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "invalid_credentials": "\uc0ac\uc6a9\uc790 \uc774\ub984 \ud639\uc740 \ube44\ubc00\ubc88\ud638\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, + "step": { + "user": { + "data": { + "password": "\ube44\ubc00\ubc88\ud638", + "username": "\uc774\uba54\uc77c \uc8fc\uc18c" + }, + "title": "Abode \uc0ac\uc6a9\uc790 \uc815\ubcf4 \uc785\ub825\ud558\uae30" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/abode/translations/lb.json b/homeassistant/components/abode/translations/lb.json new file mode 100644 index 0000000000000..4e8f6084b54f7 --- /dev/null +++ b/homeassistant/components/abode/translations/lb.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "N\u00ebmmen eng eenzeg Konfiguratioun vun ZHA ass erlaabt." + }, + "error": { + "connection_error": "Kann sech net mat Abode verbannen.", + "identifier_exists": "Konto ass scho registr\u00e9iert", + "invalid_credentials": "Ong\u00eblteg Login Informatioune" + }, + "step": { + "user": { + "data": { + "password": "Passwuert", + "username": "E-Mail Adress" + }, + "title": "F\u00ebllt \u00e4r Abode Login Informatiounen aus." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/abode/translations/lv.json b/homeassistant/components/abode/translations/lv.json new file mode 100644 index 0000000000000..eab98211e14fd --- /dev/null +++ b/homeassistant/components/abode/translations/lv.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "Parole", + "username": "E-pasta adrese" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/abode/translations/nl.json b/homeassistant/components/abode/translations/nl.json new file mode 100644 index 0000000000000..580b1f487c02a --- /dev/null +++ b/homeassistant/components/abode/translations/nl.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Slechts een enkele configuratie van Abode is toegestaan." + }, + "error": { + "connection_error": "Kan geen verbinding maken met Abode.", + "identifier_exists": "Account is al geregistreerd.", + "invalid_credentials": "Ongeldige inloggegevens." + }, + "step": { + "user": { + "data": { + "password": "Wachtwoord", + "username": "E-mailadres" + }, + "title": "Vul uw Abode-inloggegevens in" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/abode/translations/nn.json b/homeassistant/components/abode/translations/nn.json new file mode 100644 index 0000000000000..f7a32b0983e80 --- /dev/null +++ b/homeassistant/components/abode/translations/nn.json @@ -0,0 +1,3 @@ +{ + "title": "Abode" +} \ No newline at end of file diff --git a/homeassistant/components/abode/translations/no.json b/homeassistant/components/abode/translations/no.json new file mode 100644 index 0000000000000..dc269b112d79a --- /dev/null +++ b/homeassistant/components/abode/translations/no.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Bare en enkelt konfigurasjon av Abode er tillatt." + }, + "error": { + "connection_error": "Kan ikke koble til Abode.", + "identifier_exists": "Kontoen er allerede registrert.", + "invalid_credentials": "Ugyldig brukerinformasjon" + }, + "step": { + "user": { + "data": { + "password": "Passord", + "username": "E-postadresse" + }, + "title": "Fyll ut innloggingsinformasjonen for Abode" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/abode/translations/pl.json b/homeassistant/components/abode/translations/pl.json new file mode 100644 index 0000000000000..d7a25bb20b762 --- /dev/null +++ b/homeassistant/components/abode/translations/pl.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Dozwolona jest tylko jedna konfiguracja Abode." + }, + "error": { + "connection_error": "Nie mo\u017cna po\u0142\u0105czy\u0107 si\u0119 z Abode.", + "identifier_exists": "Konto jest ju\u017c zarejestrowane.", + "invalid_credentials": "Nieprawid\u0142owe dane uwierzytelniaj\u0105ce" + }, + "step": { + "user": { + "data": { + "password": "Has\u0142o", + "username": "Adres e-mail" + }, + "title": "Wprowad\u017a informacje logowania Abode" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/abode/translations/pt-BR.json b/homeassistant/components/abode/translations/pt-BR.json new file mode 100644 index 0000000000000..1f9cf968fb0ac --- /dev/null +++ b/homeassistant/components/abode/translations/pt-BR.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Somente uma \u00fanica configura\u00e7\u00e3o de Abode \u00e9 permitida." + }, + "error": { + "connection_error": "N\u00e3o foi poss\u00edvel conectar ao Abode.", + "identifier_exists": "Conta j\u00e1 cadastrada.", + "invalid_credentials": "Credenciais inv\u00e1lidas." + }, + "step": { + "user": { + "data": { + "password": "Senha", + "username": "Endere\u00e7o de e-mail" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/abode/translations/pt.json b/homeassistant/components/abode/translations/pt.json new file mode 100644 index 0000000000000..505e1a850ec65 --- /dev/null +++ b/homeassistant/components/abode/translations/pt.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "identifier_exists": "Conta j\u00e1 registada" + }, + "step": { + "user": { + "data": { + "password": "Palavra-passe", + "username": "Endere\u00e7o de e-mail" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/abode/translations/ru.json b/homeassistant/components/abode/translations/ru.json new file mode 100644 index 0000000000000..e0e6e131289da --- /dev/null +++ b/homeassistant/components/abode/translations/ru.json @@ -0,0 +1,21 @@ +{ + "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": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a Abode.", + "identifier_exists": "\u0423\u0447\u0451\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u043d\u0430.", + "invalid_credentials": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0435 \u0443\u0447\u0451\u0442\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435." + }, + "step": { + "user": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u044c", + "username": "\u0410\u0434\u0440\u0435\u0441 \u044d\u043b\u0435\u043a\u0442\u0440\u043e\u043d\u043d\u043e\u0439 \u043f\u043e\u0447\u0442\u044b" + }, + "title": "Abode" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/abode/translations/sl.json b/homeassistant/components/abode/translations/sl.json new file mode 100644 index 0000000000000..d56b1335390f7 --- /dev/null +++ b/homeassistant/components/abode/translations/sl.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Dovoljena je samo ena konfiguracija Abode." + }, + "error": { + "connection_error": "Ni mogo\u010de vzpostaviti povezave z Abode.", + "identifier_exists": "Ra\u010dun je \u017ee registriran.", + "invalid_credentials": "Neveljavne poverilnice." + }, + "step": { + "user": { + "data": { + "password": "Geslo", + "username": "E-po\u0161tni naslov" + }, + "title": "Izpolnite svoje podatke za prijavo v Abode" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/abode/translations/sv.json b/homeassistant/components/abode/translations/sv.json new file mode 100644 index 0000000000000..40328574ba7eb --- /dev/null +++ b/homeassistant/components/abode/translations/sv.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Endast en enda konfiguration av Abode \u00e4r till\u00e5ten." + }, + "error": { + "connection_error": "Det gick inte att ansluta till Abode.", + "identifier_exists": "Kontot \u00e4r redan registrerat.", + "invalid_credentials": "Ogiltiga autentiseringsuppgifter." + }, + "step": { + "user": { + "data": { + "password": "L\u00f6senord", + "username": "E-postadress" + }, + "title": "Fyll i din inloggningsinformation f\u00f6r Abode" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/abode/translations/zh-Hant.json b/homeassistant/components/abode/translations/zh-Hant.json new file mode 100644 index 0000000000000..5120d529cb53e --- /dev/null +++ b/homeassistant/components/abode/translations/zh-Hant.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u50c5\u5141\u8a31\u8a2d\u5b9a\u4e00\u7d44 Abode\u3002" + }, + "error": { + "connection_error": "\u7121\u6cd5\u9023\u7dda\u81f3 Abode\u3002", + "identifier_exists": "\u5e33\u865f\u5df2\u8a3b\u518a\u3002", + "invalid_credentials": "\u6191\u8b49\u7121\u6548\u3002" + }, + "step": { + "user": { + "data": { + "password": "\u5bc6\u78bc", + "username": "\u96fb\u5b50\u90f5\u4ef6\u5730\u5740" + }, + "title": "\u586b\u5beb Abode \u767b\u5165\u8cc7\u8a0a" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/acer_projector/manifest.json b/homeassistant/components/acer_projector/manifest.json index 4b8d696749157..85ff4a3f5b193 100644 --- a/homeassistant/components/acer_projector/manifest.json +++ b/homeassistant/components/acer_projector/manifest.json @@ -1,10 +1,7 @@ { "domain": "acer_projector", - "name": "Acer projector", - "documentation": "https://www.home-assistant.io/components/acer_projector", - "requirements": [ - "pyserial==3.1.1" - ], - "dependencies": [], + "name": "Acer Projector", + "documentation": "https://www.home-assistant.io/integrations/acer_projector", + "requirements": ["pyserial==3.1.1"], "codeowners": [] } diff --git a/homeassistant/components/acer_projector/switch.py b/homeassistant/components/acer_projector/switch.py index 242f3f4a009d5..f947f3fe0c080 100644 --- a/homeassistant/components/acer_projector/switch.py +++ b/homeassistant/components/acer_projector/switch.py @@ -2,73 +2,82 @@ import logging import re +import serial import voluptuous as vol -from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA) +from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchEntity from homeassistant.const import ( - STATE_ON, STATE_OFF, STATE_UNKNOWN, CONF_NAME, CONF_FILENAME) + CONF_FILENAME, + CONF_NAME, + STATE_OFF, + STATE_ON, + STATE_UNKNOWN, +) import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -CONF_TIMEOUT = 'timeout' -CONF_WRITE_TIMEOUT = 'write_timeout' +CONF_TIMEOUT = "timeout" +CONF_WRITE_TIMEOUT = "write_timeout" -DEFAULT_NAME = 'Acer Projector' +DEFAULT_NAME = "Acer Projector" DEFAULT_TIMEOUT = 1 DEFAULT_WRITE_TIMEOUT = 1 -ECO_MODE = 'ECO Mode' +ECO_MODE = "ECO Mode" -ICON = 'mdi:projector' +ICON = "mdi:projector" -INPUT_SOURCE = 'Input Source' +INPUT_SOURCE = "Input Source" -LAMP = 'Lamp' -LAMP_HOURS = 'Lamp Hours' +LAMP = "Lamp" +LAMP_HOURS = "Lamp Hours" -MODEL = 'Model' +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', + 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, -}) +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) + serial_port = config[CONF_FILENAME] + name = config[CONF_NAME] + timeout = config[CONF_TIMEOUT] + write_timeout = config[CONF_WRITE_TIMEOUT] add_entities([AcerSwitch(serial_port, name, timeout, write_timeout)], True) -class AcerSwitch(SwitchDevice): +class AcerSwitch(SwitchEntity): """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) + port=serial_port, timeout=timeout, write_timeout=write_timeout, **kwargs + ) self._serial_port = serial_port self._name = name self._state = False @@ -81,7 +90,7 @@ def __init__(self, serial_port, name, timeout, write_timeout, **kwargs): 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. @@ -89,14 +98,14 @@ def _write_read(self, msg): try: if not self.ser.is_open: self.ser.open() - msg = msg.encode('utf-8') + 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') + ret = self.ser.read_until(size=20).decode("utf-8") except serial.SerialException: - _LOGGER.error('Problem communicating with %s', self._serial_port) + _LOGGER.error("Problem communicating with %s", self._serial_port) self.ser.close() return ret @@ -104,7 +113,7 @@ 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) + match = re.search(r"\r(.+)\r", awns) if match: return match.group(1) return STATE_UNKNOWN @@ -133,17 +142,17 @@ def update(self): """Get the latest state from the projector.""" msg = CMD_DICT[LAMP] awns = self._write_read_format(msg) - if awns == 'Lamp 1': + if awns == "Lamp 1": self._state = True self._available = True - elif awns == 'Lamp 0': + 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) + msg = CMD_DICT.get(key) if msg: awns = self._write_read_format(msg) self._attributes[key] = awns diff --git a/homeassistant/components/actiontec/device_tracker.py b/homeassistant/components/actiontec/device_tracker.py index 3f0c87867943a..e3fdeaf35f28f 100644 --- a/homeassistant/components/actiontec/device_tracker.py +++ b/homeassistant/components/actiontec/device_tracker.py @@ -1,29 +1,36 @@ """Support for Actiontec MI424WR (Verizon FIOS) routers.""" +from collections import namedtuple 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) + DOMAIN, + PLATFORM_SCHEMA, + DeviceScanner, +) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +import homeassistant.helpers.config_validation as cv +import homeassistant.util.dt as dt_util _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') + 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 -}) +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): @@ -32,7 +39,7 @@ def get_scanner(hass, config): return scanner if scanner.success_init else None -Device = namedtuple('Device', ['mac', 'ip', 'last_update']) +Device = namedtuple("Device", ["mac", "ip", "last_update"]) class ActiontecDeviceScanner(DeviceScanner): @@ -75,9 +82,11 @@ def _update_info(self): 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] + 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 @@ -85,17 +94,16 @@ 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(b"Username: ") + telnet.write((f"{self.username}\n").encode("ascii")) + telnet.read_until(b"Password: ") + telnet.write((f"{self.password}\n").encode("ascii")) + prompt = telnet.read_until(b"Wireless Broadband Router> ").split(b"\n")[-1] + telnet.write(b"firewall mac_cache_dump\n") + telnet.write(b"\n") telnet.read_until(prompt) - leases_result = telnet.read_until(prompt).split(b'\n')[1:-1] - telnet.write('exit\n'.encode('ascii')) + leases_result = telnet.read_until(prompt).split(b"\n")[1:-1] + telnet.write(b"exit\n") except EOFError: _LOGGER.exception("Unexpected response from router") return @@ -105,11 +113,11 @@ def get_actiontec_data(self): devices = {} for lease in leases_result: - match = _LEASES_REGEX.search(lease.decode('utf-8')) + 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')) - } + 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 index e233f430cfcbb..8a3f2f3f96a83 100644 --- a/homeassistant/components/actiontec/manifest.json +++ b/homeassistant/components/actiontec/manifest.json @@ -1,8 +1,6 @@ { "domain": "actiontec", "name": "Actiontec", - "documentation": "https://www.home-assistant.io/components/actiontec", - "requirements": [], - "dependencies": [], + "documentation": "https://www.home-assistant.io/integrations/actiontec", "codeowners": [] } diff --git a/homeassistant/components/adguard/__init__.py b/homeassistant/components/adguard/__init__.py new file mode 100644 index 0000000000000..6996a2b0d51af --- /dev/null +++ b/homeassistant/components/adguard/__init__.py @@ -0,0 +1,209 @@ +"""Support for AdGuard Home.""" +from distutils.version import LooseVersion +import logging +from typing import Any, Dict + +from adguardhome import AdGuardHome, AdGuardHomeConnectionError, AdGuardHomeError +import voluptuous as vol + +from homeassistant.components.adguard.const import ( + CONF_FORCE, + DATA_ADGUARD_CLIENT, + DATA_ADGUARD_VERION, + DOMAIN, + MIN_ADGUARD_HOME_VERSION, + 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.exceptions import ConfigEntryNotReady +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], + session=session, + ) + + hass.data.setdefault(DOMAIN, {})[DATA_ADGUARD_CLIENT] = adguard + + try: + version = await adguard.version() + except AdGuardHomeConnectionError as exception: + raise ConfigEntryNotReady from exception + + if LooseVersion(MIN_ADGUARD_HOME_VERSION) > LooseVersion(version): + _LOGGER.error( + "This integration requires AdGuard Home v0.99.0 or higher to work correctly" + ) + raise ConfigEntryNotReady + + 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, enabled_default: bool = True + ) -> None: + """Initialize the AdGuard Home entity.""" + self._available = True + self._enabled_default = enabled_default + self._icon = icon + self._name = name + 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 entity_registry_enabled_default(self) -> bool: + """Return if the entity should be enabled when first added to the entity registry.""" + return self._enabled_default + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._available + + async def async_update(self) -> None: + """Update AdGuard Home entity.""" + if not self.enabled: + return + + 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..e2a226eb4ce65 --- /dev/null +++ b/homeassistant/components/adguard/config_flow.py @@ -0,0 +1,187 @@ +"""Config flow to configure the AdGuard Home integration.""" +from distutils.version import LooseVersion +import logging + +from adguardhome import AdGuardHome, AdGuardHomeConnectionError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components.adguard.const import DOMAIN, MIN_ADGUARD_HOME_VERSION +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 + + 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], + session=session, + ) + + try: + version = await adguard.version() + except AdGuardHomeConnectionError: + errors["base"] = "connection_error" + return await self._show_setup_form(errors) + + if LooseVersion(MIN_ADGUARD_HOME_VERSION) > LooseVersion(version): + return self.async_abort( + reason="adguard_home_outdated", + description_placeholders={ + "current_version": version, + "minimal_version": MIN_ADGUARD_HOME_VERSION, + }, + ) + + 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. + """ + entries = self._async_current_entries() + + if not entries: + self._hassio_discovery = user_input + return await self.async_step_hassio_confirm() + + cur_entry = entries[0] + + if ( + cur_entry.data[CONF_HOST] == user_input[CONF_HOST] + and cur_entry.data[CONF_PORT] == user_input[CONF_PORT] + ): + return self.async_abort(reason="single_instance_allowed") + + is_loaded = cur_entry.state == config_entries.ENTRY_STATE_LOADED + + if is_loaded: + await self.hass.config_entries.async_unload(cur_entry.entry_id) + + self.hass.config_entries.async_update_entry( + cur_entry, + data={ + **cur_entry.data, + CONF_HOST: user_input[CONF_HOST], + CONF_PORT: user_input[CONF_PORT], + }, + ) + + if is_loaded: + await self.hass.config_entries.async_setup(cur_entry.entry_id) + + return self.async_abort(reason="existing_instance_updated") + + 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, + session=session, + ) + + try: + version = await adguard.version() + except AdGuardHomeConnectionError: + errors["base"] = "connection_error" + return await self._show_hassio_form(errors) + + if LooseVersion(MIN_ADGUARD_HOME_VERSION) > LooseVersion(version): + return self.async_abort( + reason="adguard_home_addon_outdated", + description_placeholders={ + "current_version": version, + "minimal_version": MIN_ADGUARD_HOME_VERSION, + }, + ) + + 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..eb12a9c163f62 --- /dev/null +++ b/homeassistant/components/adguard/const.py @@ -0,0 +1,16 @@ +"""Constants for the AdGuard Home integration.""" + +DOMAIN = "adguard" + +DATA_ADGUARD_CLIENT = "adguard_client" +DATA_ADGUARD_VERION = "adguard_version" + +CONF_FORCE = "force" + +MIN_ADGUARD_HOME_VERSION = "v0.99.0" + +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..0bcd25569a5a9 --- /dev/null +++ b/homeassistant/components/adguard/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "adguard", + "name": "AdGuard Home", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/adguard", + "requirements": ["adguardhome==0.4.2"], + "codeowners": ["@frenck"] +} diff --git a/homeassistant/components/adguard/sensor.py b/homeassistant/components/adguard/sensor.py new file mode 100644 index 0000000000000..5abff10739abd --- /dev/null +++ b/homeassistant/components/adguard/sensor.py @@ -0,0 +1,235 @@ +"""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.const import TIME_MILLISECONDS, UNIT_PERCENTAGE +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, + enabled_default: bool = True, + ) -> None: + """Initialize AdGuard Home sensor.""" + self._state = None + self._unit_of_measurement = unit_of_measurement + self.measurement = measurement + + super().__init__(adguard, name, icon, enabled_default) + + @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", + enabled_default=False, + ) + + 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", + UNIT_PERCENTAGE, + ) + + async def _adguard_update(self) -> None: + """Update AdGuard Home entity.""" + percentage = await self.adguard.stats.blocked_percentage() + self._state = f"{percentage:.2f}" + + +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, + "AdGuard Safe Searches 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", + TIME_MILLISECONDS, + ) + + async def _adguard_update(self) -> None: + """Update AdGuard Home entity.""" + average = await self.adguard.stats.avg_processing_time() + self._state = f"{average:.2f}" + + +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", + enabled_default=False, + ) + + 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..471708879b496 --- /dev/null +++ b/homeassistant/components/adguard/strings.json @@ -0,0 +1,28 @@ +{ + "config": { + "step": { + "user": { + "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": { + "adguard_home_outdated": "This integration requires AdGuard Home {minimal_version} or higher, you have {current_version}.", + "adguard_home_addon_outdated": "This integration requires AdGuard Home {minimal_version} or higher, you have {current_version}. Please update your Hass.io AdGuard Home add-on.", + "existing_instance_updated": "Updated existing configuration.", + "single_instance_allowed": "Only a single configuration of AdGuard Home is allowed." + } + } +} diff --git a/homeassistant/components/adguard/switch.py b/homeassistant/components/adguard/switch.py new file mode 100644 index 0000000000000..78d2769ce5de9 --- /dev/null +++ b/homeassistant/components/adguard/switch.py @@ -0,0 +1,227 @@ +"""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.components.switch import SwitchEntity +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=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(AdGuardHomeDeviceEntity, SwitchEntity): + """Defines a AdGuard Home switch.""" + + def __init__( + self, adguard, name: str, icon: str, key: str, enabled_default: bool = True + ): + """Initialize AdGuard Home switch.""" + self._state = False + self._key = key + super().__init__(adguard, name, icon, enabled_default) + + @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", + enabled_default=False, + ) + + 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/adguard/translations/bg.json b/homeassistant/components/adguard/translations/bg.json new file mode 100644 index 0000000000000..90c3ddcb359a7 --- /dev/null +++ b/homeassistant/components/adguard/translations/bg.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "adguard_home_addon_outdated": "\u0422\u0430\u0437\u0438 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044f \u0438\u0437\u0438\u0441\u043a\u0432\u0430 AdGuard Home {minimal_version} \u0438\u043b\u0438 \u043f\u043e-\u043d\u043e\u0432\u0430 {minimal_version}, \u0438\u043c\u0430\u0442\u0435 {current_version}. \u041c\u043e\u043b\u044f, \u0430\u043a\u0442\u0443\u0430\u043b\u0438\u0437\u0438\u0440\u0430\u0439\u0442\u0435 \u0432\u0430\u0448\u0430\u0442\u0430 \u0434\u043e\u0431\u0430\u0432\u043a\u0430 \u0437\u0430 Hass.io AdGuard Home.", + "adguard_home_outdated": "\u0422\u0430\u0437\u0438 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044f \u0438\u0437\u0438\u0441\u043a\u0432\u0430 AdGuard Home {minimal_version} \u0438\u043b\u0438 \u043f\u043e-\u043d\u043e\u0432\u0430 {minimal_version}, \u0438\u043c\u0430\u0442\u0435 {current_version}.", + "existing_instance_updated": "\u0410\u043a\u0442\u0443\u0430\u043b\u0438\u0437\u0438\u0440\u0430\u043d\u0435 \u043d\u0430 \u0441\u044a\u0449\u0435\u0441\u0442\u0432\u0443\u0432\u0430\u0449\u0430\u0442\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f.", + "single_instance_allowed": "\u0420\u0430\u0437\u0440\u0435\u0448\u0435\u043d\u0430 \u0435 \u0441\u0430\u043c\u043e \u0435\u0434\u043d\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f \u043d\u0430 AdGuard Home." + }, + "error": { + "connection_error": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435." + }, + "step": { + "hassio_confirm": { + "description": "\u0418\u0441\u043a\u0430\u0442\u0435 \u043b\u0438 \u0434\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u0442\u0435 Home Assistant \u0434\u0430 \u0441\u0435 \u0441\u0432\u044a\u0440\u0437\u0432\u0430 \u0441 AdGuard Home, \u043f\u0440\u0435\u0434\u043e\u0441\u0442\u0430\u0432\u0435\u043d \u043e\u0442 Hass.io \u0434\u043e\u0431\u0430\u0432\u043a\u0430\u0442\u0430: {addon} ?", + "title": "AdGuard Home \u0447\u0440\u0435\u0437 Hass.io \u0434\u043e\u0431\u0430\u0432\u043a\u0430" + }, + "user": { + "data": { + "host": "\u0410\u0434\u0440\u0435\u0441", + "password": "\u041f\u0430\u0440\u043e\u043b\u0430", + "port": "\u041f\u043e\u0440\u0442", + "ssl": "AdGuard Home \u0438\u0437\u043f\u043e\u043b\u0437\u0432\u0430 SSL \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442", + "username": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435", + "verify_ssl": "AdGuard Home \u0438\u0437\u043f\u043e\u043b\u0437\u0432\u0430 \u043d\u0430\u0434\u0435\u0436\u0434\u0435\u043d \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442" + }, + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 \u0412\u0430\u0448\u0438\u044f AdGuard Home, \u0437\u0430 \u0434\u0430 \u043f\u043e\u0437\u0432\u043e\u043b\u0438\u0442\u0435 \u043d\u0430\u0431\u043b\u044e\u0434\u0435\u043d\u0438\u0435 \u0438 \u043a\u043e\u043d\u0442\u0440\u043e\u043b.", + "title": "\u0421\u0432\u044a\u0440\u0436\u0435\u0442\u0435 \u0412\u0430\u0448\u0438\u044f AdGuard Home." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adguard/translations/ca.json b/homeassistant/components/adguard/translations/ca.json new file mode 100644 index 0000000000000..adabb83ab0b39 --- /dev/null +++ b/homeassistant/components/adguard/translations/ca.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "adguard_home_addon_outdated": "Aquesta integraci\u00f3 necessita la versi\u00f3 d'AdGuard Home {minimal_version} o una superior, tens la {current_version}. Actualitza el complement de Hass.io d'AdGuard Home.", + "adguard_home_outdated": "Aquesta integraci\u00f3 necessita la versi\u00f3 d'AdGuard Home {minimal_version} o una superior, tens la {current_version}.", + "existing_instance_updated": "S'ha actualitzat la configuraci\u00f3 existent.", + "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." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adguard/translations/cs.json b/homeassistant/components/adguard/translations/cs.json new file mode 100644 index 0000000000000..fc450c2e908ff --- /dev/null +++ b/homeassistant/components/adguard/translations/cs.json @@ -0,0 +1,10 @@ +{ + "config": { + "step": { + "hassio_confirm": { + "description": "Chcete nakonfigurovat slu\u017ebu Home Assistant pro p\u0159ipojen\u00ed k AddGuard pomoc\u00ed hass.io {addon}?", + "title": "AdGuard prost\u0159ednictv\u00edm dopl\u0148ku Hass.io" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adguard/translations/da.json b/homeassistant/components/adguard/translations/da.json new file mode 100644 index 0000000000000..3a1a73ac6bdbf --- /dev/null +++ b/homeassistant/components/adguard/translations/da.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "adguard_home_addon_outdated": "Denne integration kr\u00e6ver AdGuard Home {minimal_version} eller h\u00f8jere, du har {current_version}. Opdater venligst din Hass.io AdGuard Home-tilf\u00f8jelse.", + "adguard_home_outdated": "Denne integration kr\u00e6ver AdGuard Home {minimal_version} eller h\u00f8jere, du har {current_version}.", + "existing_instance_updated": "Opdaterede eksisterende konfiguration.", + "single_instance_allowed": "Kun en enkelt konfiguration af AdGuard Home er tilladt." + }, + "error": { + "connection_error": "Forbindelse mislykkedes." + }, + "step": { + "hassio_confirm": { + "description": "Vil du konfigurere Home Assistant til at oprette forbindelse til AdGuard Home leveret af Hass.io-tilf\u00f8jelsen: {addon}?", + "title": "AdGuard Home via Hass.io-tilf\u00f8jelse" + }, + "user": { + "data": { + "host": "V\u00e6rt", + "password": "Adgangskode", + "port": "Port", + "ssl": "AdGuard Home bruger et SSL-certifikat", + "username": "Brugernavn", + "verify_ssl": "AdGuard Home bruger et korrekt certifikat" + }, + "description": "Konfigurer din AdGuard Home-instans for at tillade overv\u00e5gning og kontrol.", + "title": "Forbind din AdGuard Home." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adguard/translations/de.json b/homeassistant/components/adguard/translations/de.json new file mode 100644 index 0000000000000..19c1a5ce6fc33 --- /dev/null +++ b/homeassistant/components/adguard/translations/de.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "adguard_home_addon_outdated": "Diese Integration erfordert AdGuard Home {minimal_version} oder h\u00f6her, du hast {current_version}. Bitte aktualisiere dein Hass.io AdGuard Home Add-on.", + "adguard_home_outdated": "Diese Integration erfordert AdGuard Home {minimal_version} oder h\u00f6her, du hast {current_version}.", + "existing_instance_updated": "Bestehende Konfiguration wurde aktualisiert.", + "single_instance_allowed": "Es ist nur eine einzige Konfiguration von AdGuard Home zul\u00e4ssig." + }, + "error": { + "connection_error": "Fehler beim Herstellen einer Verbindung." + }, + "step": { + "hassio_confirm": { + "description": "M\u00f6chtest du Home Assistant so konfigurieren, dass eine Verbindung mit AdGuard Home als Hass.io-Add-On hergestellt wird: {addon}?", + "title": "AdGuard Home \u00fcber das Hass.io Add-on" + }, + "user": { + "data": { + "host": "Host", + "password": "Passwort", + "port": "Port", + "ssl": "AdGuard Home verwendet ein SSL-Zertifikat", + "username": "Benutzername", + "verify_ssl": "AdGuard Home verwendet ein richtiges Zertifikat" + }, + "description": "Richte deine AdGuard Home-Instanz ein um sie zu \u00dcberwachen und zu Steuern.", + "title": "Verkn\u00fcpfe 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..d9b5d81b46958 --- /dev/null +++ b/homeassistant/components/adguard/translations/en.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "adguard_home_addon_outdated": "This integration requires AdGuard Home {minimal_version} or higher, you have {current_version}. Please update your Hass.io AdGuard Home add-on.", + "adguard_home_outdated": "This integration requires AdGuard Home {minimal_version} or higher, you have {current_version}.", + "existing_instance_updated": "Updated existing configuration.", + "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." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adguard/translations/es-419.json b/homeassistant/components/adguard/translations/es-419.json new file mode 100644 index 0000000000000..5a36b35d028a7 --- /dev/null +++ b/homeassistant/components/adguard/translations/es-419.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "adguard_home_addon_outdated": "Esta integraci\u00f3n requiere AdGuard Home {minimal_version} o superior, tiene {current_version}. Actualice su complemento Hass.io AdGuard Home.", + "adguard_home_outdated": "Esta integraci\u00f3n requiere AdGuard Home {minimal_version} o superior, tiene {current_version}.", + "existing_instance_updated": "Se actualiz\u00f3 la configuraci\u00f3n existente.", + "single_instance_allowed": "Solo se permite una \u00fanica configuraci\u00f3n de AdGuard Home." + }, + "error": { + "connection_error": "Error al conectar." + }, + "step": { + "hassio_confirm": { + "description": "\u00bfDesea configurar Home Assistant para conectarse a la p\u00e1gina principal de AdGuard proporcionada por el complemento Hass.io: {addon}?", + "title": "AdGuard Home a trav\u00e9s del complemento Hass.io" + }, + "user": { + "data": { + "password": "Contrase\u00f1a", + "port": "Puerto", + "ssl": "AdGuard Home utiliza un certificado SSL", + "username": "Nombre de usuario", + "verify_ssl": "AdGuard Home utiliza un certificado adecuado" + }, + "description": "Configure su instancia de AdGuard Home para permitir la supervisi\u00f3n y el control.", + "title": "Enlace su AdGuard Home." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adguard/translations/es.json b/homeassistant/components/adguard/translations/es.json new file mode 100644 index 0000000000000..8e4bc821fdfa6 --- /dev/null +++ b/homeassistant/components/adguard/translations/es.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "adguard_home_addon_outdated": "Esta integraci\u00f3n requiere AdGuard Home {minimal_version} o superior, usted tiene {current_version}. Por favor, actualice su complemento Hass.io AdGuard Home.", + "adguard_home_outdated": "Esta integraci\u00f3n requiere AdGuard Home {minimal_version} o superior, usted tiene {current_version}.", + "existing_instance_updated": "Se ha actualizado la configuraci\u00f3n existente.", + "single_instance_allowed": "S\u00f3lo se permite una \u00fanica configuraci\u00f3n de AdGuard Home." + }, + "error": { + "connection_error": "No se conect\u00f3." + }, + "step": { + "hassio_confirm": { + "description": "\u00bfDesea configurar Home Assistant para conectarse al AdGuard Home proporcionado por el complemento Hass.io: {addon} ?", + "title": "AdGuard Home a trav\u00e9s del complemento Hass.io" + }, + "user": { + "data": { + "host": "Host", + "password": "Contrase\u00f1a", + "port": "Puerto", + "ssl": "AdGuard Home utiliza un certificado SSL", + "username": "Usuario", + "verify_ssl": "AdGuard Home utiliza un certificado apropiado" + }, + "description": "Configure su instancia de AdGuard Home para permitir la supervisi\u00f3n y el control.", + "title": "Enlace su AdGuard Home." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adguard/translations/fr.json b/homeassistant/components/adguard/translations/fr.json new file mode 100644 index 0000000000000..777e1e992af9d --- /dev/null +++ b/homeassistant/components/adguard/translations/fr.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "adguard_home_addon_outdated": "Cette int\u00e9gration n\u00e9cessite AdGuard Home {minimal_version} ou une version ult\u00e9rieure, vous disposez de {current_version}. Veuillez mettre \u00e0 jour votre compl\u00e9ment Hass.io AdGuard Home.", + "adguard_home_outdated": "Cette int\u00e9gration n\u00e9cessite AdGuard Home {minimal_version} ou une version ult\u00e9rieure, vous disposez de {current_version}.", + "existing_instance_updated": "La configuration existante a \u00e9t\u00e9 mise \u00e0 jour.", + "single_instance_allowed": "Une seule configuration d'AdGuard Home est autoris\u00e9e." + }, + "error": { + "connection_error": "\u00c9chec de connexion." + }, + "step": { + "hassio_confirm": { + "description": "Voulez-vous configurer Home Assistant pour qu'il se connecte \u00e0 AdGuard Home fourni par le module compl\u00e9mentaire Hass.io: {addon} ?", + "title": "AdGuard Home via le module compl\u00e9mentaire Hass.io" + }, + "user": { + "data": { + "host": "H\u00f4te", + "password": "Mot de passe", + "port": "Port", + "ssl": "AdGuard Home utilise un certificat SSL", + "username": "Nom d'utilisateur", + "verify_ssl": "AdGuard Home utilise un certificat appropri\u00e9" + }, + "description": "Configurez votre instance AdGuard Home pour permettre la surveillance et le contr\u00f4le.", + "title": "Liez votre AdGuard Home." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adguard/translations/hr.json b/homeassistant/components/adguard/translations/hr.json new file mode 100644 index 0000000000000..869cc46ea106d --- /dev/null +++ b/homeassistant/components/adguard/translations/hr.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "existing_instance_updated": "Postoje\u0107a konfiguracija je a\u017eurirana." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adguard/translations/hu.json b/homeassistant/components/adguard/translations/hu.json new file mode 100644 index 0000000000000..34b601027c221 --- /dev/null +++ b/homeassistant/components/adguard/translations/hu.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "Jelsz\u00f3", + "port": "Port", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adguard/translations/id.json b/homeassistant/components/adguard/translations/id.json new file mode 100644 index 0000000000000..3548361e396bb --- /dev/null +++ b/homeassistant/components/adguard/translations/id.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "connection_error": "Gagal terhubung." + }, + "step": { + "user": { + "data": { + "password": "Kata sandi", + "port": "Port" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adguard/translations/it.json b/homeassistant/components/adguard/translations/it.json new file mode 100644 index 0000000000000..c3c9aef22c9e7 --- /dev/null +++ b/homeassistant/components/adguard/translations/it.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "adguard_home_addon_outdated": "Questa integrazione richiede AdGuard Home {minimal_version} o versione successiva, si dispone di {current_version}. Aggiorna il componente aggiuntivo AdGuard Home di Hass.io.", + "adguard_home_outdated": "Questa integrazione richiede AdGuard Home {minimal_version} o versione successiva, si dispone di {current_version}.", + "existing_instance_updated": "Configurazione esistente aggiornata.", + "single_instance_allowed": "\u00c8 consentita solo una singola configurazione di AdGuard Home." + }, + "error": { + "connection_error": "Impossibile connettersi." + }, + "step": { + "hassio_confirm": { + "description": "Vuoi configurare Home Assistant per connettersi alla AdGuard Home fornita dal componente aggiuntivo di Hass.io: {addon}?", + "title": "AdGuard Home tramite il componente aggiuntivo di Hass.io" + }, + "user": { + "data": { + "host": "Host", + "password": "Password", + "port": "Porta", + "ssl": "AdGuard Home utilizza un certificato SSL", + "username": "Nome utente", + "verify_ssl": "AdGuard Home utilizza un certificato appropriato" + }, + "description": "Configura l'istanza di AdGuard Home per consentire il monitoraggio e il controllo.", + "title": "Collega la tua AdGuard Home." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adguard/translations/ko.json b/homeassistant/components/adguard/translations/ko.json new file mode 100644 index 0000000000000..b5b77e434ca2c --- /dev/null +++ b/homeassistant/components/adguard/translations/ko.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "adguard_home_addon_outdated": "\uc774 \ud1b5\ud569 \uad6c\uc131\uc694\uc18c\ub294 AdGuard Home {minimal_version} \uc774\uc0c1\uc774 \ud544\uc694\ud569\ub2c8\ub2e4. \ud604\uc7ac \ubc84\uc804\uc740 {current_version} \uc785\ub2c8\ub2e4. Hass.io AdGuard Home \uc560\ub4dc\uc628\uc744 \uc5c5\ub370\uc774\ud2b8 \ud574\uc8fc\uc138\uc694.", + "adguard_home_outdated": "\uc774 \ud1b5\ud569 \uad6c\uc131\uc694\uc18c\ub294 AdGuard Home {minimal_version} \uc774\uc0c1\uc774 \ud544\uc694\ud569\ub2c8\ub2e4. \ud604\uc7ac \ubc84\uc804\uc740 {current_version} \uc785\ub2c8\ub2e4.", + "existing_instance_updated": "\uae30\uc874 \uad6c\uc131\uc744 \uc5c5\ub370\uc774\ud2b8\ud588\uc2b5\ub2c8\ub2e4.", + "single_instance_allowed": "\ud558\ub098\uc758 AdGuard Home \ub9cc \uad6c\uc131\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4." + }, + "error": { + "connection_error": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4." + }, + "step": { + "hassio_confirm": { + "description": "Hass.io {addon} \uc560\ub4dc\uc628\uc73c\ub85c AdGuard Home \uc5d0 \uc5f0\uacb0\ud558\ub3c4\ub85d Home Assistant \ub97c \uad6c\uc131\ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?", + "title": "Hass.io \uc560\ub4dc\uc628\uc758 AdGuard Home" + }, + "user": { + "data": { + "host": "\ud638\uc2a4\ud2b8", + "password": "\ube44\ubc00\ubc88\ud638", + "port": "\ud3ec\ud2b8", + "ssl": "AdGuard Home \uc740 SSL \uc778\uc99d\uc11c\ub97c \uc0ac\uc6a9\ud558\uace0 \uc788\uc2b5\ub2c8\ub2e4", + "username": "\uc0ac\uc6a9\uc790 \uc774\ub984", + "verify_ssl": "AdGuard Home \uc740 \uc62c\ubc14\ub978 \uc778\uc99d\uc11c\ub97c \uc0ac\uc6a9\ud558\uace0 \uc788\uc2b5\ub2c8\ub2e4" + }, + "description": "\ubaa8\ub2c8\ud130\ub9c1 \ubc0f \uc81c\uc5b4\uac00 \uac00\ub2a5\ud558\ub3c4\ub85d AdGuard Home \uc778\uc2a4\ud134\uc2a4\ub97c \uc124\uc815\ud574\uc8fc\uc138\uc694.", + "title": "AdGuard Home \uc5f0\uacb0\ud558\uae30" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adguard/translations/lb.json b/homeassistant/components/adguard/translations/lb.json new file mode 100644 index 0000000000000..4c839fc9e18eb --- /dev/null +++ b/homeassistant/components/adguard/translations/lb.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "adguard_home_addon_outdated": "D\u00ebs Integratioun ben\u00e9idegt AdgGuard Home {minimal_version} oder m\u00e9i, dir hutt {current_version}. Aktualis\u00e9iert w.e.g. \u00e4ren Hass.io AdGuard Home Add-on.", + "adguard_home_outdated": "D\u00ebs Integratioun ben\u00e9idegt AdgGuard Home {minimal_version} oder m\u00e9i, dir hutt {current_version}.", + "existing_instance_updated": "D\u00e9i bestehend Konfiguratioun ass ge\u00e4nnert.", + "single_instance_allowed": "N\u00ebmmen eng eenzeg Konfiguratioun vun AdGuard Home ass erlaabt." + }, + "error": { + "connection_error": "Feeler beim verbannen." + }, + "step": { + "hassio_confirm": { + "description": "W\u00ebllt dir Home Assistant konfigur\u00e9iere fir sech mam AdGuard Home ze verbannen dee vum hass.io add-on {addon} bereet gestallt g\u00ebtt?", + "title": "AdGuard Home via Hass.io add-on" + }, + "user": { + "data": { + "host": "Apparat", + "password": "Passwuert", + "port": "Port", + "ssl": "AdGuard Home benotzt een SSL Zertifikat", + "username": "Benotzernumm", + "verify_ssl": "AdGuard Home benotzt een eegenen Zertifikat" + }, + "description": "Konfigur\u00e9iert \u00e4r AdGuard Home Instanz fir d'Iwwerwaachung an d'Kontroll z'erlaben.", + "title": "Verbannt \u00e4ren AdGuard Home" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adguard/translations/nl.json b/homeassistant/components/adguard/translations/nl.json new file mode 100644 index 0000000000000..4e3439dd624c1 --- /dev/null +++ b/homeassistant/components/adguard/translations/nl.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "adguard_home_addon_outdated": "Deze integratie vereist AdGuard Home {minimal_version} of hoger, u heeft {current_version}. Update uw Hass.io AdGuard Home-add-on.", + "adguard_home_outdated": "Deze integratie vereist AdGuard Home {minimal_version} of hoger, u heeft {current_version}.", + "existing_instance_updated": "Bestaande configuratie bijgewerkt.", + "single_instance_allowed": "Slechts \u00e9\u00e9n configuratie van AdGuard Home is toegestaan." + }, + "error": { + "connection_error": "Kon niet verbinden." + }, + "step": { + "hassio_confirm": { + "description": "Wilt u Home Assistant configureren om verbinding te maken met AdGuard Home van de Hass.io-add-on: {addon}?", + "title": "AdGuard Home via Hass.io add-on" + }, + "user": { + "data": { + "host": "Host", + "password": "Wachtwoord", + "port": "Poort", + "ssl": "AdGuard Home maakt gebruik van een SSL certificaat", + "username": "Gebruikersnaam", + "verify_ssl": "AdGuard Home maakt gebruik van een goed certificaat" + }, + "description": "Stel uw AdGuard Home-instantie in om toezicht en controle mogelijk te maken.", + "title": "Link uw AdGuard Home." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adguard/translations/nn.json b/homeassistant/components/adguard/translations/nn.json new file mode 100644 index 0000000000000..7c129cba3afc7 --- /dev/null +++ b/homeassistant/components/adguard/translations/nn.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "Brukarnamn" + } + } + } + } +} \ 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..5194e799b1650 --- /dev/null +++ b/homeassistant/components/adguard/translations/no.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "adguard_home_addon_outdated": "Denne integrasjonen krever AdGuard Home {minimal_version} eller h\u00f8yere, du har {current_version}. Vennligst oppdater Hass.io AdGuard Home-tillegget.", + "adguard_home_outdated": "Denne integrasjonen krever AdGuard Home {minimal_version} eller h\u00f8yere, du har {current_version}.", + "existing_instance_updated": "Oppdatert eksisterende konfigurasjon.", + "single_instance_allowed": "Kun en 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": "", + "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." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adguard/translations/pl.json b/homeassistant/components/adguard/translations/pl.json new file mode 100644 index 0000000000000..71264e906e454 --- /dev/null +++ b/homeassistant/components/adguard/translations/pl.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "adguard_home_addon_outdated": "Ta integracja wymaga AdGuard Home {minimal_version} lub nowszej wersji, masz {current_version}. Zaktualizuj sw\u00f3j dodatek Hass.io AdGuard Home.", + "adguard_home_outdated": "Ta integracja wymaga AdGuard Home {minimal_version} lub nowszej wersji, masz {current_version}.", + "existing_instance_updated": "Zaktualizowano istniej\u0105c\u0105 konfiguracj\u0119.", + "single_instance_allowed": "Dozwolona jest tylko jedna konfiguracja AdGuard Home." + }, + "error": { + "connection_error": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia." + }, + "step": { + "hassio_confirm": { + "description": "Czy chcesz skonfigurowa\u0107 Home Assistant, aby po\u0142\u0105czy\u0142 si\u0119 z AdGuard Home przez dodatek Hass.io {addon}?", + "title": "AdGuard Home przez dodatek Hass.io" + }, + "user": { + "data": { + "host": "Nazwa hosta lub adres IP", + "password": "Has\u0142o", + "port": "Port", + "ssl": "AdGuard Home u\u017cywa certyfikatu SSL", + "username": "Nazwa u\u017cytkownika", + "verify_ssl": "AdGuard Home u\u017cywa odpowiedniego certyfikatu." + }, + "description": "Skonfiguruj instancj\u0119 AdGuard Home, aby umo\u017cliwi\u0107 monitorowanie i kontrol\u0119.", + "title": "Po\u0142\u0105cz AdGuard Home" + } + } + } +} \ 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..605085af1f13c --- /dev/null +++ b/homeassistant/components/adguard/translations/pt-BR.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "existing_instance_updated": "Configura\u00e7\u00e3o existente atualizada.", + "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." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/axis/.translations/pt.json b/homeassistant/components/adguard/translations/pt.json similarity index 100% rename from homeassistant/components/axis/.translations/pt.json rename to homeassistant/components/adguard/translations/pt.json diff --git a/homeassistant/components/adguard/translations/ru.json b/homeassistant/components/adguard/translations/ru.json new file mode 100644 index 0000000000000..1287b40854487 --- /dev/null +++ b/homeassistant/components/adguard/translations/ru.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "adguard_home_addon_outdated": "\u042d\u0442\u0430 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044f \u043d\u0435 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442 AdGuard Home \u0432\u0435\u0440\u0441\u0438\u0438 {current_version}. \u0414\u043b\u044f \u043a\u043e\u0440\u0440\u0435\u043a\u0442\u043d\u043e\u0439 \u0440\u0430\u0431\u043e\u0442\u044b \u0442\u0440\u0435\u0431\u0443\u0435\u0442\u0441\u044f \u0432\u0435\u0440\u0441\u0438\u044f {minimal_version}, \u0438\u043b\u0438 \u0431\u043e\u043b\u0435\u0435 \u043d\u043e\u0432\u0430\u044f. \u0414\u043b\u044f \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u044f \u043d\u043e\u0432\u043e\u0439 \u0432\u0435\u0440\u0441\u0438\u0438, \u043e\u0431\u043d\u043e\u0432\u0438\u0442\u0435 \u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u0435 Hass.io.", + "adguard_home_outdated": "\u042d\u0442\u0430 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044f \u043d\u0435 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442 AdGuard Home \u0432\u0435\u0440\u0441\u0438\u0438 {current_version}. \u0418\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0439\u0442\u0435 \u0432\u0435\u0440\u0441\u0438\u044e {minimal_version} \u0438\u043b\u0438 \u0431\u043e\u043b\u0435\u0435 \u043d\u043e\u0432\u0443\u044e.", + "existing_instance_updated": "\u041a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0430.", + "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 Home Assistant \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" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adguard/translations/sl.json b/homeassistant/components/adguard/translations/sl.json new file mode 100644 index 0000000000000..7cad7c1ac3a04 --- /dev/null +++ b/homeassistant/components/adguard/translations/sl.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "adguard_home_addon_outdated": "Za to integracijo je potrebna AdGuard Home {minimal_version} ali vi\u0161ja, vi imate {current_version}. Prosimo posodobite va\u0161 hass.io AdGuard Home dodatek.", + "adguard_home_outdated": "Za to integracijo je potrebna AdGuard Home {minimal_version} ali vi\u0161ja, vi imate {current_version}.", + "existing_instance_updated": "Posodobljena obstoje\u010da konfiguracija.", + "single_instance_allowed": "Dovoljena je samo ena konfiguracija AdGuard Home." + }, + "error": { + "connection_error": "Povezava ni uspela." + }, + "step": { + "hassio_confirm": { + "description": "\u017delite konfigurirati Home Assistant-a za povezavo z AdGuard Home, ki ga ponuja Hass.io add-on {addon} ?", + "title": "AdGuard Home preko dodatka Hass.io" + }, + "user": { + "data": { + "host": "Gostitelj", + "password": "Geslo", + "port": "Vrata", + "ssl": "AdGuard Home uporablja SSL certifikat", + "username": "Uporabni\u0161ko ime", + "verify_ssl": "AdGuard Home uporablja ustrezen certifikat" + }, + "description": "Nastavite primerek AdGuard Home, da omogo\u010dite spremljanje in nadzor.", + "title": "Pove\u017eite svoj 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..8ae4a5481d294 --- /dev/null +++ b/homeassistant/components/adguard/translations/sv.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "adguard_home_addon_outdated": "Den h\u00e4r integrationen kr\u00e4ver AdGuard Home {minimal_version} eller senare, du har {current_version}. Uppdatera ditt Hass.io AdGuard Home-till\u00e4gg.", + "adguard_home_outdated": "Den h\u00e4r integrationen kr\u00e4ver AdGuard Home {minimal_version} eller senare, du har {current_version}.", + "existing_instance_updated": "Uppdaterade existerande konfiguration.", + "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." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adguard/translations/vi.json b/homeassistant/components/adguard/translations/vi.json new file mode 100644 index 0000000000000..1b76fef567192 --- /dev/null +++ b/homeassistant/components/adguard/translations/vi.json @@ -0,0 +1,14 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "\u0110\u1ecba ch\u1ec9", + "password": "M\u1eadt kh\u1ea9u", + "port": "C\u1ed5ng", + "username": "T\u00ean \u0111\u0103ng nh\u1eadp" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adguard/translations/zh-Hans.json b/homeassistant/components/adguard/translations/zh-Hans.json new file mode 100644 index 0000000000000..7c52a9d1ac00a --- /dev/null +++ b/homeassistant/components/adguard/translations/zh-Hans.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "existing_instance_updated": "\u66f4\u65b0\u4e86\u73b0\u6709\u914d\u7f6e\u3002" + }, + "step": { + "user": { + "data": { + "password": "\u5bc6\u7801", + "port": "\u7aef\u53e3", + "username": "\u7528\u6237\u540d" + } + } + } + } +} \ 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..f3473fd31976a --- /dev/null +++ b/homeassistant/components/adguard/translations/zh-Hant.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "adguard_home_addon_outdated": "\u6574\u5408\u9700\u8981 AdGuard Home {minimal_version} \u6216\u66f4\u65b0\u7248\u672c\uff0c\u60a8\u76ee\u524d\u4f7f\u7528\u7248\u672c\u70ba {current_version}\u3002\u8acb\u66f4\u65b0 Hass.io AdGuard Home \u5143\u4ef6\u3002", + "adguard_home_outdated": "\u6574\u5408\u9700\u8981 AdGuard Home {minimal_version} \u6216\u66f4\u65b0\u7248\u672c\uff0c\u60a8\u76ee\u524d\u4f7f\u7528\u7248\u672c\u70ba {current_version}\u3002", + "existing_instance_updated": "\u5df2\u66f4\u65b0\u73fe\u6709\u8a2d\u5b9a\u3002", + "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" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ads/__init__.py b/homeassistant/components/ads/__init__.py index 920a2a034d799..15d58eb462046 100644 --- a/homeassistant/components/ads/__init__.py +++ b/homeassistant/components/ads/__init__.py @@ -1,71 +1,90 @@ """Support for Automation Device Specification (ADS).""" -import threading -import struct -import logging -import ctypes -from collections import namedtuple import asyncio -import async_timeout +from collections import namedtuple +import ctypes +import logging +import struct +import threading +import async_timeout +import pyads import voluptuous as vol from homeassistant.const import ( - CONF_DEVICE, CONF_IP_ADDRESS, CONF_PORT, EVENT_HOMEASSISTANT_STOP) + 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' +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, -}) +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) + net_id = conf[CONF_DEVICE] ip_address = conf.get(CONF_IP_ADDRESS) - port = conf.get(CONF_PORT) + port = conf[CONF_PORT] client = pyads.Connection(net_id, port, ip_address) @@ -91,7 +110,10 @@ def setup(hass, config): except pyads.ADSError: _LOGGER.error( "Could not connect to ADS host (netid=%s, ip=%s, port=%s)", - net_id, ip_address, port) + net_id, + ip_address, + port, + ) return False hass.data[DATA_ADS] = ads @@ -109,15 +131,18 @@ def handle_write_data_by_name(call): _LOGGER.error(err) hass.services.register( - DOMAIN, SERVICE_WRITE_DATA_BY_NAME, handle_write_data_by_name, - schema=SCHEMA_SERVICE_WRITE_DATA_BY_NAME) + 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' + "NotificationItem", "hnotify huser name plc_datatype callback" ) @@ -136,16 +161,17 @@ def __init__(self, ads_client): 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) + notification_item.hnotify, + notification_item.huser, + ) try: self._client.del_device_notification( - notification_item.hnotify, - notification_item.huser + notification_item.hnotify, notification_item.huser ) except pyads.ADSError as err: _LOGGER.error(err) @@ -160,7 +186,7 @@ def register_device(self, 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) @@ -169,7 +195,7 @@ def write_by_name(self, name, value, plc_datatype): 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) @@ -178,23 +204,25 @@ def read_by_name(self, name, plc_datatype): 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) + 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) + hnotify, huser, name, plc_datatype, callback + ) _LOGGER.debug( - "Added device notification %d for variable %s", - hnotify, name) + "Added device notification %d for variable %s", hnotify, name + ) def _device_notification_callback(self, notification, name): """Handle device notifications.""" @@ -213,17 +241,17 @@ def _device_notification_callback(self, notification, name): # Parse data to desired datatype if notification_item.plc_datatype == self.PLCTYPE_BOOL: - value = bool(struct.unpack(' bool: + """Set up configured Airly.""" + return True + + +async def async_setup_entry(hass, config_entry): + """Set up Airly as config entry.""" + api_key = config_entry.data[CONF_API_KEY] + latitude = config_entry.data[CONF_LATITUDE] + longitude = config_entry.data[CONF_LONGITUDE] + + # For backwards compat, set unique ID + if config_entry.unique_id is None: + hass.config_entries.async_update_entry( + config_entry, unique_id=f"{latitude}-{longitude}" + ) + + websession = async_get_clientsession(hass) + # Change update_interval for other Airly instances + update_interval = set_update_interval( + hass, len(hass.config_entries.async_entries(DOMAIN)) + ) + + coordinator = AirlyDataUpdateCoordinator( + hass, websession, api_key, latitude, longitude, update_interval + ) + await coordinator.async_refresh() + + if not coordinator.last_update_success: + raise ConfigEntryNotReady + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][config_entry.entry_id] = coordinator + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(config_entry, component) + ) + + return True + + +async def async_unload_entry(hass, config_entry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(config_entry, component) + for component in PLATFORMS + ] + ) + ) + if unload_ok: + hass.data[DOMAIN].pop(config_entry.entry_id) + + # Change update_interval for other Airly instances + set_update_interval(hass, len(hass.data[DOMAIN])) + + return unload_ok + + +class AirlyDataUpdateCoordinator(DataUpdateCoordinator): + """Define an object to hold Airly data.""" + + def __init__(self, hass, session, api_key, latitude, longitude, update_interval): + """Initialize.""" + self.latitude = latitude + self.longitude = longitude + self.airly = Airly(api_key, session) + + super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=update_interval) + + async def _async_update_data(self): + """Update data via library.""" + data = {} + with async_timeout.timeout(20): + measurements = self.airly.create_measurements_session_point( + self.latitude, self.longitude + ) + try: + await measurements.update() + except (AirlyError, ClientConnectorError) as error: + raise UpdateFailed(error) + + values = measurements.current["values"] + index = measurements.current["indexes"][0] + standards = measurements.current["standards"] + + if index["description"] == NO_AIRLY_SENSORS: + raise UpdateFailed("Can't retrieve data: no Airly sensors in this area") + for value in values: + data[value["name"]] = value["value"] + for standard in standards: + data[f"{standard['pollutant']}_LIMIT"] = standard["limit"] + data[f"{standard['pollutant']}_PERCENT"] = standard["percent"] + data[ATTR_API_CAQI] = index["value"] + data[ATTR_API_CAQI_LEVEL] = index["level"].lower().replace("_", " ") + data[ATTR_API_CAQI_DESCRIPTION] = index["description"] + data[ATTR_API_ADVICE] = index["advice"] + return data diff --git a/homeassistant/components/airly/air_quality.py b/homeassistant/components/airly/air_quality.py new file mode 100644 index 0000000000000..deeff9af00f73 --- /dev/null +++ b/homeassistant/components/airly/air_quality.py @@ -0,0 +1,137 @@ +"""Support for the Airly air_quality service.""" +from homeassistant.components.air_quality import ( + ATTR_AQI, + ATTR_PM_2_5, + ATTR_PM_10, + AirQualityEntity, +) +from homeassistant.const import CONF_NAME + +from .const import ( + ATTR_API_ADVICE, + ATTR_API_CAQI, + ATTR_API_CAQI_DESCRIPTION, + ATTR_API_CAQI_LEVEL, + ATTR_API_PM10, + ATTR_API_PM10_LIMIT, + ATTR_API_PM10_PERCENT, + ATTR_API_PM25, + ATTR_API_PM25_LIMIT, + ATTR_API_PM25_PERCENT, + DOMAIN, +) + +ATTRIBUTION = "Data provided by Airly" + +LABEL_ADVICE = "advice" +LABEL_AQI_DESCRIPTION = f"{ATTR_AQI}_description" +LABEL_AQI_LEVEL = f"{ATTR_AQI}_level" +LABEL_PM_2_5_LIMIT = f"{ATTR_PM_2_5}_limit" +LABEL_PM_2_5_PERCENT = f"{ATTR_PM_2_5}_percent_of_limit" +LABEL_PM_10_LIMIT = f"{ATTR_PM_10}_limit" +LABEL_PM_10_PERCENT = f"{ATTR_PM_10}_percent_of_limit" + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Airly air_quality entity based on a config entry.""" + name = config_entry.data[CONF_NAME] + + coordinator = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities( + [AirlyAirQuality(coordinator, name, config_entry.unique_id)], False + ) + + +def round_state(func): + """Round state.""" + + def _decorator(self): + res = func(self) + if isinstance(res, float): + return round(res) + return res + + return _decorator + + +class AirlyAirQuality(AirQualityEntity): + """Define an Airly air quality.""" + + def __init__(self, coordinator, name, unique_id): + """Initialize.""" + self.coordinator = coordinator + self._name = name + self._unique_id = unique_id + self._icon = "mdi:blur" + + @property + def name(self): + """Return the name.""" + return self._name + + @property + def should_poll(self): + """Return the polling requirement of the entity.""" + return False + + @property + def icon(self): + """Return the icon.""" + return self._icon + + @property + @round_state + def air_quality_index(self): + """Return the air quality index.""" + return self.coordinator.data[ATTR_API_CAQI] + + @property + @round_state + def particulate_matter_2_5(self): + """Return the particulate matter 2.5 level.""" + return self.coordinator.data[ATTR_API_PM25] + + @property + @round_state + def particulate_matter_10(self): + """Return the particulate matter 10 level.""" + return self.coordinator.data[ATTR_API_PM10] + + @property + def attribution(self): + """Return the attribution.""" + return ATTRIBUTION + + @property + def unique_id(self): + """Return a unique_id for this entity.""" + return self._unique_id + + @property + def available(self): + """Return True if entity is available.""" + return self.coordinator.last_update_success + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return { + LABEL_AQI_DESCRIPTION: self.coordinator.data[ATTR_API_CAQI_DESCRIPTION], + LABEL_ADVICE: self.coordinator.data[ATTR_API_ADVICE], + LABEL_AQI_LEVEL: self.coordinator.data[ATTR_API_CAQI_LEVEL], + LABEL_PM_2_5_LIMIT: self.coordinator.data[ATTR_API_PM25_LIMIT], + LABEL_PM_2_5_PERCENT: round(self.coordinator.data[ATTR_API_PM25_PERCENT]), + LABEL_PM_10_LIMIT: self.coordinator.data[ATTR_API_PM10_LIMIT], + LABEL_PM_10_PERCENT: round(self.coordinator.data[ATTR_API_PM10_PERCENT]), + } + + async def async_added_to_hass(self): + """Connect to dispatcher listening for entity data notifications.""" + self.async_on_remove( + self.coordinator.async_add_listener(self.async_write_ha_state) + ) + + async def async_update(self): + """Update Airly entity.""" + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/airly/config_flow.py b/homeassistant/components/airly/config_flow.py new file mode 100644 index 0000000000000..84bad2d3719f3 --- /dev/null +++ b/homeassistant/components/airly/config_flow.py @@ -0,0 +1,111 @@ +"""Adds config flow for Airly.""" +from airly import Airly +from airly.exceptions import AirlyError +import async_timeout +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv + +from .const import ( # pylint:disable=unused-import + DEFAULT_NAME, + DOMAIN, + NO_AIRLY_SENSORS, +) + + +class AirlyFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Config flow for Airly.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + def __init__(self): + """Initialize.""" + self._errors = {} + + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + self._errors = {} + + websession = async_get_clientsession(self.hass) + + if user_input is not None: + await self.async_set_unique_id( + f"{user_input[CONF_LATITUDE]}-{user_input[CONF_LONGITUDE]}" + ) + self._abort_if_unique_id_configured() + api_key_valid = await self._test_api_key(websession, user_input["api_key"]) + if not api_key_valid: + self._errors["base"] = "auth" + else: + location_valid = await self._test_location( + websession, + user_input["api_key"], + user_input["latitude"], + user_input["longitude"], + ) + if not location_valid: + self._errors["base"] = "wrong_location" + + if not self._errors: + return self.async_create_entry( + title=user_input[CONF_NAME], data=user_input + ) + + return self._show_config_form( + name=DEFAULT_NAME, + api_key="", + latitude=self.hass.config.latitude, + longitude=self.hass.config.longitude, + ) + + def _show_config_form(self, name=None, api_key=None, latitude=None, longitude=None): + """Show the configuration form to edit data.""" + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_API_KEY, default=api_key): str, + vol.Optional( + CONF_LATITUDE, default=self.hass.config.latitude + ): cv.latitude, + vol.Optional( + CONF_LONGITUDE, default=self.hass.config.longitude + ): cv.longitude, + vol.Optional(CONF_NAME, default=name): str, + } + ), + errors=self._errors, + ) + + async def _test_api_key(self, client, api_key): + """Return true if api_key is valid.""" + + with async_timeout.timeout(10): + airly = Airly(api_key, client) + measurements = airly.create_measurements_session_point( + latitude=52.24131, longitude=20.99101 + ) + try: + await measurements.update() + except AirlyError: + return False + return True + + async def _test_location(self, client, api_key, latitude, longitude): + """Return true if location is valid.""" + + with async_timeout.timeout(10): + airly = Airly(api_key, client) + measurements = airly.create_measurements_session_point( + latitude=latitude, longitude=longitude + ) + + await measurements.update() + current = measurements.current + if current["indexes"][0]["description"] == NO_AIRLY_SENSORS: + return False + return True diff --git a/homeassistant/components/airly/const.py b/homeassistant/components/airly/const.py new file mode 100644 index 0000000000000..d7f8fc1279704 --- /dev/null +++ b/homeassistant/components/airly/const.py @@ -0,0 +1,19 @@ +"""Constants for Airly integration.""" +ATTR_API_ADVICE = "ADVICE" +ATTR_API_CAQI = "CAQI" +ATTR_API_CAQI_DESCRIPTION = "DESCRIPTION" +ATTR_API_CAQI_LEVEL = "LEVEL" +ATTR_API_HUMIDITY = "HUMIDITY" +ATTR_API_PM1 = "PM1" +ATTR_API_PM10 = "PM10" +ATTR_API_PM10_LIMIT = "PM10_LIMIT" +ATTR_API_PM10_PERCENT = "PM10_PERCENT" +ATTR_API_PM25 = "PM25" +ATTR_API_PM25_LIMIT = "PM25_LIMIT" +ATTR_API_PM25_PERCENT = "PM25_PERCENT" +ATTR_API_PRESSURE = "PRESSURE" +ATTR_API_TEMPERATURE = "TEMPERATURE" +DEFAULT_NAME = "Airly" +DOMAIN = "airly" +MAX_REQUESTS_PER_DAY = 100 +NO_AIRLY_SENSORS = "There are no Airly sensors in this area yet." diff --git a/homeassistant/components/airly/manifest.json b/homeassistant/components/airly/manifest.json new file mode 100644 index 0000000000000..e86a187793fd3 --- /dev/null +++ b/homeassistant/components/airly/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "airly", + "name": "Airly", + "documentation": "https://www.home-assistant.io/integrations/airly", + "codeowners": ["@bieniu"], + "requirements": ["airly==0.0.2"], + "config_flow": true +} diff --git a/homeassistant/components/airly/sensor.py b/homeassistant/components/airly/sensor.py new file mode 100644 index 0000000000000..ec7b8cb3c76f2 --- /dev/null +++ b/homeassistant/components/airly/sensor.py @@ -0,0 +1,158 @@ +"""Support for the Airly sensor service.""" +from homeassistant.const import ( + ATTR_ATTRIBUTION, + ATTR_DEVICE_CLASS, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONF_NAME, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_TEMPERATURE, + PRESSURE_HPA, + TEMP_CELSIUS, + UNIT_PERCENTAGE, +) +from homeassistant.helpers.entity import Entity + +from .const import ( + ATTR_API_HUMIDITY, + ATTR_API_PM1, + ATTR_API_PRESSURE, + ATTR_API_TEMPERATURE, + DOMAIN, +) + +ATTRIBUTION = "Data provided by Airly" + +ATTR_ICON = "icon" +ATTR_LABEL = "label" +ATTR_UNIT = "unit" + +SENSOR_TYPES = { + ATTR_API_PM1: { + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:blur", + ATTR_LABEL: ATTR_API_PM1, + ATTR_UNIT: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + }, + ATTR_API_HUMIDITY: { + ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, + ATTR_ICON: None, + ATTR_LABEL: ATTR_API_HUMIDITY.capitalize(), + ATTR_UNIT: UNIT_PERCENTAGE, + }, + ATTR_API_PRESSURE: { + ATTR_DEVICE_CLASS: DEVICE_CLASS_PRESSURE, + ATTR_ICON: None, + ATTR_LABEL: ATTR_API_PRESSURE.capitalize(), + ATTR_UNIT: PRESSURE_HPA, + }, + ATTR_API_TEMPERATURE: { + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_ICON: None, + ATTR_LABEL: ATTR_API_TEMPERATURE.capitalize(), + ATTR_UNIT: TEMP_CELSIUS, + }, +} + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Airly sensor entities based on a config entry.""" + name = config_entry.data[CONF_NAME] + + coordinator = hass.data[DOMAIN][config_entry.entry_id] + + sensors = [] + for sensor in SENSOR_TYPES: + unique_id = f"{config_entry.unique_id}-{sensor.lower()}" + sensors.append(AirlySensor(coordinator, name, sensor, unique_id)) + + async_add_entities(sensors, False) + + +def round_state(func): + """Round state.""" + + def _decorator(self): + res = func(self) + if isinstance(res, float): + return round(res) + return res + + return _decorator + + +class AirlySensor(Entity): + """Define an Airly sensor.""" + + def __init__(self, coordinator, name, kind, unique_id): + """Initialize.""" + self.coordinator = coordinator + self._name = name + self._unique_id = unique_id + self.kind = kind + self._device_class = None + self._state = None + self._icon = None + self._unit_of_measurement = None + self._attrs = {ATTR_ATTRIBUTION: ATTRIBUTION} + + @property + def name(self): + """Return the name.""" + return f"{self._name} {SENSOR_TYPES[self.kind][ATTR_LABEL]}" + + @property + def should_poll(self): + """Return the polling requirement of the entity.""" + return False + + @property + def state(self): + """Return the state.""" + self._state = self.coordinator.data[self.kind] + if self.kind in [ATTR_API_PM1, ATTR_API_PRESSURE]: + self._state = round(self._state) + if self.kind in [ATTR_API_TEMPERATURE, ATTR_API_HUMIDITY]: + self._state = round(self._state, 1) + return self._state + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return self._attrs + + @property + def icon(self): + """Return the icon.""" + self._icon = SENSOR_TYPES[self.kind][ATTR_ICON] + return self._icon + + @property + def device_class(self): + """Return the device_class.""" + return SENSOR_TYPES[self.kind][ATTR_DEVICE_CLASS] + + @property + def unique_id(self): + """Return a unique_id for this entity.""" + return self._unique_id + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return SENSOR_TYPES[self.kind][ATTR_UNIT] + + @property + def available(self): + """Return True if entity is available.""" + return self.coordinator.last_update_success + + async def async_added_to_hass(self): + """Connect to dispatcher listening for entity data notifications.""" + self.async_on_remove( + self.coordinator.async_add_listener(self.async_write_ha_state) + ) + + async def async_update(self): + """Update Airly entity.""" + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/airly/strings.json b/homeassistant/components/airly/strings.json new file mode 100644 index 0000000000000..794f70901f392 --- /dev/null +++ b/homeassistant/components/airly/strings.json @@ -0,0 +1,23 @@ +{ + "config": { + "step": { + "user": { + "title": "Airly", + "description": "Set up Airly air quality integration. To generate API key go to https://developer.airly.eu/register", + "data": { + "name": "Name of the integration", + "api_key": "Airly API key", + "latitude": "Latitude", + "longitude": "Longitude" + } + } + }, + "error": { + "wrong_location": "No Airly measuring stations in this area.", + "auth": "API key is not correct." + }, + "abort": { + "already_configured": "Airly integration for these coordinates is already configured." + } + } +} diff --git a/homeassistant/components/airly/translations/bg.json b/homeassistant/components/airly/translations/bg.json new file mode 100644 index 0000000000000..d4e7dd2ec2811 --- /dev/null +++ b/homeassistant/components/airly/translations/bg.json @@ -0,0 +1,20 @@ +{ + "config": { + "error": { + "auth": "API \u043a\u043b\u044e\u0447\u044a\u0442 \u043d\u0435 \u0435 \u043f\u0440\u0430\u0432\u0438\u043b\u0435\u043d.", + "wrong_location": "\u0412 \u0442\u0430\u0437\u0438 \u043e\u0431\u043b\u0430\u0441\u0442 \u043d\u044f\u043c\u0430 \u0438\u0437\u043c\u0435\u0440\u0432\u0430\u0442\u0435\u043b\u043d\u0438 \u0441\u0442\u0430\u043d\u0446\u0438\u0438 \u043d\u0430 Airly." + }, + "step": { + "user": { + "data": { + "api_key": "API \u043a\u043b\u044e\u0447 \u0437\u0430 Airly", + "latitude": "\u0428\u0438\u0440\u0438\u043d\u0430", + "longitude": "\u0414\u044a\u043b\u0436\u0438\u043d\u0430", + "name": "\u0418\u043c\u0435 \u043d\u0430 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044f\u0442\u0430" + }, + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 \u0438\u043d\u0442\u0435\u0433\u0440\u0438\u0440\u0430\u043d\u0435 \u043d\u0430 \u043a\u0430\u0447\u0435\u0441\u0442\u0432\u043e\u0442\u043e \u043d\u0430 \u0432\u044a\u0437\u0434\u0443\u0445\u0430 Airly \u0417\u0430 \u0434\u0430 \u0433\u0435\u043d\u0435\u0440\u0438\u0440\u0430\u0442\u0435 \u043a\u043b\u044e\u0447 \u0437\u0430 API, \u043e\u0442\u0438\u0434\u0435\u0442\u0435 \u043d\u0430 https://developer.airly.eu/register", + "title": "Airly" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airly/translations/ca.json b/homeassistant/components/airly/translations/ca.json new file mode 100644 index 0000000000000..3caf870ccdf0a --- /dev/null +++ b/homeassistant/components/airly/translations/ca.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Ja est\u00e0 configurada un integraci\u00f3 Airly amb aquestes coordenades." + }, + "error": { + "auth": "La clau API no \u00e9s correcta.", + "wrong_location": "No hi ha estacions de mesura Airly en aquesta zona." + }, + "step": { + "user": { + "data": { + "api_key": "Clau API d'Airly", + "latitude": "Latitud", + "longitude": "Longitud", + "name": "Nom de la integraci\u00f3" + }, + "description": "Configura una integraci\u00f3 de qualitat d'aire Airly. Per generar la clau API, v\u00e9s a https://developer.airly.eu/register", + "title": "Airly" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airly/translations/da.json b/homeassistant/components/airly/translations/da.json new file mode 100644 index 0000000000000..5eaaeac8cfc39 --- /dev/null +++ b/homeassistant/components/airly/translations/da.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Airly-integration for disse koordinater er allerede konfigureret." + }, + "error": { + "auth": "API-n\u00f8glen er ikke korrekt.", + "wrong_location": "Ingen Airly-m\u00e5lestationer i dette omr\u00e5de." + }, + "step": { + "user": { + "data": { + "api_key": "Airly API-n\u00f8gle", + "latitude": "Breddegrad", + "longitude": "L\u00e6ngdegrad", + "name": "Integrationens navn" + }, + "description": "Konfigurer Airly luftkvalitetsintegration. For at generere API-n\u00f8gle, g\u00e5 til https://developer.airly.eu/register", + "title": "Airly" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airly/translations/de.json b/homeassistant/components/airly/translations/de.json new file mode 100644 index 0000000000000..551c5cd294d97 --- /dev/null +++ b/homeassistant/components/airly/translations/de.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Die Airly-Integration ist f\u00fcr diese Koordinaten bereits konfiguriert." + }, + "error": { + "auth": "Der API-Schl\u00fcssel ist nicht korrekt.", + "wrong_location": "Keine Airly Luftmessstation an diesem Ort" + }, + "step": { + "user": { + "data": { + "api_key": "Airly API-Schl\u00fcssel", + "latitude": "Breitengrad", + "longitude": "L\u00e4ngengrad", + "name": "Name der Integration" + }, + "description": "Einrichtung der Airly-Luftqualit\u00e4t Integration. Um einen API-Schl\u00fcssel zu generieren, registriere dich auf https://developer.airly.eu/register", + "title": "Airly" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airly/translations/en.json b/homeassistant/components/airly/translations/en.json new file mode 100644 index 0000000000000..ab243699232c1 --- /dev/null +++ b/homeassistant/components/airly/translations/en.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Airly integration for these coordinates is already configured." + }, + "error": { + "auth": "API key is not correct.", + "wrong_location": "No Airly measuring stations in this area." + }, + "step": { + "user": { + "data": { + "api_key": "Airly API key", + "latitude": "Latitude", + "longitude": "Longitude", + "name": "Name of the integration" + }, + "description": "Set up Airly air quality integration. To generate API key go to https://developer.airly.eu/register", + "title": "Airly" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airly/translations/es-419.json b/homeassistant/components/airly/translations/es-419.json new file mode 100644 index 0000000000000..43770d1767b17 --- /dev/null +++ b/homeassistant/components/airly/translations/es-419.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "La integraci\u00f3n a\u00e9rea para estas coordenadas ya est\u00e1 configurada." + }, + "error": { + "auth": "La clave API no es correcta.", + "wrong_location": "No hay estaciones de medici\u00f3n Airly en esta \u00e1rea." + }, + "step": { + "user": { + "data": { + "api_key": "Clave API de Airly", + "latitude": "Latitud", + "longitude": "Longitud", + "name": "Nombre de la integraci\u00f3n" + }, + "description": "Configure la integraci\u00f3n de la calidad del aire de Airly. Para generar la clave API, vaya a https://developer.airly.eu/register", + "title": "Airly" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airly/translations/es.json b/homeassistant/components/airly/translations/es.json new file mode 100644 index 0000000000000..353acfe2fb8c5 --- /dev/null +++ b/homeassistant/components/airly/translations/es.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "La integraci\u00f3n a\u00e9rea para estas coordenadas ya est\u00e1 configurada." + }, + "error": { + "auth": "La clave de la API no es correcta.", + "wrong_location": "No hay estaciones de medici\u00f3n Airly en esta zona." + }, + "step": { + "user": { + "data": { + "api_key": "Clave API de Airly", + "latitude": "Latitud", + "longitude": "Longitud", + "name": "Nombre de la integraci\u00f3n" + }, + "description": "Establecer la integraci\u00f3n de la calidad del aire de Airly. Para generar la clave de la API vaya a https://developer.airly.eu/register", + "title": "Airly" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airly/translations/fr.json b/homeassistant/components/airly/translations/fr.json new file mode 100644 index 0000000000000..a454a38fbe69e --- /dev/null +++ b/homeassistant/components/airly/translations/fr.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "L'int\u00e9gration des coordonn\u00e9es d'Airly est d\u00e9j\u00e0 configur\u00e9." + }, + "error": { + "auth": "La cl\u00e9 API n'est pas correcte.", + "wrong_location": "Aucune station de mesure Airly dans cette zone." + }, + "step": { + "user": { + "data": { + "api_key": "Cl\u00e9 API Airly", + "latitude": "Latitude", + "longitude": "Longitude", + "name": "Nom de l'int\u00e9gration" + }, + "description": "Configurez l'int\u00e9gration de la qualit\u00e9 de l'air Airly. Pour g\u00e9n\u00e9rer une cl\u00e9 API, rendez-vous sur https://developer.airly.eu/register.", + "title": "Airly" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airly/translations/hu.json b/homeassistant/components/airly/translations/hu.json new file mode 100644 index 0000000000000..f91b2de241f42 --- /dev/null +++ b/homeassistant/components/airly/translations/hu.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Ezen koordin\u00e1t\u00e1k Airly integr\u00e1ci\u00f3ja m\u00e1r konfigur\u00e1lva van." + }, + "error": { + "auth": "Az API kulcs nem megfelel\u0151.", + "wrong_location": "Ezen a ter\u00fcleten nincs Airly m\u00e9r\u0151\u00e1llom\u00e1s." + }, + "step": { + "user": { + "data": { + "api_key": "Airly API kulcs", + "latitude": "Sz\u00e9less\u00e9g", + "longitude": "Hossz\u00fas\u00e1g", + "name": "Az integr\u00e1ci\u00f3 neve" + }, + "description": "Az Airly leveg\u0151min\u0151s\u00e9gi integr\u00e1ci\u00f3j\u00e1nak be\u00e1ll\u00edt\u00e1sa. Api-kulcs l\u00e9trehoz\u00e1s\u00e1hoz nyissa meg a k\u00f6vetkez\u0151 weboldalt: https://developer.airly.eu/register", + "title": "Airly" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airly/translations/it.json b/homeassistant/components/airly/translations/it.json new file mode 100644 index 0000000000000..c42af14f03008 --- /dev/null +++ b/homeassistant/components/airly/translations/it.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "L'integrazione Airly per queste coordinate \u00e8 gi\u00e0 configurata." + }, + "error": { + "auth": "La chiave API non \u00e8 corretta.", + "wrong_location": "Nessuna stazione di misurazione Airly in quest'area." + }, + "step": { + "user": { + "data": { + "api_key": "Chiave API Airly", + "latitude": "Latitudine", + "longitude": "Logitudine", + "name": "Nome dell'integrazione" + }, + "description": "Configurazione dell'integrazione della qualit\u00e0 dell'aria Airly. Per generare la chiave API andare su https://developer.airly.eu/register", + "title": "Airly" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airly/translations/ko.json b/homeassistant/components/airly/translations/ko.json new file mode 100644 index 0000000000000..5283797eb793b --- /dev/null +++ b/homeassistant/components/airly/translations/ko.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "\uc774 \uc88c\ud45c\uc5d0 \ub300\ud55c Airly \ud1b5\ud569 \uad6c\uc131\uc694\uc18c\ub294 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + }, + "error": { + "auth": "API \ud0a4\uac00 \uc62c\ubc14\ub974\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4.", + "wrong_location": "\uc774 \uc9c0\uc5ed\uc5d0\ub294 Airly \uce21\uc815 \uc2a4\ud14c\uc774\uc158\uc774 \uc5c6\uc2b5\ub2c8\ub2e4." + }, + "step": { + "user": { + "data": { + "api_key": "Airly API \ud0a4", + "latitude": "\uc704\ub3c4", + "longitude": "\uacbd\ub3c4", + "name": "\ud1b5\ud569 \uad6c\uc131\uc694\uc18c\uc758 \uc774\ub984" + }, + "description": "Airly \uacf5\uae30 \ud488\uc9c8 \ud1b5\ud569 \uad6c\uc131\uc694\uc18c\ub97c \uc124\uc815\ud569\ub2c8\ub2e4. API \ud0a4\ub97c \uc0dd\uc131\ud558\ub824\uba74 https://developer.airly.eu/register \ub85c \uc774\ub3d9\ud574\uc8fc\uc138\uc694", + "title": "Airly" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airly/translations/lb.json b/homeassistant/components/airly/translations/lb.json new file mode 100644 index 0000000000000..9a935079d99e9 --- /dev/null +++ b/homeassistant/components/airly/translations/lb.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Airly Integratioun fir d\u00ebs Koordinaten ass scho konfigur\u00e9iert." + }, + "error": { + "auth": "Api Schl\u00ebssel ass net korrekt.", + "wrong_location": "Keng Airly Moos Statioun an d\u00ebsem Ber\u00e4ich" + }, + "step": { + "user": { + "data": { + "api_key": "Airly API Schl\u00ebssel", + "latitude": "Breedegrad", + "longitude": "L\u00e4ngegrad", + "name": "Numm vun der Installatioun" + }, + "description": "Airly Loft Qualit\u00e9it Integratioun ariichten. Fir een API Schl\u00ebssel z'erstelle gitt op https://developer.airly.eu/register", + "title": "Airly" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airly/translations/nl.json b/homeassistant/components/airly/translations/nl.json new file mode 100644 index 0000000000000..f8edf64daa2c4 --- /dev/null +++ b/homeassistant/components/airly/translations/nl.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Airly-integratie voor deze co\u00f6rdinaten is al geconfigureerd." + }, + "error": { + "auth": "API-sleutel is niet correct.", + "wrong_location": "Geen Airly meetstations in dit gebied." + }, + "step": { + "user": { + "data": { + "api_key": "Airly API-sleutel", + "latitude": "Breedtegraad", + "longitude": "Lengtegraad", + "name": "Naam van de integratie" + }, + "description": "Airly-integratie van luchtkwaliteit instellen. Ga naar https://developer.airly.eu/register om de API-sleutel te genereren", + "title": "Airly" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airly/translations/nn.json b/homeassistant/components/airly/translations/nn.json new file mode 100644 index 0000000000000..9cf2b5d70fb60 --- /dev/null +++ b/homeassistant/components/airly/translations/nn.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "user": { + "title": "Airly" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airly/translations/no.json b/homeassistant/components/airly/translations/no.json new file mode 100644 index 0000000000000..965b12ef1fe92 --- /dev/null +++ b/homeassistant/components/airly/translations/no.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Airly integrering for disse koordinatene er allerede konfigurert." + }, + "error": { + "auth": "API-n\u00f8kkelen er ikke korrekt.", + "wrong_location": "Ingen Airly m\u00e5lestasjoner i dette omr\u00e5det." + }, + "step": { + "user": { + "data": { + "api_key": "Airly API-n\u00f8kkel", + "latitude": "Breddegrad", + "longitude": "Lengdegrad", + "name": "Navn p\u00e5 integrasjonen" + }, + "description": "Sett opp Airly luftkvalitet integrering. For \u00e5 generere API-n\u00f8kkel g\u00e5 til https://developer.airly.eu/register", + "title": "" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airly/translations/pl.json b/homeassistant/components/airly/translations/pl.json new file mode 100644 index 0000000000000..3cc43883308d0 --- /dev/null +++ b/homeassistant/components/airly/translations/pl.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Integracja Airly dla tych wsp\u00f3\u0142rz\u0119dnych jest ju\u017c skonfigurowana." + }, + "error": { + "auth": "Klucz API jest nieprawid\u0142owy.", + "wrong_location": "Brak stacji pomiarowych Airly w tym rejonie." + }, + "step": { + "user": { + "data": { + "api_key": "Klucz API Airly", + "latitude": "Szeroko\u015b\u0107 geograficzna", + "longitude": "D\u0142ugo\u015b\u0107 geograficzna", + "name": "Nazwa integracji" + }, + "description": "Konfiguracja integracji Airly. By wygenerowa\u0107 klucz API, przejd\u017a na stron\u0119 https://developer.airly.eu/register", + "title": "Airly" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airly/translations/pt.json b/homeassistant/components/airly/translations/pt.json new file mode 100644 index 0000000000000..971b4653be320 --- /dev/null +++ b/homeassistant/components/airly/translations/pt.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "user": { + "data": { + "latitude": "Latitude", + "longitude": "Longitude" + }, + "title": "" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airly/translations/ru.json b/homeassistant/components/airly/translations/ru.json new file mode 100644 index 0000000000000..9b3a62331db45 --- /dev/null +++ b/homeassistant/components/airly/translations/ru.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "\u0418\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044f Airly \u0441 \u0442\u0430\u043a\u0438\u043c\u0438 \u0436\u0435 \u043a\u043e\u043e\u0440\u0434\u0438\u043d\u0430\u0442\u0430\u043c\u0438 \u0443\u0436\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d\u0430." + }, + "error": { + "auth": "\u041d\u0435\u043f\u0440\u0430\u0432\u0438\u043b\u044c\u043d\u044b\u0439 \u043a\u043b\u044e\u0447 API.", + "wrong_location": "\u0412 \u044d\u0442\u043e\u0439 \u043e\u0431\u043b\u0430\u0441\u0442\u0438 \u043d\u0435\u0442 \u0438\u0437\u043c\u0435\u0440\u0438\u0442\u0435\u043b\u044c\u043d\u044b\u0445 \u0441\u0442\u0430\u043d\u0446\u0438\u0439 Airly." + }, + "step": { + "user": { + "data": { + "api_key": "\u041a\u043b\u044e\u0447 API", + "latitude": "\u0428\u0438\u0440\u043e\u0442\u0430", + "longitude": "\u0414\u043e\u043b\u0433\u043e\u0442\u0430", + "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435" + }, + "description": "\u0418\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044f \u0441\u0435\u0440\u0432\u0438\u0441\u0430 \u043f\u043e \u0430\u043d\u0430\u043b\u0438\u0437\u0443 \u043a\u0430\u0447\u0435\u0441\u0442\u0432\u0430 \u0432\u043e\u0437\u0434\u0443\u0445\u0430 Airly. \u0427\u0442\u043e\u0431\u044b \u0441\u043e\u0437\u0434\u0430\u0442\u044c \u043a\u043b\u044e\u0447 API, \u043f\u0435\u0440\u0435\u0439\u0434\u0438\u0442\u0435 \u043f\u043e \u0441\u0441\u044b\u043b\u043a\u0435 https://developer.airly.eu/register.", + "title": "Airly" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airly/translations/sl.json b/homeassistant/components/airly/translations/sl.json new file mode 100644 index 0000000000000..9fe84c532d1f5 --- /dev/null +++ b/homeassistant/components/airly/translations/sl.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Airly integracija za te koordinate je \u017ee nastavljen." + }, + "error": { + "auth": "Klju\u010d API ni pravilen.", + "wrong_location": "Na tem obmo\u010dju ni merilnih postaj Airly." + }, + "step": { + "user": { + "data": { + "api_key": "Airly API klju\u010d", + "latitude": "Zemljepisna \u0161irina", + "longitude": "Zemljepisna dol\u017eina", + "name": "Ime integracije" + }, + "description": "Nastavite Airly integracijo za kakovost zraka. \u010ce \u017eelite ustvariti API klju\u010d pojdite na https://developer.airly.eu/register", + "title": "Airly" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airly/translations/sv.json b/homeassistant/components/airly/translations/sv.json new file mode 100644 index 0000000000000..4a79f9cefe94a --- /dev/null +++ b/homeassistant/components/airly/translations/sv.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Airly-integrationen f\u00f6r dessa koordinater \u00e4r redan konfigurerad." + }, + "error": { + "auth": "API-nyckeln \u00e4r inte korrekt.", + "wrong_location": "Inga Airly m\u00e4tstationer i detta omr\u00e5de." + }, + "step": { + "user": { + "data": { + "api_key": "Airly API-nyckel", + "latitude": "Latitud", + "longitude": "Longitud", + "name": "Integrationens namn" + }, + "description": "Konfigurera integration av luftkvalitet. F\u00f6r att skapa API-nyckel, g\u00e5 till https://developer.airly.eu/register", + "title": "Airly" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airly/translations/zh-Hant.json b/homeassistant/components/airly/translations/zh-Hant.json new file mode 100644 index 0000000000000..d594922cd8fc4 --- /dev/null +++ b/homeassistant/components/airly/translations/zh-Hant.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "\u6b64 Airly \u6574\u5408\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210\u3002" + }, + "error": { + "auth": "API \u5bc6\u9470\u4e0d\u6b63\u78ba\u3002", + "wrong_location": "\u8a72\u5340\u57df\u6c92\u6709 Arily \u76e3\u6e2c\u7ad9\u3002" + }, + "step": { + "user": { + "data": { + "api_key": "Airly API \u5bc6\u9470", + "latitude": "\u7def\u5ea6", + "longitude": "\u7d93\u5ea6", + "name": "\u6574\u5408\u540d\u7a31" + }, + "description": "\u6b32\u8a2d\u5b9a Airly \u7a7a\u6c23\u54c1\u8cea\u6574\u5408\u3002\u8acb\u81f3 https://developer.airly.eu/register \u7522\u751f API \u5bc6\u9470", + "title": "Airly" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/__init__.py b/homeassistant/components/airvisual/__init__.py index b1f79d1724163..099fdfc5df71b 100644 --- a/homeassistant/components/airvisual/__init__.py +++ b/homeassistant/components/airvisual/__init__.py @@ -1 +1,337 @@ """The airvisual component.""" +import asyncio +from datetime import timedelta + +from pyairvisual import Client +from pyairvisual.errors import AirVisualError, NodeProError +import voluptuous as vol + +from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.const import ( + ATTR_ATTRIBUTION, + CONF_API_KEY, + CONF_IP_ADDRESS, + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_PASSWORD, + CONF_SHOW_ON_MAP, + CONF_STATE, +) +from homeassistant.core import callback +from homeassistant.helpers import aiohttp_client, config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import ( + CONF_CITY, + CONF_COUNTRY, + CONF_GEOGRAPHIES, + CONF_INTEGRATION_TYPE, + DATA_COORDINATOR, + DOMAIN, + INTEGRATION_TYPE_GEOGRAPHY, + INTEGRATION_TYPE_NODE_PRO, + LOGGER, +) + +PLATFORMS = ["air_quality", "sensor"] + +DEFAULT_ATTRIBUTION = "Data provided by AirVisual" +DEFAULT_GEOGRAPHY_SCAN_INTERVAL = timedelta(minutes=10) +DEFAULT_NODE_PRO_SCAN_INTERVAL = timedelta(minutes=1) +DEFAULT_OPTIONS = {CONF_SHOW_ON_MAP: True} + +GEOGRAPHY_COORDINATES_SCHEMA = vol.Schema( + { + vol.Required(CONF_LATITUDE): cv.latitude, + vol.Required(CONF_LONGITUDE): cv.longitude, + } +) + +GEOGRAPHY_PLACE_SCHEMA = vol.Schema( + { + vol.Required(CONF_CITY): cv.string, + vol.Required(CONF_STATE): cv.string, + vol.Required(CONF_COUNTRY): cv.string, + } +) + +CLOUD_API_SCHEMA = vol.Schema( + { + vol.Required(CONF_API_KEY): cv.string, + vol.Optional(CONF_GEOGRAPHIES, default=[]): vol.All( + cv.ensure_list, + [vol.Any(GEOGRAPHY_COORDINATES_SCHEMA, GEOGRAPHY_PLACE_SCHEMA)], + ), + } +) + +CONFIG_SCHEMA = vol.Schema({DOMAIN: CLOUD_API_SCHEMA}, extra=vol.ALLOW_EXTRA) + + +@callback +def async_get_geography_id(geography_dict): + """Generate a unique ID from a geography dict.""" + if not geography_dict: + return + + if CONF_CITY in geography_dict: + return ", ".join( + ( + geography_dict[CONF_CITY], + geography_dict[CONF_STATE], + geography_dict[CONF_COUNTRY], + ) + ) + return ", ".join( + (str(geography_dict[CONF_LATITUDE]), str(geography_dict[CONF_LONGITUDE])) + ) + + +async def async_setup(hass, config): + """Set up the AirVisual component.""" + hass.data[DOMAIN] = {DATA_COORDINATOR: {}} + + if DOMAIN not in config: + return True + + conf = config[DOMAIN] + + for geography in conf.get( + CONF_GEOGRAPHIES, + [{CONF_LATITUDE: hass.config.latitude, CONF_LONGITUDE: hass.config.longitude}], + ): + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_API_KEY: conf[CONF_API_KEY], **geography}, + ) + ) + + return True + + +@callback +def _standardize_geography_config_entry(hass, config_entry): + """Ensure that geography config entries have appropriate properties.""" + entry_updates = {} + + if not config_entry.unique_id: + # If the config entry doesn't already have a unique ID, set one: + entry_updates["unique_id"] = config_entry.data[CONF_API_KEY] + if not config_entry.options: + # If the config entry doesn't already have any options set, set defaults: + entry_updates["options"] = {CONF_SHOW_ON_MAP: True} + if CONF_INTEGRATION_TYPE not in config_entry.data: + # If the config entry data doesn't contain the integration type, add it: + entry_updates["data"] = { + **config_entry.data, + CONF_INTEGRATION_TYPE: INTEGRATION_TYPE_GEOGRAPHY, + } + + if not entry_updates: + return + + hass.config_entries.async_update_entry(config_entry, **entry_updates) + + +@callback +def _standardize_node_pro_config_entry(hass, config_entry): + """Ensure that Node/Pro config entries have appropriate properties.""" + entry_updates = {} + + if CONF_INTEGRATION_TYPE not in config_entry.data: + # If the config entry data doesn't contain the integration type, add it: + entry_updates["data"] = { + **config_entry.data, + CONF_INTEGRATION_TYPE: INTEGRATION_TYPE_NODE_PRO, + } + + if not entry_updates: + return + + hass.config_entries.async_update_entry(config_entry, **entry_updates) + + +async def async_setup_entry(hass, config_entry): + """Set up AirVisual as config entry.""" + websession = aiohttp_client.async_get_clientsession(hass) + + if CONF_API_KEY in config_entry.data: + _standardize_geography_config_entry(hass, config_entry) + + client = Client(api_key=config_entry.data[CONF_API_KEY], session=websession) + + async def async_update_data(): + """Get new data from the API.""" + if CONF_CITY in config_entry.data: + api_coro = client.api.city( + config_entry.data[CONF_CITY], + config_entry.data[CONF_STATE], + config_entry.data[CONF_COUNTRY], + ) + else: + api_coro = client.api.nearest_city( + config_entry.data[CONF_LATITUDE], config_entry.data[CONF_LONGITUDE], + ) + + try: + return await api_coro + except AirVisualError as err: + raise UpdateFailed(f"Error while retrieving data: {err}") + + coordinator = DataUpdateCoordinator( + hass, + LOGGER, + name="geography data", + update_interval=DEFAULT_GEOGRAPHY_SCAN_INTERVAL, + update_method=async_update_data, + ) + + # Only geography-based entries have options: + config_entry.add_update_listener(async_update_options) + else: + _standardize_node_pro_config_entry(hass, config_entry) + + client = Client(session=websession) + + async def async_update_data(): + """Get new data from the API.""" + try: + return await client.node.from_samba( + config_entry.data[CONF_IP_ADDRESS], + config_entry.data[CONF_PASSWORD], + include_history=False, + include_trends=False, + ) + except NodeProError as err: + raise UpdateFailed(f"Error while retrieving data: {err}") + + coordinator = DataUpdateCoordinator( + hass, + LOGGER, + name="Node/Pro data", + update_interval=DEFAULT_NODE_PRO_SCAN_INTERVAL, + update_method=async_update_data, + ) + + await coordinator.async_refresh() + + hass.data[DOMAIN][DATA_COORDINATOR][config_entry.entry_id] = coordinator + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(config_entry, component) + ) + + return True + + +async def async_migrate_entry(hass, config_entry): + """Migrate an old config entry.""" + version = config_entry.version + + LOGGER.debug("Migrating from version %s", version) + + # 1 -> 2: One geography per config entry + if version == 1: + version = config_entry.version = 2 + + # Update the config entry to only include the first geography (there is always + # guaranteed to be at least one): + geographies = list(config_entry.data[CONF_GEOGRAPHIES]) + first_geography = geographies.pop(0) + first_id = async_get_geography_id(first_geography) + + hass.config_entries.async_update_entry( + config_entry, + unique_id=first_id, + title=f"Cloud API ({first_id})", + data={CONF_API_KEY: config_entry.data[CONF_API_KEY], **first_geography}, + ) + + # For any geographies that remain, create a new config entry for each one: + for geography in geographies: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_API_KEY: config_entry.data[CONF_API_KEY], **geography}, + ) + ) + + LOGGER.info("Migration to version %s successful", version) + + return True + + +async def async_unload_entry(hass, config_entry): + """Unload an AirVisual config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(config_entry, component) + for component in PLATFORMS + ] + ) + ) + if unload_ok: + hass.data[DOMAIN][DATA_COORDINATOR].pop(config_entry.entry_id) + + return unload_ok + + +async def async_update_options(hass, config_entry): + """Handle an options update.""" + coordinator = hass.data[DOMAIN][DATA_COORDINATOR][config_entry.entry_id] + await coordinator.async_request_refresh() + + +class AirVisualEntity(Entity): + """Define a generic AirVisual entity.""" + + def __init__(self, coordinator): + """Initialize.""" + self._attrs = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} + self._icon = None + self._unit = None + self.coordinator = coordinator + + @property + def available(self): + """Return if entity is available.""" + return self.coordinator.last_update_success + + @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 unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return self._unit + + async def async_added_to_hass(self): + """Register callbacks.""" + + @callback + def update(): + """Update the state.""" + self.update_from_latest_data() + self.async_write_ha_state() + + self.async_on_remove(self.coordinator.async_add_listener(update)) + + self.update_from_latest_data() + + @callback + def update_from_latest_data(self): + """Update the entity from the latest data.""" + raise NotImplementedError diff --git a/homeassistant/components/airvisual/air_quality.py b/homeassistant/components/airvisual/air_quality.py new file mode 100644 index 0000000000000..bd1c10a9d845d --- /dev/null +++ b/homeassistant/components/airvisual/air_quality.py @@ -0,0 +1,112 @@ +"""Support for AirVisual Node/Pro units.""" +from homeassistant.components.air_quality import AirQualityEntity +from homeassistant.const import CONCENTRATION_MICROGRAMS_PER_CUBIC_METER +from homeassistant.core import callback + +from . import AirVisualEntity +from .const import ( + CONF_INTEGRATION_TYPE, + DATA_COORDINATOR, + DOMAIN, + INTEGRATION_TYPE_GEOGRAPHY, +) + +ATTR_HUMIDITY = "humidity" +ATTR_SENSOR_LIFE = "{0}_sensor_life" +ATTR_VOC = "voc" + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up AirVisual air quality entities based on a config entry.""" + coordinator = hass.data[DOMAIN][DATA_COORDINATOR][config_entry.entry_id] + + # Geography-based AirVisual integrations don't utilize this platform: + if config_entry.data[CONF_INTEGRATION_TYPE] == INTEGRATION_TYPE_GEOGRAPHY: + return + + async_add_entities([AirVisualNodeProSensor(coordinator)], True) + + +class AirVisualNodeProSensor(AirVisualEntity, AirQualityEntity): + """Define a sensor for a AirVisual Node/Pro.""" + + def __init__(self, airvisual): + """Initialize.""" + super().__init__(airvisual) + + self._icon = "mdi:chemical-weapon" + self._unit = CONCENTRATION_MICROGRAMS_PER_CUBIC_METER + + @property + def air_quality_index(self): + """Return the Air Quality Index (AQI).""" + if self.coordinator.data["current"]["settings"]["is_aqi_usa"]: + return self.coordinator.data["current"]["measurements"]["aqi_us"] + return self.coordinator.data["current"]["measurements"]["aqi_cn"] + + @property + def available(self): + """Return True if entity is available.""" + return bool(self.coordinator.data) + + @property + def carbon_dioxide(self): + """Return the CO2 (carbon dioxide) level.""" + return self.coordinator.data["current"]["measurements"].get("co2") + + @property + def device_info(self): + """Return device registry information for this entity.""" + return { + "identifiers": { + (DOMAIN, self.coordinator.data["current"]["serial_number"]) + }, + "name": self.coordinator.data["current"]["settings"]["node_name"], + "manufacturer": "AirVisual", + "model": f'{self.coordinator.data["current"]["status"]["model"]}', + "sw_version": ( + f'Version {self.coordinator.data["current"]["status"]["system_version"]}' + f'{self.coordinator.data["current"]["status"]["app_version"]}' + ), + } + + @property + def name(self): + """Return the name.""" + node_name = self.coordinator.data["current"]["settings"]["node_name"] + return f"{node_name} Node/Pro: Air Quality" + + @property + def particulate_matter_2_5(self): + """Return the particulate matter 2.5 level.""" + return self.coordinator.data["current"]["measurements"].get("pm2_5") + + @property + def particulate_matter_10(self): + """Return the particulate matter 10 level.""" + return self.coordinator.data["current"]["measurements"].get("pm1_0") + + @property + def particulate_matter_0_1(self): + """Return the particulate matter 0.1 level.""" + return self.coordinator.data["current"]["measurements"].get("pm0_1") + + @property + def unique_id(self): + """Return a unique, Home Assistant friendly identifier for this entity.""" + return self.coordinator.data["current"]["serial_number"] + + @callback + def update_from_latest_data(self): + """Update the entity from the latest data.""" + self._attrs.update( + { + ATTR_VOC: self.coordinator.data["current"]["measurements"].get("voc"), + **{ + ATTR_SENSOR_LIFE.format(pollutant): lifespan + for pollutant, lifespan in self.coordinator.data["current"][ + "status" + ]["sensor_life"].items() + }, + } + ) diff --git a/homeassistant/components/airvisual/config_flow.py b/homeassistant/components/airvisual/config_flow.py new file mode 100644 index 0000000000000..abbc2df9061a3 --- /dev/null +++ b/homeassistant/components/airvisual/config_flow.py @@ -0,0 +1,201 @@ +"""Define a config flow manager for AirVisual.""" +import asyncio + +from pyairvisual import Client +from pyairvisual.errors import InvalidKeyError, NodeProError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import ( + CONF_API_KEY, + CONF_IP_ADDRESS, + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_PASSWORD, + CONF_SHOW_ON_MAP, +) +from homeassistant.core import callback +from homeassistant.helpers import aiohttp_client, config_validation as cv + +from . import async_get_geography_id +from .const import ( # pylint: disable=unused-import + CONF_GEOGRAPHIES, + CONF_INTEGRATION_TYPE, + DOMAIN, + INTEGRATION_TYPE_GEOGRAPHY, + INTEGRATION_TYPE_NODE_PRO, + LOGGER, +) + + +class AirVisualFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle an AirVisual config flow.""" + + VERSION = 2 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + @property + def geography_schema(self): + """Return the data schema for the cloud API.""" + return vol.Schema( + { + vol.Required(CONF_API_KEY): str, + vol.Required( + CONF_LATITUDE, default=self.hass.config.latitude + ): cv.latitude, + vol.Required( + CONF_LONGITUDE, default=self.hass.config.longitude + ): cv.longitude, + } + ) + + @property + def pick_integration_type_schema(self): + """Return the data schema for picking the integration type.""" + return vol.Schema( + { + vol.Required("type"): vol.In( + [INTEGRATION_TYPE_GEOGRAPHY, INTEGRATION_TYPE_NODE_PRO] + ) + } + ) + + @property + def node_pro_schema(self): + """Return the data schema for a Node/Pro.""" + return vol.Schema( + {vol.Required(CONF_IP_ADDRESS): str, vol.Required(CONF_PASSWORD): str} + ) + + async def _async_set_unique_id(self, unique_id): + """Set the unique ID of the config flow and abort if it already exists.""" + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Define the config flow to handle options.""" + return AirVisualOptionsFlowHandler(config_entry) + + async def async_step_geography(self, user_input=None): + """Handle the initialization of the integration via the cloud API.""" + if not user_input: + return self.async_show_form( + step_id="geography", data_schema=self.geography_schema + ) + + geo_id = async_get_geography_id(user_input) + await self._async_set_unique_id(geo_id) + self._abort_if_unique_id_configured() + + # Find older config entries without unique ID: + for entry in self._async_current_entries(): + if entry.version != 1: + continue + + if any( + geo_id == async_get_geography_id(geography) + for geography in entry.data[CONF_GEOGRAPHIES] + ): + return self.async_abort(reason="already_configured") + + websession = aiohttp_client.async_get_clientsession(self.hass) + client = Client(session=websession, api_key=user_input[CONF_API_KEY]) + + # If this is the first (and only the first) time we've seen this API key, check + # that it's valid: + checked_keys = self.hass.data.setdefault("airvisual_checked_api_keys", set()) + check_keys_lock = self.hass.data.setdefault( + "airvisual_checked_api_keys_lock", asyncio.Lock() + ) + + async with check_keys_lock: + if user_input[CONF_API_KEY] not in checked_keys: + try: + await client.api.nearest_city() + except InvalidKeyError: + return self.async_show_form( + step_id="geography", + data_schema=self.geography_schema, + errors={CONF_API_KEY: "invalid_api_key"}, + ) + + checked_keys.add(user_input[CONF_API_KEY]) + + return self.async_create_entry( + title=f"Cloud API ({geo_id})", + data={**user_input, CONF_INTEGRATION_TYPE: INTEGRATION_TYPE_GEOGRAPHY}, + ) + + async def async_step_import(self, import_config): + """Import a config entry from configuration.yaml.""" + return await self.async_step_geography(import_config) + + async def async_step_node_pro(self, user_input=None): + """Handle the initialization of the integration with a Node/Pro.""" + if not user_input: + return self.async_show_form( + step_id="node_pro", data_schema=self.node_pro_schema + ) + + await self._async_set_unique_id(user_input[CONF_IP_ADDRESS]) + + websession = aiohttp_client.async_get_clientsession(self.hass) + client = Client(session=websession) + + try: + await client.node.from_samba( + user_input[CONF_IP_ADDRESS], + user_input[CONF_PASSWORD], + include_history=False, + include_trends=False, + ) + except NodeProError as err: + LOGGER.error("Error connecting to Node/Pro unit: %s", err) + return self.async_show_form( + step_id="node_pro", + data_schema=self.node_pro_schema, + errors={CONF_IP_ADDRESS: "unable_to_connect"}, + ) + + return self.async_create_entry( + title=f"Node/Pro ({user_input[CONF_IP_ADDRESS]})", + data={**user_input, CONF_INTEGRATION_TYPE: INTEGRATION_TYPE_NODE_PRO}, + ) + + async def async_step_user(self, user_input=None): + """Handle the start of the config flow.""" + if not user_input: + return self.async_show_form( + step_id="user", data_schema=self.pick_integration_type_schema + ) + + if user_input["type"] == INTEGRATION_TYPE_GEOGRAPHY: + return await self.async_step_geography() + return await self.async_step_node_pro() + + +class AirVisualOptionsFlowHandler(config_entries.OptionsFlow): + """Handle an AirVisual options flow.""" + + def __init__(self, config_entry): + """Initialize.""" + self.config_entry = config_entry + + async def async_step_init(self, user_input=None): + """Manage the options.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Required( + CONF_SHOW_ON_MAP, + default=self.config_entry.options.get(CONF_SHOW_ON_MAP), + ): bool + } + ), + ) diff --git a/homeassistant/components/airvisual/const.py b/homeassistant/components/airvisual/const.py new file mode 100644 index 0000000000000..a98a899b76242 --- /dev/null +++ b/homeassistant/components/airvisual/const.py @@ -0,0 +1,15 @@ +"""Define AirVisual constants.""" +import logging + +DOMAIN = "airvisual" +LOGGER = logging.getLogger(__package__) + +INTEGRATION_TYPE_GEOGRAPHY = "Geographical Location" +INTEGRATION_TYPE_NODE_PRO = "AirVisual Node/Pro" + +CONF_CITY = "city" +CONF_COUNTRY = "country" +CONF_GEOGRAPHIES = "geographies" +CONF_INTEGRATION_TYPE = "integration_type" + +DATA_COORDINATOR = "coordinator" diff --git a/homeassistant/components/airvisual/manifest.json b/homeassistant/components/airvisual/manifest.json index ddb109a99b07e..93b57a4804ee2 100644 --- a/homeassistant/components/airvisual/manifest.json +++ b/homeassistant/components/airvisual/manifest.json @@ -1,12 +1,8 @@ { "domain": "airvisual", - "name": "Airvisual", - "documentation": "https://www.home-assistant.io/components/airvisual", - "requirements": [ - "pyairvisual==3.0.1" - ], - "dependencies": [], - "codeowners": [ - "@bachya" - ] + "name": "AirVisual", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/airvisual", + "requirements": ["pyairvisual==4.4.0"], + "codeowners": ["@bachya"] } diff --git a/homeassistant/components/airvisual/sensor.py b/homeassistant/components/airvisual/sensor.py index 5567002013305..b122f3c27b463 100644 --- a/homeassistant/components/airvisual/sensor.py +++ b/homeassistant/components/airvisual/sensor.py @@ -1,210 +1,153 @@ """Support for AirVisual air quality sensors.""" from logging import getLogger -from datetime import timedelta -import voluptuous as vol - -from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - ATTR_ATTRIBUTION, ATTR_LATITUDE, ATTR_LONGITUDE, CONF_API_KEY, - CONF_LATITUDE, CONF_LONGITUDE, CONF_MONITORED_CONDITIONS, - CONF_SCAN_INTERVAL, CONF_STATE, CONF_SHOW_ON_MAP) -from homeassistant.helpers import aiohttp_client, config_validation as cv -from homeassistant.helpers.entity import Entity -from homeassistant.util import Throttle + ATTR_LATITUDE, + ATTR_LONGITUDE, + ATTR_STATE, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_PARTS_PER_BILLION, + CONCENTRATION_PARTS_PER_MILLION, + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_SHOW_ON_MAP, + CONF_STATE, + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_TEMPERATURE, + TEMP_CELSIUS, + UNIT_PERCENTAGE, +) +from homeassistant.core import callback + +from . import AirVisualEntity +from .const import ( + CONF_CITY, + CONF_COUNTRY, + CONF_INTEGRATION_TYPE, + DATA_COORDINATOR, + DOMAIN, + INTEGRATION_TYPE_GEOGRAPHY, +) _LOGGER = getLogger(__name__) -ATTR_CITY = 'city' -ATTR_COUNTRY = 'country' -ATTR_POLLUTANT_SYMBOL = 'pollutant_symbol' -ATTR_POLLUTANT_UNIT = 'pollutant_unit' -ATTR_REGION = 'region' - -CONF_CITY = 'city' -CONF_COUNTRY = 'country' - -DEFAULT_ATTRIBUTION = "Data provided by AirVisual" -DEFAULT_SCAN_INTERVAL = timedelta(minutes=10) - -MASS_PARTS_PER_MILLION = 'ppm' -MASS_PARTS_PER_BILLION = 'ppb' -VOLUME_MICROGRAMS_PER_CUBIC_METER = 'µg/m3' - -SENSOR_TYPE_LEVEL = 'air_pollution_level' -SENSOR_TYPE_AQI = 'air_quality_index' -SENSOR_TYPE_POLLUTANT = 'main_pollutant' -SENSORS = [ - (SENSOR_TYPE_LEVEL, 'Air Pollution Level', 'mdi:gauge', None), - (SENSOR_TYPE_AQI, 'Air Quality Index', 'mdi:chart-line', 'AQI'), - (SENSOR_TYPE_POLLUTANT, 'Main Pollutant', 'mdi:chemical-weapon', None), +ATTR_CITY = "city" +ATTR_COUNTRY = "country" +ATTR_POLLUTANT_SYMBOL = "pollutant_symbol" +ATTR_POLLUTANT_UNIT = "pollutant_unit" +ATTR_REGION = "region" + +MASS_PARTS_PER_MILLION = "ppm" +MASS_PARTS_PER_BILLION = "ppb" +VOLUME_MICROGRAMS_PER_CUBIC_METER = "µg/m3" + +SENSOR_KIND_LEVEL = "air_pollution_level" +SENSOR_KIND_AQI = "air_quality_index" +SENSOR_KIND_POLLUTANT = "main_pollutant" +SENSOR_KIND_BATTERY_LEVEL = "battery_level" +SENSOR_KIND_HUMIDITY = "humidity" +SENSOR_KIND_TEMPERATURE = "temperature" + +GEOGRAPHY_SENSORS = [ + (SENSOR_KIND_LEVEL, "Air Pollution Level", "mdi:gauge", None), + (SENSOR_KIND_AQI, "Air Quality Index", "mdi:chart-line", "AQI"), + (SENSOR_KIND_POLLUTANT, "Main Pollutant", "mdi:chemical-weapon", None), ] +GEOGRAPHY_SENSOR_LOCALES = {"cn": "Chinese", "us": "U.S."} -POLLUTANT_LEVEL_MAPPING = [{ - 'label': 'Good', - 'icon': 'mdi:emoticon-excited', - 'minimum': 0, - 'maximum': 50 -}, { - 'label': 'Moderate', - 'icon': 'mdi:emoticon-happy', - 'minimum': 51, - 'maximum': 100 -}, { - 'label': 'Unhealthy for sensitive groups', - 'icon': 'mdi:emoticon-neutral', - 'minimum': 101, - 'maximum': 150 -}, { - 'label': 'Unhealthy', - 'icon': 'mdi:emoticon-sad', - 'minimum': 151, - 'maximum': 200 -}, { - 'label': 'Very Unhealthy', - 'icon': 'mdi:emoticon-dead', - 'minimum': 201, - 'maximum': 300 -}, { - 'label': 'Hazardous', - 'icon': 'mdi:biohazard', - 'minimum': 301, - 'maximum': 10000 -}] +NODE_PRO_SENSORS = [ + (SENSOR_KIND_BATTERY_LEVEL, "Battery", DEVICE_CLASS_BATTERY, UNIT_PERCENTAGE), + (SENSOR_KIND_HUMIDITY, "Humidity", DEVICE_CLASS_HUMIDITY, UNIT_PERCENTAGE), + (SENSOR_KIND_TEMPERATURE, "Temperature", DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS), +] -POLLUTANT_MAPPING = { - 'co': { - 'label': 'Carbon Monoxide', - 'unit': MASS_PARTS_PER_MILLION - }, - 'n2': { - 'label': 'Nitrogen Dioxide', - 'unit': MASS_PARTS_PER_BILLION - }, - 'o3': { - 'label': 'Ozone', - 'unit': MASS_PARTS_PER_BILLION - }, - 'p1': { - 'label': 'PM10', - 'unit': VOLUME_MICROGRAMS_PER_CUBIC_METER +POLLUTANT_LEVEL_MAPPING = [ + {"label": "Good", "icon": "mdi:emoticon-excited", "minimum": 0, "maximum": 50}, + {"label": "Moderate", "icon": "mdi:emoticon-happy", "minimum": 51, "maximum": 100}, + { + "label": "Unhealthy for sensitive groups", + "icon": "mdi:emoticon-neutral", + "minimum": 101, + "maximum": 150, }, - 'p2': { - 'label': 'PM2.5', - 'unit': VOLUME_MICROGRAMS_PER_CUBIC_METER - }, - 's2': { - 'label': 'Sulfur Dioxide', - 'unit': MASS_PARTS_PER_BILLION + {"label": "Unhealthy", "icon": "mdi:emoticon-sad", "minimum": 151, "maximum": 200}, + { + "label": "Very Unhealthy", + "icon": "mdi:emoticon-dead", + "minimum": 201, + "maximum": 300, }, + {"label": "Hazardous", "icon": "mdi:biohazard", "minimum": 301, "maximum": 10000}, +] + +POLLUTANT_MAPPING = { + "co": {"label": "Carbon Monoxide", "unit": CONCENTRATION_PARTS_PER_MILLION}, + "n2": {"label": "Nitrogen Dioxide", "unit": CONCENTRATION_PARTS_PER_BILLION}, + "o3": {"label": "Ozone", "unit": CONCENTRATION_PARTS_PER_BILLION}, + "p1": {"label": "PM10", "unit": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER}, + "p2": {"label": "PM2.5", "unit": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER}, + "s2": {"label": "Sulfur Dioxide", "unit": CONCENTRATION_PARTS_PER_BILLION}, } -SENSOR_LOCALES = {'cn': 'Chinese', 'us': 'U.S.'} - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_API_KEY): cv.string, - vol.Required(CONF_MONITORED_CONDITIONS, default=list(SENSOR_LOCALES)): - vol.All(cv.ensure_list, [vol.In(SENSOR_LOCALES)]), - vol.Inclusive(CONF_CITY, 'city'): cv.string, - vol.Inclusive(CONF_COUNTRY, 'city'): cv.string, - vol.Inclusive(CONF_LATITUDE, 'coords'): cv.latitude, - vol.Inclusive(CONF_LONGITUDE, 'coords'): cv.longitude, - vol.Optional(CONF_SHOW_ON_MAP, default=True): cv.boolean, - vol.Inclusive(CONF_STATE, 'city'): cv.string, - vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL): - cv.time_period -}) - - -async def async_setup_platform( - hass, config, async_add_entities, discovery_info=None): - """Configure the platform and add the sensors.""" - from pyairvisual import Client - - city = config.get(CONF_CITY) - state = config.get(CONF_STATE) - country = config.get(CONF_COUNTRY) - - latitude = config.get(CONF_LATITUDE, hass.config.latitude) - longitude = config.get(CONF_LONGITUDE, hass.config.longitude) - - websession = aiohttp_client.async_get_clientsession(hass) - - if city and state and country: - _LOGGER.debug( - "Using city, state, and country: %s, %s, %s", city, state, country) - location_id = ','.join((city, state, country)) - data = AirVisualData( - Client(websession, api_key=config[CONF_API_KEY]), - city=city, - state=state, - country=country, - show_on_map=config[CONF_SHOW_ON_MAP], - scan_interval=config[CONF_SCAN_INTERVAL]) + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up AirVisual sensors based on a config entry.""" + coordinator = hass.data[DOMAIN][DATA_COORDINATOR][config_entry.entry_id] + + if config_entry.data[CONF_INTEGRATION_TYPE] == INTEGRATION_TYPE_GEOGRAPHY: + sensors = [ + AirVisualGeographySensor( + coordinator, config_entry, kind, name, icon, unit, locale, + ) + for locale in GEOGRAPHY_SENSOR_LOCALES + for kind, name, icon, unit in GEOGRAPHY_SENSORS + ] else: - _LOGGER.debug( - "Using latitude and longitude: %s, %s", latitude, longitude) - location_id = ','.join((str(latitude), str(longitude))) - data = AirVisualData( - Client(websession, api_key=config[CONF_API_KEY]), - latitude=latitude, - longitude=longitude, - show_on_map=config[CONF_SHOW_ON_MAP], - scan_interval=config[CONF_SCAN_INTERVAL]) - - await data.async_update() - - sensors = [] - for locale in config[CONF_MONITORED_CONDITIONS]: - for kind, name, icon, unit in SENSORS: - sensors.append( - AirVisualSensor( - data, kind, name, icon, unit, locale, location_id)) + sensors = [ + AirVisualNodeProSensor(coordinator, kind, name, device_class, unit) + for kind, name, device_class, unit in NODE_PRO_SENSORS + ] async_add_entities(sensors, True) -class AirVisualSensor(Entity): - """Define an AirVisual sensor.""" +class AirVisualGeographySensor(AirVisualEntity): + """Define an AirVisual sensor related to geography data via the Cloud API.""" - def __init__(self, airvisual, kind, name, icon, unit, locale, location_id): + def __init__(self, coordinator, config_entry, kind, name, icon, unit, locale): """Initialize.""" - self._attrs = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} + super().__init__(coordinator) + + self._attrs.update( + { + ATTR_CITY: config_entry.data.get(CONF_CITY), + ATTR_STATE: config_entry.data.get(CONF_STATE), + ATTR_COUNTRY: config_entry.data.get(CONF_COUNTRY), + } + ) + self._config_entry = config_entry self._icon = icon + self._kind = kind self._locale = locale - self._location_id = location_id self._name = name self._state = None - self._type = kind self._unit = unit - self.airvisual = airvisual - - @property - def device_state_attributes(self): - """Return the device state attributes.""" - if self.airvisual.show_on_map: - self._attrs[ATTR_LATITUDE] = self.airvisual.latitude - self._attrs[ATTR_LONGITUDE] = self.airvisual.longitude - else: - self._attrs['lati'] = self.airvisual.latitude - self._attrs['long'] = self.airvisual.longitude - - return self._attrs @property def available(self): """Return True if entity is available.""" - return bool(self.airvisual.pollution_info) - - @property - def icon(self): - """Return the icon.""" - return self._icon + try: + return self.coordinator.last_update_success and bool( + self.coordinator.data["current"]["pollution"] + ) + except KeyError: + return False @property def name(self): """Return the name.""" - return '{0} {1}'.format(SENSOR_LOCALES[self._locale], self._name) + return f"{GEOGRAPHY_SENSOR_LOCALES[self._locale]} {self._name}" @property def state(self): @@ -213,82 +156,111 @@ def state(self): @property def unique_id(self): - """Return a unique, HASS-friendly identifier for this entity.""" - return '{0}_{1}_{2}'.format( - self._location_id, self._locale, self._type) + """Return a unique, Home Assistant friendly identifier for this entity.""" + return f"{self._config_entry.unique_id}_{self._locale}_{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.airvisual.async_update() - data = self.airvisual.pollution_info - - if not data: + @callback + def update_from_latest_data(self): + """Update the entity from the latest data.""" + try: + data = self.coordinator.data["current"]["pollution"] + except KeyError: return - if self._type == SENSOR_TYPE_LEVEL: - aqi = data['aqi{0}'.format(self._locale)] + if self._kind == SENSOR_KIND_LEVEL: + aqi = data[f"aqi{self._locale}"] [level] = [ - i for i in POLLUTANT_LEVEL_MAPPING - if i['minimum'] <= aqi <= i['maximum'] + i + for i in POLLUTANT_LEVEL_MAPPING + if i["minimum"] <= aqi <= i["maximum"] ] - self._state = level['label'] - self._icon = level['icon'] - elif self._type == SENSOR_TYPE_AQI: - self._state = data['aqi{0}'.format(self._locale)] - elif self._type == SENSOR_TYPE_POLLUTANT: - symbol = data['main{0}'.format(self._locale)] - self._state = POLLUTANT_MAPPING[symbol]['label'] - self._attrs.update({ - ATTR_POLLUTANT_SYMBOL: symbol, - ATTR_POLLUTANT_UNIT: POLLUTANT_MAPPING[symbol]['unit'] - }) - - -class AirVisualData: - """Define an object to hold sensor data.""" - - def __init__(self, client, **kwargs): + self._state = level["label"] + self._icon = level["icon"] + elif self._kind == SENSOR_KIND_AQI: + self._state = data[f"aqi{self._locale}"] + elif self._kind == SENSOR_KIND_POLLUTANT: + symbol = data[f"main{self._locale}"] + self._state = POLLUTANT_MAPPING[symbol]["label"] + self._attrs.update( + { + ATTR_POLLUTANT_SYMBOL: symbol, + ATTR_POLLUTANT_UNIT: POLLUTANT_MAPPING[symbol]["unit"], + } + ) + + if CONF_LATITUDE in self._config_entry.data: + if self._config_entry.options[CONF_SHOW_ON_MAP]: + self._attrs[ATTR_LATITUDE] = self._config_entry.data[CONF_LATITUDE] + self._attrs[ATTR_LONGITUDE] = self._config_entry.data[CONF_LONGITUDE] + self._attrs.pop("lati", None) + self._attrs.pop("long", None) + else: + self._attrs["lati"] = self._config_entry.data[CONF_LATITUDE] + self._attrs["long"] = self._config_entry.data[CONF_LONGITUDE] + self._attrs.pop(ATTR_LATITUDE, None) + self._attrs.pop(ATTR_LONGITUDE, None) + + +class AirVisualNodeProSensor(AirVisualEntity): + """Define an AirVisual sensor related to a Node/Pro unit.""" + + def __init__(self, coordinator, kind, name, device_class, unit): """Initialize.""" - self._client = client - self.city = kwargs.get(CONF_CITY) - self.country = kwargs.get(CONF_COUNTRY) - self.latitude = kwargs.get(CONF_LATITUDE) - self.longitude = kwargs.get(CONF_LONGITUDE) - self.pollution_info = {} - self.show_on_map = kwargs.get(CONF_SHOW_ON_MAP) - self.state = kwargs.get(CONF_STATE) - - self.async_update = Throttle( - kwargs[CONF_SCAN_INTERVAL])(self._async_update) - - async def _async_update(self): - """Update AirVisual data.""" - from pyairvisual.errors import AirVisualError + super().__init__(coordinator) - try: - if self.city and self.state and self.country: - resp = await self._client.api.city( - self.city, self.state, self.country) - self.longitude, self.latitude = resp['location']['coordinates'] - else: - resp = await self._client.api.nearest_city( - self.latitude, self.longitude) + self._device_class = device_class + self._kind = kind + self._name = name + self._state = None + self._unit = unit - _LOGGER.debug("New data retrieved: %s", resp) + @property + def device_class(self): + """Return the device class.""" + return self._device_class - self.pollution_info = resp['current']['pollution'] - except (KeyError, AirVisualError) as err: - if self.city and self.state and self.country: - location = (self.city, self.state, self.country) - else: - location = (self.latitude, self.longitude) + @property + def device_info(self): + """Return device registry information for this entity.""" + return { + "identifiers": { + (DOMAIN, self.coordinator.data["current"]["serial_number"]) + }, + "name": self.coordinator.data["current"]["settings"]["node_name"], + "manufacturer": "AirVisual", + "model": f'{self.coordinator.data["current"]["status"]["model"]}', + "sw_version": ( + f'Version {self.coordinator.data["current"]["status"]["system_version"]}' + f'{self.coordinator.data["current"]["status"]["app_version"]}' + ), + } - _LOGGER.error( - "Can't retrieve data for location: %s (%s)", location, - err) - self.pollution_info = {} + @property + def name(self): + """Return the name.""" + node_name = self.coordinator.data["current"]["settings"]["node_name"] + return f"{node_name} Node/Pro: {self._name}" + + @property + def state(self): + """Return the state.""" + return self._state + + @property + def unique_id(self): + """Return a unique, Home Assistant friendly identifier for this entity.""" + return f"{self.coordinator.data['current']['serial_number']}_{self._kind}" + + @callback + def update_from_latest_data(self): + """Update the entity from the latest data.""" + if self._kind == SENSOR_KIND_BATTERY_LEVEL: + self._state = self.coordinator.data["current"]["status"]["battery"] + elif self._kind == SENSOR_KIND_HUMIDITY: + self._state = self.coordinator.data["current"]["measurements"].get( + "humidity" + ) + elif self._kind == SENSOR_KIND_TEMPERATURE: + self._state = self.coordinator.data["current"]["measurements"].get( + "temperature_C" + ) diff --git a/homeassistant/components/airvisual/strings.json b/homeassistant/components/airvisual/strings.json new file mode 100644 index 0000000000000..8b9978b611fda --- /dev/null +++ b/homeassistant/components/airvisual/strings.json @@ -0,0 +1,50 @@ +{ + "config": { + "step": { + "geography": { + "title": "Configure a Geography", + "description": "Use the AirVisual cloud API to monitor a geographical location.", + "data": { + "api_key": "API Key", + "latitude": "Latitude", + "longitude": "Longitude" + } + }, + "node_pro": { + "title": "Configure an AirVisual Node/Pro", + "description": "Monitor a personal AirVisual unit. The password can be retrieved from the unit's UI.", + "data": { + "ip_address": "Unit IP Address/Hostname", + "password": "Unit Password" + } + }, + "user": { + "title": "Configure AirVisual", + "description": "Pick what type of AirVisual data you want to monitor.", + "data": { + "cloud_api": "Geographical Location", + "node_pro": "AirVisual Node Pro", + "type": "Integration Type" + } + } + }, + "error": { + "general_error": "There was an unknown error.", + "invalid_api_key": "Invalid API key provided.", + "unable_to_connect": "Unable to connect to Node/Pro unit." + }, + "abort": { + "already_configured": "These coordinates or Node/Pro ID are already registered." + } + }, + "options": { + "step": { + "init": { + "title": "Configure AirVisual", + "data": { + "show_on_map": "Show monitored geography on the map" + } + } + } + } +} diff --git a/homeassistant/components/airvisual/translations/ca.json b/homeassistant/components/airvisual/translations/ca.json new file mode 100644 index 0000000000000..2d9a644c70491 --- /dev/null +++ b/homeassistant/components/airvisual/translations/ca.json @@ -0,0 +1,54 @@ +{ + "config": { + "abort": { + "already_configured": "Aquesta clau API ja est\u00e0 sent utilitzada." + }, + "error": { + "general_error": "S'ha produ\u00eft un error desconegut.", + "invalid_api_key": "Clau API inv\u00e0lida", + "unable_to_connect": "No s'ha pogut connectar a la unitat Node/Pro." + }, + "step": { + "geography": { + "data": { + "api_key": "Clau API", + "latitude": "Latitud", + "longitude": "Longitud" + }, + "description": "Utilitza l'API d'AirVisual per monitoritzar una ubicaci\u00f3 geogr\u00e0fica.", + "title": "Configuraci\u00f3 localitzaci\u00f3 geogr\u00e0fica" + }, + "node_pro": { + "data": { + "ip_address": "Adre\u00e7a IP o amfitri\u00f3 de la unitat", + "password": "Contrasenya de la unitat" + }, + "description": "Monitoritza una unitat personal d'AirVisual. Pots obtenir la contrasenya des de la interf\u00edcie d'usuari (UI) de la unitat.", + "title": "Configuraci\u00f3 d'AirVisual Node/Pro" + }, + "user": { + "data": { + "api_key": "Clau API", + "cloud_api": "Ubicaci\u00f3 geogr\u00e0fica", + "latitude": "Latitud", + "longitude": "Longitud", + "node_pro": "AirVisual Node Pro", + "type": "Tipus d'integraci\u00f3" + }, + "description": "Monitoritzaci\u00f3 de la qualitat de l'aire per ubicaci\u00f3 geogr\u00e0fica.", + "title": "Configura AirVisual" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "show_on_map": "Mostra al mapa l'\u00e0rea geogr\u00e0fica monitoritzada" + }, + "description": "Estableix les diferents opcions de la integraci\u00f3 AirVisual.", + "title": "Configuraci\u00f3 d'AirVisual" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/de.json b/homeassistant/components/airvisual/translations/de.json new file mode 100644 index 0000000000000..5e66daa391915 --- /dev/null +++ b/homeassistant/components/airvisual/translations/de.json @@ -0,0 +1,53 @@ +{ + "config": { + "abort": { + "already_configured": "Diese Koordinaten oder Node/Pro ID sind bereits registriert." + }, + "error": { + "general_error": "Es gab einen unbekannten Fehler.", + "invalid_api_key": "Ung\u00fcltiger API-Schl\u00fcssel bereitgestellt.", + "unable_to_connect": "Verbindung zum Node/Pro-Ger\u00e4t nicht m\u00f6glich." + }, + "step": { + "geography": { + "data": { + "api_key": "API-Schl\u00fcssel", + "latitude": "Breitengrad", + "longitude": "L\u00e4ngengrad" + }, + "title": "Konfigurieren Sie eine Geografie" + }, + "node_pro": { + "data": { + "ip_address": "IP-Adresse/Hostname des Ger\u00e4ts", + "password": "Ger\u00e4tekennwort" + }, + "description": "\u00dcberwachen Sie eine pers\u00f6nliche AirVisual-Einheit. Das Passwort kann von der Benutzeroberfl\u00e4che des Ger\u00e4ts abgerufen werden.", + "title": "Konfigurieren Sie einen AirVisual Node/Pro" + }, + "user": { + "data": { + "api_key": "API-Schl\u00fcssel", + "cloud_api": "Geografische Position", + "latitude": "Breitengrad", + "longitude": "L\u00e4ngengrad", + "node_pro": "AirVisual Node Pro", + "type": "Integrationstyp" + }, + "description": "W\u00e4hlen Sie aus, welche Art von AirVisual-Daten Sie \u00fcberwachen m\u00f6chten.", + "title": "Konfigurieren Sie AirVisual" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "show_on_map": "Zeigen Sie die \u00fcberwachte Geografie auf der Karte an" + }, + "description": "Legen Sie verschiedene Optionen f\u00fcr die AirVisual-Integration fest.", + "title": "Konfigurieren Sie AirVisual" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/en.json b/homeassistant/components/airvisual/translations/en.json new file mode 100644 index 0000000000000..842eaaaa1de1d --- /dev/null +++ b/homeassistant/components/airvisual/translations/en.json @@ -0,0 +1,54 @@ +{ + "config": { + "abort": { + "already_configured": "These coordinates or Node/Pro ID are already registered." + }, + "error": { + "general_error": "There was an unknown error.", + "invalid_api_key": "Invalid API key provided.", + "unable_to_connect": "Unable to connect to Node/Pro unit." + }, + "step": { + "geography": { + "data": { + "api_key": "API Key", + "latitude": "Latitude", + "longitude": "Longitude" + }, + "description": "Use the AirVisual cloud API to monitor a geographical location.", + "title": "Configure a Geography" + }, + "node_pro": { + "data": { + "ip_address": "Unit IP Address/Hostname", + "password": "Unit Password" + }, + "description": "Monitor a personal AirVisual unit. The password can be retrieved from the unit's UI.", + "title": "Configure an AirVisual Node/Pro" + }, + "user": { + "data": { + "api_key": "API Key", + "cloud_api": "Geographical Location", + "latitude": "Latitude", + "longitude": "Longitude", + "node_pro": "AirVisual Node Pro", + "type": "Integration Type" + }, + "description": "Pick what type of AirVisual data you want to monitor.", + "title": "Configure AirVisual" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "show_on_map": "Show monitored geography on the map" + }, + "description": "Set various options for the AirVisual integration.", + "title": "Configure AirVisual" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/es-419.json b/homeassistant/components/airvisual/translations/es-419.json new file mode 100644 index 0000000000000..aea8c1ad57403 --- /dev/null +++ b/homeassistant/components/airvisual/translations/es-419.json @@ -0,0 +1,49 @@ +{ + "config": { + "abort": { + "already_configured": "Estas coordenadas ya han sido registradas." + }, + "error": { + "invalid_api_key": "Clave de API inv\u00e1lida" + }, + "step": { + "geography": { + "data": { + "api_key": "Clave API", + "latitude": "Latitud", + "longitude": "Longitud" + }, + "title": "Configurar una geograf\u00eda" + }, + "node_pro": { + "data": { + "password": "Contrase\u00f1a de la unidad" + }, + "title": "Configurar un AirVisual Node/Pro" + }, + "user": { + "data": { + "api_key": "Clave API", + "cloud_api": "Localizaci\u00f3n geogr\u00e1fica", + "latitude": "Latitud", + "longitude": "Longitud", + "node_pro": "AirVisual Node Pro", + "type": "Tipo de integraci\u00f3n" + }, + "description": "Monitoree la calidad del aire en una ubicaci\u00f3n geogr\u00e1fica.", + "title": "Configurar AirVisual" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "show_on_map": "Mostrar geograf\u00eda monitoreada en el mapa" + }, + "description": "Establezca varias opciones para la integraci\u00f3n de AirVisual.", + "title": "Configurar AirVisual" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/es.json b/homeassistant/components/airvisual/translations/es.json new file mode 100644 index 0000000000000..e64b2f3169189 --- /dev/null +++ b/homeassistant/components/airvisual/translations/es.json @@ -0,0 +1,54 @@ +{ + "config": { + "abort": { + "already_configured": "Esta clave API ya est\u00e1 en uso." + }, + "error": { + "general_error": "Se ha producido un error desconocido.", + "invalid_api_key": "Clave API inv\u00e1lida", + "unable_to_connect": "No se puede conectar a la unidad Node/Pro." + }, + "step": { + "geography": { + "data": { + "api_key": "Clave API", + "latitude": "Latitud", + "longitude": "Longitud" + }, + "description": "Utilizar la API en la nube de AirVisual para monitorizar una ubicaci\u00f3n geogr\u00e1fica.", + "title": "Configurar una Geograf\u00eda" + }, + "node_pro": { + "data": { + "ip_address": "Direcci\u00f3n IP/Nombre de host de la Unidad", + "password": "Contrase\u00f1a de la Unidad" + }, + "description": "Monitorizar una unidad personal AirVisual. La contrase\u00f1a puede ser recuperada desde la interfaz de la unidad.", + "title": "Configurar un AirVisual Node/Pro" + }, + "user": { + "data": { + "api_key": "Clave API", + "cloud_api": "Ubicaci\u00f3n Geogr\u00e1fica", + "latitude": "Latitud", + "longitude": "Longitud", + "node_pro": "AirVisual Node Pro", + "type": "Tipo de Integraci\u00f3n" + }, + "description": "Monitorizar la calidad del aire en una ubicaci\u00f3n geogr\u00e1fica.", + "title": "Configurar AirVisual" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "show_on_map": "Mostrar geograf\u00eda monitorizada en el mapa" + }, + "description": "Ajustar varias opciones para la integraci\u00f3n de AirVisual.", + "title": "Configurar AirVisual" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/fi.json b/homeassistant/components/airvisual/translations/fi.json new file mode 100644 index 0000000000000..51426854fdb4f --- /dev/null +++ b/homeassistant/components/airvisual/translations/fi.json @@ -0,0 +1,24 @@ +{ + "config": { + "step": { + "geography": { + "data": { + "api_key": "API-avain", + "latitude": "Leveysaste", + "longitude": "Pituusaste" + } + }, + "node_pro": { + "data": { + "password": "Salasana" + } + }, + "user": { + "data": { + "cloud_api": "Maantieteellinen sijainti", + "type": "Integrointityyppi" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/fr.json b/homeassistant/components/airvisual/translations/fr.json new file mode 100644 index 0000000000000..d2013b0f17dd5 --- /dev/null +++ b/homeassistant/components/airvisual/translations/fr.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "Cette cl\u00e9 API est d\u00e9j\u00e0 utilis\u00e9e." + }, + "error": { + "invalid_api_key": "Cl\u00e9 API invalide" + }, + "step": { + "geography": { + "data": { + "api_key": "Cl\u00e9 d'API", + "latitude": "Latitude", + "longitude": "Longitude" + } + }, + "user": { + "data": { + "api_key": "Cl\u00e9 API", + "latitude": "Latitude", + "longitude": "Longitude" + }, + "description": "Surveiller la qualit\u00e9 de l\u2019air dans un emplacement g\u00e9ographique.", + "title": "Configurer AirVisual" + } + } + }, + "options": { + "step": { + "init": { + "description": "D\u00e9finissez diverses options pour l'int\u00e9gration d'AirVisual.", + "title": "Configurer AirVisual" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/hi.json b/homeassistant/components/airvisual/translations/hi.json new file mode 100644 index 0000000000000..ff9c8ebe20660 --- /dev/null +++ b/homeassistant/components/airvisual/translations/hi.json @@ -0,0 +1,34 @@ +{ + "config": { + "error": { + "general_error": "\u0915\u094b\u0908 \u0905\u091c\u094d\u091e\u093e\u0924 \u0924\u094d\u0930\u0941\u091f\u093f \u0925\u0940\u0964", + "unable_to_connect": "\u0928\u094b\u0921 / \u092a\u094d\u0930\u094b \u0907\u0915\u093e\u0908 \u0938\u0947 \u0915\u0928\u0947\u0915\u094d\u091f \u0915\u0930\u0928\u0947 \u092e\u0947\u0902 \u0905\u0938\u092e\u0930\u094d\u0925\u0964" + }, + "step": { + "geography": { + "data": { + "api_key": "\u090f\u092a\u0940\u0906\u0908 \u0915\u0941\u0902\u091c\u0940", + "latitude": "\u0905\u0915\u094d\u0937\u093e\u0902\u0936", + "longitude": "\u0926\u0947\u0936\u093e\u0928\u094d\u0924\u0930" + }, + "description": "\u092d\u094c\u0917\u094b\u0932\u093f\u0915 \u0938\u094d\u0925\u093f\u0924\u093f \u0915\u0940 \u0928\u093f\u0917\u0930\u093e\u0928\u0940 \u0915\u0947 \u0932\u093f\u090f \u090f\u092f\u0930\u0935\u093f\u091c\u0941\u0905\u0932 \u0915\u094d\u0932\u093e\u0909\u0921 \u090f\u092a\u0940\u0906\u0908 \u0915\u093e \u0909\u092a\u092f\u094b\u0917 \u0915\u0930\u0947\u0902\u0964", + "title": "\u092d\u0942\u0917\u094b\u0932 \u0915\u0949\u0928\u094d\u092b\u093c\u093f\u0917\u0930 \u0915\u0930\u0947\u0902" + }, + "node_pro": { + "data": { + "ip_address": "\u0907\u0915\u093e\u0908 \u0915\u0947 \u0906\u0908\u092a\u0940 \u092a\u0924\u0947/\u0939\u094b\u0938\u094d\u091f\u0928\u093e\u092e", + "password": "\u0907\u0915\u093e\u0908 \u092a\u093e\u0938\u0935\u0930\u094d\u0921" + }, + "description": "\u090f\u0915 \u0935\u094d\u092f\u0915\u094d\u0924\u093f\u0917\u0924 \u090f\u092f\u0930\u0935\u093f\u091c\u0941\u0905\u0932 \u0907\u0915\u093e\u0908 \u0915\u0940 \u0928\u093f\u0917\u0930\u093e\u0928\u0940 \u0915\u0930\u0947\u0902\u0964 \u092a\u093e\u0938\u0935\u0930\u094d\u0921 \u092f\u0942\u0928\u093f\u091f \u0915\u0947 \u092f\u0942\u0906\u0908 \u0938\u0947 \u092a\u094d\u0930\u093e\u092a\u094d\u0924 \u0915\u093f\u092f\u093e \u091c\u093e \u0938\u0915\u0924\u093e \u0939\u0948\u0964", + "title": "\u090f\u092f\u0930\u0935\u093f\u091c\u0941\u0905\u0932 \u0928\u094b\u0921 \u092a\u094d\u0930\u094b" + }, + "user": { + "data": { + "cloud_api": "\u092d\u094c\u0917\u094b\u0932\u093f\u0915 \u0938\u094d\u0925\u093f\u0924\u093f", + "node_pro": "\u090f\u092f\u0930\u0935\u093f\u091c\u0941\u0905\u0932 \u0928\u094b\u0921 \u092a\u094d\u0930\u094b", + "type": "\u090f\u0915\u0940\u0915\u0930\u0923 \u092a\u094d\u0930\u0915\u093e\u0930" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/hu.json b/homeassistant/components/airvisual/translations/hu.json new file mode 100644 index 0000000000000..f1c4dadef1130 --- /dev/null +++ b/homeassistant/components/airvisual/translations/hu.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "general_error": "Ismeretlen hiba t\u00f6rt\u00e9nt." + }, + "step": { + "geography": { + "data": { + "latitude": "Sz\u00e9less\u00e9g", + "longitude": "Hossz\u00fas\u00e1g" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/it.json b/homeassistant/components/airvisual/translations/it.json new file mode 100644 index 0000000000000..9831011c7b22d --- /dev/null +++ b/homeassistant/components/airvisual/translations/it.json @@ -0,0 +1,54 @@ +{ + "config": { + "abort": { + "already_configured": "Queste coordinate o Node/Pro ID sono gi\u00e0 registrate." + }, + "error": { + "general_error": "Si \u00e8 verificato un errore sconosciuto.", + "invalid_api_key": "Chiave API non valida fornita.", + "unable_to_connect": "Impossibile connettersi all'unit\u00e0 Node/Pro." + }, + "step": { + "geography": { + "data": { + "api_key": "Chiave API", + "latitude": "Latitudine", + "longitude": "Logitudine" + }, + "description": "Utilizzare l'API di AirVisual cloud per monitorare una posizione geografica.", + "title": "Configurare una Geografia" + }, + "node_pro": { + "data": { + "ip_address": "Indirizzo IP/Nome host dell'unit\u00e0", + "password": "Password dell'unit\u00e0" + }, + "description": "Monitorare un'unit\u00e0 AirVisual personale. La password pu\u00f2 essere recuperata dall'interfaccia utente dell'unit\u00e0.", + "title": "Configurare un AirVisual Node/Pro" + }, + "user": { + "data": { + "api_key": "Chiave API", + "cloud_api": "Posizione geografica", + "latitude": "Latitudine", + "longitude": "Logitudine", + "node_pro": "AirVisual Node Pro", + "type": "Tipo di integrazione" + }, + "description": "Scegliere il tipo di dati AirVisual che si desidera monitorare.", + "title": "Configura AirVisual" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "show_on_map": "Mostra l'area geografica monitorata sulla mappa" + }, + "description": "Impostare varie opzioni per l'integrazione AirVisual.", + "title": "Configurare AirVisual" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/ko.json b/homeassistant/components/airvisual/translations/ko.json new file mode 100644 index 0000000000000..d88e58d3ea62f --- /dev/null +++ b/homeassistant/components/airvisual/translations/ko.json @@ -0,0 +1,54 @@ +{ + "config": { + "abort": { + "already_configured": "\uc88c\ud45c\uac12 \ub610\ub294 Node/Pro ID \uac00 \uc774\ubbf8 \ub4f1\ub85d\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + }, + "error": { + "general_error": "\uc54c \uc218 \uc5c6\ub294 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4.", + "invalid_api_key": "\uc798\ubabb\ub41c API \ud0a4", + "unable_to_connect": "AirVisual Node/Pro \uae30\uae30\uc5d0 \uc5f0\uacb0\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4." + }, + "step": { + "geography": { + "data": { + "api_key": "API \ud0a4", + "latitude": "\uc704\ub3c4", + "longitude": "\uacbd\ub3c4" + }, + "description": "AirVisual \ud074\ub77c\uc6b0\ub4dc API \ub97c \uc0ac\uc6a9\ud558\uc5ec \uc9c0\ub9ac\uc801 \uc704\uce58\ub97c \ubaa8\ub2c8\ud130\ub9c1\ud569\ub2c8\ub2e4.", + "title": "\uc9c0\ub9ac\uc801 \uc704\uce58 \uad6c\uc131\ud558\uae30" + }, + "node_pro": { + "data": { + "ip_address": "\uae30\uae30 IP \uc8fc\uc18c/\ud638\uc2a4\ud2b8 \uc774\ub984", + "password": "\uae30\uae30 \ube44\ubc00\ubc88\ud638" + }, + "description": "\uc0ac\uc6a9\uc790\uc758 AirVisual \uae30\uae30\ub97c \ubaa8\ub2c8\ud130\ub9c1\ud569\ub2c8\ub2e4. \uae30\uae30\uc758 UI \uc5d0\uc11c \ube44\ubc00\ubc88\ud638\ub97c \ucc3e\uc744 \uc218 \uc788\uc2b5\ub2c8\ub2e4.", + "title": "AirVisual Node/Pro \uad6c\uc131\ud558\uae30" + }, + "user": { + "data": { + "api_key": "API \ud0a4", + "cloud_api": "\uc9c0\ub9ac\uc801 \uc704\uce58", + "latitude": "\uc704\ub3c4", + "longitude": "\uacbd\ub3c4", + "node_pro": "AirVisual Node Pro", + "type": "\uc5f0\ub3d9 \uc720\ud615" + }, + "description": "\ubaa8\ub2c8\ud130\ub9c1\ud560 AirVisual \ub370\uc774\ud130 \uc720\ud615\uc744 \uc120\ud0dd\ud574\uc8fc\uc138\uc694.", + "title": "AirVisual \uad6c\uc131\ud558\uae30" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "show_on_map": "\uc9c0\ub3c4\uc5d0 \ubaa8\ub2c8\ud130\ub9c1\ub41c \uc9c0\ub9ac \uc815\ubcf4 \ud45c\uc2dc" + }, + "description": "AirVisual \ud1b5\ud569 \uad6c\uc131\uc694\uc18c\uc5d0 \ub300\ud55c \ub2e4\uc591\ud55c \uc635\uc158\uc744 \uc124\uc815\ud574\uc8fc\uc138\uc694.", + "title": "AirVisual \uad6c\uc131\ud558\uae30" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/lb.json b/homeassistant/components/airvisual/translations/lb.json new file mode 100644 index 0000000000000..52e55242a050f --- /dev/null +++ b/homeassistant/components/airvisual/translations/lb.json @@ -0,0 +1,54 @@ +{ + "config": { + "abort": { + "already_configured": "D\u00ebs Koordinate oder ode/Pro ID si schon registr\u00e9iert." + }, + "error": { + "general_error": "Onbekannten Feeler", + "invalid_api_key": "Ong\u00ebltegen API Schl\u00ebssel uginn", + "unable_to_connect": "Kann sech net mat der Node/Pri verbannen." + }, + "step": { + "geography": { + "data": { + "api_key": "API Schl\u00ebssel", + "latitude": "Breedegrad", + "longitude": "L\u00e4ngegrad" + }, + "description": "Benotz Airvisual cloud API fir eng geografescher Lag z'iwwerwaachen.", + "title": "Geografie ariichten" + }, + "node_pro": { + "data": { + "ip_address": "IP Adresse / Numm vun der Unit\u00e9it", + "password": "Passwuert vun der Unit\u00e9it" + }, + "description": "Pers\u00e9inlech Airvisual Unit\u00e9it iwwerwaachen. Passwuert kann vum UI vum Apparat ausgelies ginn.", + "title": "Airvisual Node/Pro ariichten" + }, + "user": { + "data": { + "api_key": "API Schl\u00ebssel", + "cloud_api": "Geografesche Standuert", + "latitude": "Breedegrad", + "longitude": "L\u00e4ngegrad", + "node_pro": "Airvisual Node Pro", + "type": "Typ vun der Integratioun" + }, + "description": "Loft Qualit\u00e9it an enger geografescher Lag iwwerwaachen.", + "title": "AirVisual konfigur\u00e9ieren" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "show_on_map": "Iwwerwaachte Geografie op der Kaart uweisen" + }, + "description": "Verschidden Optioune fir d'AirVisual Integratioun d\u00e9fin\u00e9ieren.", + "title": "Airvisual ariichten" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/nl.json b/homeassistant/components/airvisual/translations/nl.json new file mode 100644 index 0000000000000..97b083b91fa5f --- /dev/null +++ b/homeassistant/components/airvisual/translations/nl.json @@ -0,0 +1,54 @@ +{ + "config": { + "abort": { + "already_configured": "Deze co\u00f6rdinaten of Node / Pro ID zijn al geregistreerd." + }, + "error": { + "general_error": "Er is een onbekende fout opgetreden.", + "invalid_api_key": "Ongeldige API-sleutel opgegeven.", + "unable_to_connect": "Kan geen verbinding maken met Node / Pro-apparaat." + }, + "step": { + "geography": { + "data": { + "api_key": "API-sleutel", + "latitude": "Breedtegraad", + "longitude": "Lengtegraad" + }, + "description": "Gebruik de AirVisual cloud API om een geografische locatie te bewaken.", + "title": "Configureer een geografie" + }, + "node_pro": { + "data": { + "ip_address": "IP adres/hostname van unit", + "password": "Wachtwoord van unit" + }, + "description": "Monitor een persoonlijke AirVisual-eenheid. Het wachtwoord kan worden opgehaald uit de gebruikersinterface van het apparaat.", + "title": "Configureer een AirVisual Node / Pro" + }, + "user": { + "data": { + "api_key": "API-sleutel", + "cloud_api": "Geografische ligging", + "latitude": "Breedtegraad", + "longitude": "Lengtegraad", + "node_pro": "AirVisual Node Pro", + "type": "Integratietype" + }, + "description": "Kies welk type AirVisual-gegevens u wilt bewaken.", + "title": "Configureer AirVisual" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "show_on_map": "Toon gecontroleerde geografie op de kaart" + }, + "description": "Stel verschillende opties in voor de AirVisual-integratie.", + "title": "Configureer AirVisual" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/no.json b/homeassistant/components/airvisual/translations/no.json new file mode 100644 index 0000000000000..652c4fe1d7672 --- /dev/null +++ b/homeassistant/components/airvisual/translations/no.json @@ -0,0 +1,54 @@ +{ + "config": { + "abort": { + "already_configured": "Disse koordinatene eller Node / Pro ID er allerede registrert." + }, + "error": { + "general_error": "Det oppstod en ukjent feil.", + "invalid_api_key": "Ugyldig API-n\u00f8kkel", + "unable_to_connect": "Kan ikke koble til Node / Pro-enheten." + }, + "step": { + "geography": { + "data": { + "api_key": "API-n\u00f8kkel", + "latitude": "Breddegrad", + "longitude": "Lengdegrad" + }, + "description": "Bruk AirVisual cloud API til \u00e5 overv\u00e5ke en geografisk plassering.", + "title": "Konfigurer en Geography" + }, + "node_pro": { + "data": { + "ip_address": "Enhetens IP-adresse / vertsnavn", + "password": "Passord for enhet" + }, + "description": "Overv\u00e5ke en personlig AirVisual-enhet. Passordet kan hentes fra enhetens brukergrensesnitt.", + "title": "Konfigurer en AirVisual Node / Pro" + }, + "user": { + "data": { + "api_key": "API-n\u00f8kkel", + "cloud_api": "Geografisk plassering", + "latitude": "Breddegrad", + "longitude": "Lengdegrad", + "node_pro": "", + "type": "Integrasjonstype" + }, + "description": "Velg hvilken type AirVisual-data du vil overv\u00e5ke.", + "title": "Konfigurer AirVisual" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "show_on_map": "Vis overv\u00e5ket geografi p\u00e5 kartet" + }, + "description": "Angi forskjellige alternativer for AirVisual-integrasjonen.", + "title": "Konfigurer AirVisual" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/pl.json b/homeassistant/components/airvisual/translations/pl.json new file mode 100644 index 0000000000000..6df8d8fb623b6 --- /dev/null +++ b/homeassistant/components/airvisual/translations/pl.json @@ -0,0 +1,54 @@ +{ + "config": { + "abort": { + "already_configured": "Ten klucz API jest ju\u017c w u\u017cyciu." + }, + "error": { + "general_error": "Nieznany b\u0142\u0105d", + "invalid_api_key": "Nieprawid\u0142owy klucz API", + "unable_to_connect": "Nie mo\u017cna po\u0142\u0105czy\u0107 si\u0119 z jednostk\u0105 Node/Pro." + }, + "step": { + "geography": { + "data": { + "api_key": "Klucz API", + "latitude": "Szeroko\u015b\u0107 geograficzna", + "longitude": "D\u0142ugo\u015b\u0107 geograficzna" + }, + "description": "U\u017cyj interfejsu API chmury AirVisual do monitorowania lokalizacji geograficznej.", + "title": "Konfiguracja Geography" + }, + "node_pro": { + "data": { + "ip_address": "Nazwa hosta lub adres IP jednostki", + "password": "Has\u0142o jednostki" + }, + "description": "Has\u0142o", + "title": "Konfiguracja AirVisual Node/Pro" + }, + "user": { + "data": { + "api_key": "Klucz API", + "cloud_api": "Lokalizacja geograficzna", + "latitude": "Szeroko\u015b\u0107 geograficzna", + "longitude": "D\u0142ugo\u015b\u0107 geograficzna", + "node_pro": "AirVisual Node Pro", + "type": "Typ integracji" + }, + "description": "Monitoruj jako\u015b\u0107 powietrza w okre\u015blonej lokalizacji geograficznej.", + "title": "Konfiguracja AirVisual" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "show_on_map": "Wy\u015bwietlaj encje na mapie" + }, + "description": "Konfiguracja opcji integracji AirVisual.", + "title": "Konfiguracja AirVisual" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/ru.json b/homeassistant/components/airvisual/translations/ru.json new file mode 100644 index 0000000000000..ecc8999fd1849 --- /dev/null +++ b/homeassistant/components/airvisual/translations/ru.json @@ -0,0 +1,54 @@ +{ + "config": { + "abort": { + "already_configured": "\u041c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u0443\u0436\u0435 \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u043d\u043e." + }, + "error": { + "general_error": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430.", + "invalid_api_key": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043a\u043b\u044e\u0447 API.", + "unable_to_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443." + }, + "step": { + "geography": { + "data": { + "api_key": "\u041a\u043b\u044e\u0447 API", + "latitude": "\u0428\u0438\u0440\u043e\u0442\u0430", + "longitude": "\u0414\u043e\u043b\u0433\u043e\u0442\u0430" + }, + "description": "\u041c\u043e\u043d\u0438\u0442\u043e\u0440\u0438\u043d\u0433 \u043c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0441 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u043c \u043e\u0431\u043b\u0430\u0447\u043d\u043e\u0433\u043e API AirVisual.", + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u044f" + }, + "node_pro": { + "data": { + "ip_address": "IP-\u0430\u0434\u0440\u0435\u0441/\u0418\u043c\u044f \u0445\u043e\u0441\u0442\u0430", + "password": "\u041f\u0430\u0440\u043e\u043b\u044c" + }, + "description": "\u041c\u043e\u043d\u0438\u0442\u043e\u0440\u0438\u043d\u0433 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 AirVisual. \u041f\u0430\u0440\u043e\u043b\u044c \u043c\u043e\u0436\u043d\u043e \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u0432 \u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432.", + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 AirVisual Node / Pro" + }, + "user": { + "data": { + "api_key": "\u041a\u043b\u044e\u0447 API", + "cloud_api": "\u041c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435", + "latitude": "\u0428\u0438\u0440\u043e\u0442\u0430", + "longitude": "\u0414\u043e\u043b\u0433\u043e\u0442\u0430", + "node_pro": "AirVisual Node Pro", + "type": "\u0422\u0438\u043f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438" + }, + "description": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0442\u0438\u043f \u0434\u0430\u043d\u043d\u044b\u0445 AirVisual, \u043a\u043e\u0442\u043e\u0440\u044b\u0439 \u0412\u044b \u0445\u043e\u0442\u0438\u0442\u0435 \u043e\u0442\u0441\u043b\u0435\u0436\u0438\u0432\u0430\u0442\u044c.", + "title": "AirVisual" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "show_on_map": "\u041f\u043e\u043a\u0430\u0437\u044b\u0432\u0430\u0442\u044c \u043e\u0442\u0441\u043b\u0435\u0436\u0438\u0432\u0430\u0435\u043c\u0443\u044e \u043e\u0431\u043b\u0430\u0441\u0442\u044c \u043d\u0430 \u043a\u0430\u0440\u0442\u0435" + }, + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 AirVisual.", + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 AirVisual" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/sk.json b/homeassistant/components/airvisual/translations/sk.json new file mode 100644 index 0000000000000..e6945904d9030 --- /dev/null +++ b/homeassistant/components/airvisual/translations/sk.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "latitude": "Zemepisn\u00e1 \u0161\u00edrka", + "longitude": "Zemepisn\u00e1 d\u013a\u017eka" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/sl.json b/homeassistant/components/airvisual/translations/sl.json new file mode 100644 index 0000000000000..376da5c390014 --- /dev/null +++ b/homeassistant/components/airvisual/translations/sl.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "already_configured": "Te koordinate so \u017ee registrirane." + }, + "error": { + "invalid_api_key": "Neveljaven API klju\u010d" + }, + "step": { + "user": { + "data": { + "api_key": "API Klju\u010d", + "latitude": "Zemljepisna \u0161irina", + "longitude": "Zemljepisna dol\u017eina" + }, + "description": "Spremljajte kakovost zraka na zemljepisni lokaciji.", + "title": "Nastavite AirVisual" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "show_on_map": "Prika\u017ei nadzorovano obmo\u010dje na zemljevidu" + }, + "description": "Nastavite razli\u010dne mo\u017enosti za integracijo AirVisual.", + "title": "Nastavite AirVisual" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/sv.json b/homeassistant/components/airvisual/translations/sv.json new file mode 100644 index 0000000000000..12911fa76d7e9 --- /dev/null +++ b/homeassistant/components/airvisual/translations/sv.json @@ -0,0 +1,31 @@ +{ + "config": { + "error": { + "general_error": "Ett ok\u00e4nt fel intr\u00e4ffade." + }, + "step": { + "geography": { + "data": { + "api_key": "API-nyckel", + "latitude": "Latitud", + "longitude": "Longitud" + } + }, + "node_pro": { + "data": { + "ip_address": "Enhets IP-adress / v\u00e4rdnamn", + "password": "Enhetsl\u00f6senord" + } + }, + "user": { + "data": { + "api_key": "API-nyckel", + "cloud_api": "Geografisk Plats", + "latitude": "Latitud", + "longitude": "Longitud", + "type": "Integrationstyp" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/vi.json b/homeassistant/components/airvisual/translations/vi.json new file mode 100644 index 0000000000000..6246d8997da46 --- /dev/null +++ b/homeassistant/components/airvisual/translations/vi.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "type": "Lo\u1ea1i t\u00edch h\u1ee3p" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/zh-Hant.json b/homeassistant/components/airvisual/translations/zh-Hant.json new file mode 100644 index 0000000000000..1153d7c3b99a5 --- /dev/null +++ b/homeassistant/components/airvisual/translations/zh-Hant.json @@ -0,0 +1,54 @@ +{ + "config": { + "abort": { + "already_configured": "\u6b64\u5ea7\u6a19\u6216 Node/Pro ID \u5df2\u8a3b\u518a\u3002" + }, + "error": { + "general_error": "\u767c\u751f\u672a\u77e5\u932f\u8aa4\u3002", + "invalid_api_key": "API \u5bc6\u78bc\u7121\u6548\u3002", + "unable_to_connect": "\u7121\u6cd5\u9023\u7dda\u81f3 Node/Pro \u8a2d\u5099\u3002" + }, + "step": { + "geography": { + "data": { + "api_key": "API \u5bc6\u9470", + "latitude": "\u7def\u5ea6", + "longitude": "\u7d93\u5ea6" + }, + "description": "\u4f7f\u7528 AirVisual \u96f2\u7aef API \u4ee5\u76e3\u63a7\u5730\u7406\u5ea7\u6a19\u3002", + "title": "\u8a2d\u5b9a\u5730\u7406\u5ea7\u6a19" + }, + "node_pro": { + "data": { + "ip_address": "\u8a2d\u5099 IP \u4f4d\u5740/\u4e3b\u6a5f\u540d\u7a31", + "password": "\u8a2d\u5099\u5bc6\u78bc" + }, + "description": "\u76e3\u63a7\u500b\u4eba AirVisual \u8a2d\u5099\uff0c\u5bc6\u78bc\u53ef\u4ee5\u900f\u904e\u8a2d\u5099 UI \u7372\u5f97\u3002", + "title": "\u8a2d\u5b9a AirVisual Node/Pro" + }, + "user": { + "data": { + "api_key": "API \u5bc6\u9470", + "cloud_api": "\u5730\u7406\u5ea7\u6a19", + "latitude": "\u7def\u5ea6", + "longitude": "\u7d93\u5ea6", + "node_pro": "AirVisual Node Pro", + "type": "\u6574\u5408\u985e\u578b" + }, + "description": "\u9078\u64c7\u6240\u8981\u76e3\u63a7\u7684 AirVisual \u8cc7\u6599\u985e\u578b\u3002", + "title": "\u8a2d\u5b9a AirVisual" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "show_on_map": "\u65bc\u5730\u5716\u4e0a\u986f\u793a\u76e3\u63a7\u4f4d\u7f6e\u3002" + }, + "description": "\u8a2d\u5b9a AirVisual \u6574\u5408\u9078\u9805\u3002", + "title": "\u8a2d\u5b9a AirVisual" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aladdin_connect/cover.py b/homeassistant/components/aladdin_connect/cover.py index 70c85fd58a5c9..8b61d29b78a93 100644 --- a/homeassistant/components/aladdin_connect/cover.py +++ b/homeassistant/components/aladdin_connect/cover.py @@ -1,40 +1,49 @@ """Platform for the Aladdin Connect cover component.""" import logging +from aladdin_connect import AladdinConnectClient import voluptuous as vol -from homeassistant.components.cover import (CoverDevice, PLATFORM_SCHEMA, - SUPPORT_OPEN, SUPPORT_CLOSE) -from homeassistant.const import (CONF_USERNAME, CONF_PASSWORD, STATE_CLOSED, - STATE_OPENING, STATE_CLOSING, STATE_OPEN) +from homeassistant.components.cover import ( + PLATFORM_SCHEMA, + SUPPORT_CLOSE, + SUPPORT_OPEN, + CoverEntity, +) +from homeassistant.const import ( + CONF_PASSWORD, + CONF_USERNAME, + STATE_CLOSED, + STATE_CLOSING, + STATE_OPEN, + STATE_OPENING, +) import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -NOTIFICATION_ID = 'aladdin_notification' -NOTIFICATION_TITLE = 'Aladdin Connect Cover Setup' +NOTIFICATION_ID = "aladdin_notification" +NOTIFICATION_TITLE = "Aladdin Connect Cover Setup" STATES_MAP = { - 'open': STATE_OPEN, - 'opening': STATE_OPENING, - 'closed': STATE_CLOSED, - 'closing': STATE_CLOSING + "open": STATE_OPEN, + "opening": STATE_OPENING, + "closed": STATE_CLOSED, + "closing": STATE_CLOSING, } SUPPORTED_FEATURES = SUPPORT_OPEN | SUPPORT_CLOSE -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string -}) +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 Aladdin Connect platform.""" - from aladdin_connect import AladdinConnectClient - username = config.get(CONF_USERNAME) - password = config.get(CONF_PASSWORD) + username = config[CONF_USERNAME] + password = config[CONF_PASSWORD] acc = AladdinConnectClient(username, password) try: @@ -44,28 +53,27 @@ def setup_platform(hass, config, add_entities, discovery_info=None): 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), + "Error: {ex}
You will need to restart hass after fixing.", title=NOTIFICATION_TITLE, - notification_id=NOTIFICATION_ID) + notification_id=NOTIFICATION_ID, + ) -class AladdinDevice(CoverDevice): +class AladdinDevice(CoverEntity): """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']) + 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' + return "garage" @property def supported_features(self): @@ -75,7 +83,7 @@ def supported_features(self): @property def unique_id(self): """Return a unique ID.""" - return '{}-{}'.format(self._device_id, self._number) + return f"{self._device_id}-{self._number}" @property def name(self): diff --git a/homeassistant/components/aladdin_connect/manifest.json b/homeassistant/components/aladdin_connect/manifest.json index 0681d5df38b24..2eb72f6bd35d4 100644 --- a/homeassistant/components/aladdin_connect/manifest.json +++ b/homeassistant/components/aladdin_connect/manifest.json @@ -1,10 +1,7 @@ { "domain": "aladdin_connect", - "name": "Aladdin connect", - "documentation": "https://www.home-assistant.io/components/aladdin_connect", - "requirements": [ - "aladdin_connect==0.3" - ], - "dependencies": [], + "name": "Aladdin Connect", + "documentation": "https://www.home-assistant.io/integrations/aladdin_connect", + "requirements": ["aladdin_connect==0.3"], "codeowners": [] } diff --git a/homeassistant/components/alarm_control_panel/__init__.py b/homeassistant/components/alarm_control_panel/__init__.py index 36a68eda174b3..50b8adb4c032d 100644 --- a/homeassistant/components/alarm_control_panel/__init__.py +++ b/homeassistant/components/alarm_control_panel/__init__.py @@ -1,63 +1,91 @@ """Component to interface with an alarm control panel.""" +from abc import abstractmethod from datetime import timedelta import logging 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, - SERVICE_ALARM_ARM_NIGHT, SERVICE_ALARM_ARM_CUSTOM_BYPASS) -from homeassistant.helpers.config_validation import ( # noqa - PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE) + ATTR_CODE, + ATTR_CODE_FORMAT, + SERVICE_ALARM_ARM_AWAY, + SERVICE_ALARM_ARM_CUSTOM_BYPASS, + SERVICE_ALARM_ARM_HOME, + SERVICE_ALARM_ARM_NIGHT, + SERVICE_ALARM_DISARM, + SERVICE_ALARM_TRIGGER, +) import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.config_validation import ( # noqa: F401 + PLATFORM_SCHEMA, + PLATFORM_SCHEMA_BASE, + make_entity_service_schema, +) from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent -DOMAIN = 'alarm_control_panel' +from .const import ( + SUPPORT_ALARM_ARM_AWAY, + SUPPORT_ALARM_ARM_CUSTOM_BYPASS, + SUPPORT_ALARM_ARM_HOME, + SUPPORT_ALARM_ARM_NIGHT, + SUPPORT_ALARM_TRIGGER, +) + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "alarm_control_panel" SCAN_INTERVAL = timedelta(seconds=30) -ATTR_CHANGED_BY = 'changed_by' -FORMAT_TEXT = 'text' -FORMAT_NUMBER = 'number' +ATTR_CHANGED_BY = "changed_by" +FORMAT_TEXT = "text" +FORMAT_NUMBER = "number" +ATTR_CODE_ARM_REQUIRED = "code_arm_required" -ENTITY_ID_FORMAT = DOMAIN + '.{}' +ENTITY_ID_FORMAT = DOMAIN + ".{}" -ALARM_SERVICE_SCHEMA = vol.Schema({ - vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids, - vol.Optional(ATTR_CODE): cv.string, -}) +ALARM_SERVICE_SCHEMA = make_entity_service_schema({vol.Optional(ATTR_CODE): cv.string}) async def async_setup(hass, config): """Track states and offer events for sensors.""" component = hass.data[DOMAIN] = EntityComponent( - logging.getLogger(__name__), DOMAIN, hass, SCAN_INTERVAL) + logging.getLogger(__name__), DOMAIN, hass, SCAN_INTERVAL + ) await component.async_setup(config) component.async_register_entity_service( - SERVICE_ALARM_DISARM, ALARM_SERVICE_SCHEMA, - 'async_alarm_disarm' + 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' + SERVICE_ALARM_ARM_HOME, + ALARM_SERVICE_SCHEMA, + "async_alarm_arm_home", + [SUPPORT_ALARM_ARM_HOME], ) component.async_register_entity_service( - SERVICE_ALARM_ARM_AWAY, ALARM_SERVICE_SCHEMA, - 'async_alarm_arm_away' + SERVICE_ALARM_ARM_AWAY, + ALARM_SERVICE_SCHEMA, + "async_alarm_arm_away", + [SUPPORT_ALARM_ARM_AWAY], ) component.async_register_entity_service( - SERVICE_ALARM_ARM_NIGHT, ALARM_SERVICE_SCHEMA, - 'async_alarm_arm_night' + SERVICE_ALARM_ARM_NIGHT, + ALARM_SERVICE_SCHEMA, + "async_alarm_arm_night", + [SUPPORT_ALARM_ARM_NIGHT], ) component.async_register_entity_service( - SERVICE_ALARM_ARM_CUSTOM_BYPASS, ALARM_SERVICE_SCHEMA, - 'async_alarm_arm_custom_bypass' + SERVICE_ALARM_ARM_CUSTOM_BYPASS, + ALARM_SERVICE_SCHEMA, + "async_alarm_arm_custom_bypass", + [SUPPORT_ALARM_ARM_CUSTOM_BYPASS], ) component.async_register_entity_service( - SERVICE_ALARM_TRIGGER, ALARM_SERVICE_SCHEMA, - 'async_alarm_trigger' + SERVICE_ALARM_TRIGGER, + ALARM_SERVICE_SCHEMA, + "async_alarm_trigger", + [SUPPORT_ALARM_TRIGGER], ) return True @@ -73,9 +101,8 @@ async def async_unload_entry(hass, entry): return await hass.data[DOMAIN].async_unload_entry(entry) -# pylint: disable=no-self-use -class AlarmControlPanel(Entity): - """An abstract class for alarm control devices.""" +class AlarmControlPanelEntity(Entity): + """An abstract class for alarm control entities.""" @property def code_format(self): @@ -87,78 +114,82 @@ def changed_by(self): """Last change triggered by.""" return None + @property + def code_arm_required(self): + """Whether the code is required for arm actions.""" + return True + 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) + async def async_alarm_disarm(self, code=None): + """Send disarm command.""" + await 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) + async def async_alarm_arm_home(self, code=None): + """Send arm home command.""" + await 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) + async def async_alarm_arm_away(self, code=None): + """Send arm away command.""" + await 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) + async def async_alarm_arm_night(self, code=None): + """Send arm night command.""" + await 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) + async def async_alarm_trigger(self, code=None): + """Send alarm trigger command.""" + await 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. + async def async_alarm_arm_custom_bypass(self, code=None): + """Send arm custom bypass command.""" + await self.hass.async_add_executor_job(self.alarm_arm_custom_bypass, code) - 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 + @abstractmethod + def supported_features(self) -> int: + """Return the list of supported features.""" @property def state_attributes(self): """Return the state attributes.""" state_attr = { ATTR_CODE_FORMAT: self.code_format, - ATTR_CHANGED_BY: self.changed_by + ATTR_CHANGED_BY: self.changed_by, + ATTR_CODE_ARM_REQUIRED: self.code_arm_required, } return state_attr + + +class AlarmControlPanel(AlarmControlPanelEntity): + """An abstract class for alarm control entities (for backwards compatibility).""" + + def __init_subclass__(cls, **kwargs): + """Print deprecation warning.""" + super().__init_subclass__(**kwargs) + _LOGGER.warning( + "AlarmControlPanel is deprecated, modify %s to extend AlarmControlPanelEntity", + cls.__name__, + ) diff --git a/homeassistant/components/alarm_control_panel/const.py b/homeassistant/components/alarm_control_panel/const.py new file mode 100644 index 0000000000000..2844cb286ab99 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/const.py @@ -0,0 +1,14 @@ +"""Provides the constants needed for component.""" + +SUPPORT_ALARM_ARM_HOME = 1 +SUPPORT_ALARM_ARM_AWAY = 2 +SUPPORT_ALARM_ARM_NIGHT = 4 +SUPPORT_ALARM_TRIGGER = 8 +SUPPORT_ALARM_ARM_CUSTOM_BYPASS = 16 + +CONDITION_TRIGGERED = "is_triggered" +CONDITION_DISARMED = "is_disarmed" +CONDITION_ARMED_HOME = "is_armed_home" +CONDITION_ARMED_AWAY = "is_armed_away" +CONDITION_ARMED_NIGHT = "is_armed_night" +CONDITION_ARMED_CUSTOM_BYPASS = "is_armed_custom_bypass" diff --git a/homeassistant/components/alarm_control_panel/device_action.py b/homeassistant/components/alarm_control_panel/device_action.py new file mode 100644 index 0000000000000..81e444ae16f0c --- /dev/null +++ b/homeassistant/components/alarm_control_panel/device_action.py @@ -0,0 +1,146 @@ +"""Provides device automations for Alarm control panel.""" +from typing import List, Optional + +import voluptuous as vol + +from homeassistant.const import ( + ATTR_CODE, + ATTR_ENTITY_ID, + CONF_CODE, + CONF_DEVICE_ID, + CONF_DOMAIN, + CONF_ENTITY_ID, + CONF_TYPE, + SERVICE_ALARM_ARM_AWAY, + SERVICE_ALARM_ARM_HOME, + SERVICE_ALARM_ARM_NIGHT, + SERVICE_ALARM_DISARM, + SERVICE_ALARM_TRIGGER, +) +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import entity_registry +import homeassistant.helpers.config_validation as cv + +from . import ATTR_CODE_ARM_REQUIRED, DOMAIN +from .const import ( + SUPPORT_ALARM_ARM_AWAY, + SUPPORT_ALARM_ARM_HOME, + SUPPORT_ALARM_ARM_NIGHT, + SUPPORT_ALARM_TRIGGER, +) + +ACTION_TYPES = {"arm_away", "arm_home", "arm_night", "disarm", "trigger"} + +ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( + { + vol.Required(CONF_TYPE): vol.In(ACTION_TYPES), + vol.Required(CONF_ENTITY_ID): cv.entity_domain(DOMAIN), + vol.Optional(CONF_CODE): cv.string, + } +) + + +async def async_get_actions(hass: HomeAssistant, device_id: str) -> List[dict]: + """List device actions for Alarm control panel devices.""" + registry = await entity_registry.async_get_registry(hass) + actions = [] + + # Get all the integrations entities for this device + for entry in entity_registry.async_entries_for_device(registry, device_id): + if entry.domain != DOMAIN: + continue + + state = hass.states.get(entry.entity_id) + + # We need a state or else we can't populate the HVAC and preset modes. + if state is None: + continue + + supported_features = state.attributes["supported_features"] + + # Add actions for each entity that belongs to this integration + if supported_features & SUPPORT_ALARM_ARM_AWAY: + actions.append( + { + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "arm_away", + } + ) + if supported_features & SUPPORT_ALARM_ARM_HOME: + actions.append( + { + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "arm_home", + } + ) + if supported_features & SUPPORT_ALARM_ARM_NIGHT: + actions.append( + { + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "arm_night", + } + ) + actions.append( + { + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "disarm", + } + ) + if supported_features & SUPPORT_ALARM_TRIGGER: + actions.append( + { + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "trigger", + } + ) + + return actions + + +async def async_call_action_from_config( + hass: HomeAssistant, config: dict, variables: dict, context: Optional[Context] +) -> None: + """Execute a device action.""" + config = ACTION_SCHEMA(config) + + service_data = {ATTR_ENTITY_ID: config[CONF_ENTITY_ID]} + if CONF_CODE in config: + service_data[ATTR_CODE] = config[CONF_CODE] + + if config[CONF_TYPE] == "arm_away": + service = SERVICE_ALARM_ARM_AWAY + elif config[CONF_TYPE] == "arm_home": + service = SERVICE_ALARM_ARM_HOME + elif config[CONF_TYPE] == "arm_night": + service = SERVICE_ALARM_ARM_NIGHT + elif config[CONF_TYPE] == "disarm": + service = SERVICE_ALARM_DISARM + elif config[CONF_TYPE] == "trigger": + service = SERVICE_ALARM_TRIGGER + + await hass.services.async_call( + DOMAIN, service, service_data, blocking=True, context=context + ) + + +async def async_get_action_capabilities(hass, config): + """List action capabilities.""" + state = hass.states.get(config[CONF_ENTITY_ID]) + code_required = state.attributes.get(ATTR_CODE_ARM_REQUIRED) if state else False + + if config[CONF_TYPE] == "trigger" or ( + config[CONF_TYPE] != "disarm" and not code_required + ): + return {} + + return {"extra_fields": vol.Schema({vol.Optional(CONF_CODE): str})} diff --git a/homeassistant/components/alarm_control_panel/device_condition.py b/homeassistant/components/alarm_control_panel/device_condition.py new file mode 100644 index 0000000000000..c4d43d1b05147 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/device_condition.py @@ -0,0 +1,162 @@ +"""Provide the device automations for Alarm control panel.""" +from typing import Dict, List + +import voluptuous as vol + +from homeassistant.components.alarm_control_panel.const import ( + SUPPORT_ALARM_ARM_AWAY, + SUPPORT_ALARM_ARM_CUSTOM_BYPASS, + SUPPORT_ALARM_ARM_HOME, + SUPPORT_ALARM_ARM_NIGHT, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_CONDITION, + CONF_DEVICE_ID, + CONF_DOMAIN, + CONF_ENTITY_ID, + CONF_TYPE, + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_CUSTOM_BYPASS, + STATE_ALARM_ARMED_HOME, + STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_DISARMED, + STATE_ALARM_TRIGGERED, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import condition, config_validation as cv, entity_registry +from homeassistant.helpers.config_validation import DEVICE_CONDITION_BASE_SCHEMA +from homeassistant.helpers.typing import ConfigType, TemplateVarsType + +from . import DOMAIN +from .const import ( + CONDITION_ARMED_AWAY, + CONDITION_ARMED_CUSTOM_BYPASS, + CONDITION_ARMED_HOME, + CONDITION_ARMED_NIGHT, + CONDITION_DISARMED, + CONDITION_TRIGGERED, +) + +CONDITION_TYPES = { + CONDITION_TRIGGERED, + CONDITION_DISARMED, + CONDITION_ARMED_HOME, + CONDITION_ARMED_AWAY, + CONDITION_ARMED_NIGHT, + CONDITION_ARMED_CUSTOM_BYPASS, +} + +CONDITION_SCHEMA = DEVICE_CONDITION_BASE_SCHEMA.extend( + { + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_TYPE): vol.In(CONDITION_TYPES), + } +) + + +async def async_get_conditions( + hass: HomeAssistant, device_id: str +) -> List[Dict[str, str]]: + """List device conditions for Alarm control panel devices.""" + registry = await entity_registry.async_get_registry(hass) + conditions = [] + + # Get all the integrations entities for this device + for entry in entity_registry.async_entries_for_device(registry, device_id): + if entry.domain != DOMAIN: + continue + + state = hass.states.get(entry.entity_id) + + # We need a state or else we can't populate the different armed conditions + if state is None: + continue + + supported_features = state.attributes["supported_features"] + + # Add conditions for each entity that belongs to this integration + conditions += [ + { + CONF_CONDITION: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: CONDITION_DISARMED, + }, + { + CONF_CONDITION: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: CONDITION_TRIGGERED, + }, + ] + if supported_features & SUPPORT_ALARM_ARM_HOME: + conditions.append( + { + CONF_CONDITION: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: CONDITION_ARMED_HOME, + } + ) + if supported_features & SUPPORT_ALARM_ARM_AWAY: + conditions.append( + { + CONF_CONDITION: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: CONDITION_ARMED_AWAY, + } + ) + if supported_features & SUPPORT_ALARM_ARM_NIGHT: + conditions.append( + { + CONF_CONDITION: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: CONDITION_ARMED_NIGHT, + } + ) + if supported_features & SUPPORT_ALARM_ARM_CUSTOM_BYPASS: + conditions.append( + { + CONF_CONDITION: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: CONDITION_ARMED_CUSTOM_BYPASS, + } + ) + + return conditions + + +def async_condition_from_config( + config: ConfigType, config_validation: bool +) -> condition.ConditionCheckerType: + """Create a function to test a device condition.""" + if config_validation: + config = CONDITION_SCHEMA(config) + if config[CONF_TYPE] == CONDITION_TRIGGERED: + state = STATE_ALARM_TRIGGERED + elif config[CONF_TYPE] == CONDITION_DISARMED: + state = STATE_ALARM_DISARMED + elif config[CONF_TYPE] == CONDITION_ARMED_HOME: + state = STATE_ALARM_ARMED_HOME + elif config[CONF_TYPE] == CONDITION_ARMED_AWAY: + state = STATE_ALARM_ARMED_AWAY + elif config[CONF_TYPE] == CONDITION_ARMED_NIGHT: + state = STATE_ALARM_ARMED_NIGHT + elif config[CONF_TYPE] == CONDITION_ARMED_CUSTOM_BYPASS: + state = STATE_ALARM_ARMED_CUSTOM_BYPASS + + def test_is_state(hass: HomeAssistant, variables: TemplateVarsType) -> bool: + """Test if an entity is a certain state.""" + return condition.state(hass, config[ATTR_ENTITY_ID], state) + + return test_is_state diff --git a/homeassistant/components/alarm_control_panel/device_trigger.py b/homeassistant/components/alarm_control_panel/device_trigger.py new file mode 100644 index 0000000000000..849da062665c3 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/device_trigger.py @@ -0,0 +1,163 @@ +"""Provides device automations for Alarm control panel.""" +from typing import List + +import voluptuous as vol + +from homeassistant.components.alarm_control_panel.const import ( + SUPPORT_ALARM_ARM_AWAY, + SUPPORT_ALARM_ARM_HOME, + SUPPORT_ALARM_ARM_NIGHT, +) +from homeassistant.components.automation import AutomationActionType, state +from homeassistant.components.device_automation import TRIGGER_BASE_SCHEMA +from homeassistant.const import ( + CONF_DEVICE_ID, + CONF_DOMAIN, + CONF_ENTITY_ID, + CONF_PLATFORM, + CONF_TYPE, + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_HOME, + STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_ARMING, + STATE_ALARM_DISARMED, + STATE_ALARM_PENDING, + STATE_ALARM_TRIGGERED, +) +from homeassistant.core import CALLBACK_TYPE, HomeAssistant +from homeassistant.helpers import config_validation as cv, entity_registry +from homeassistant.helpers.typing import ConfigType + +from . import DOMAIN + +TRIGGER_TYPES = { + "triggered", + "disarmed", + "arming", + "armed_home", + "armed_away", + "armed_night", +} + +TRIGGER_SCHEMA = TRIGGER_BASE_SCHEMA.extend( + { + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_TYPE): vol.In(TRIGGER_TYPES), + } +) + + +async def async_get_triggers(hass: HomeAssistant, device_id: str) -> List[dict]: + """List device triggers for Alarm control panel devices.""" + registry = await entity_registry.async_get_registry(hass) + triggers = [] + + # Get all the integrations entities for this device + for entry in entity_registry.async_entries_for_device(registry, device_id): + if entry.domain != DOMAIN: + continue + + entity_state = hass.states.get(entry.entity_id) + + # We need a state or else we can't populate the HVAC and preset modes. + if entity_state is None: + continue + + supported_features = entity_state.attributes["supported_features"] + + # Add triggers for each entity that belongs to this integration + triggers += [ + { + CONF_PLATFORM: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "disarmed", + }, + { + CONF_PLATFORM: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "triggered", + }, + { + CONF_PLATFORM: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "arming", + }, + ] + if supported_features & SUPPORT_ALARM_ARM_HOME: + triggers.append( + { + CONF_PLATFORM: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "armed_home", + } + ) + if supported_features & SUPPORT_ALARM_ARM_AWAY: + triggers.append( + { + CONF_PLATFORM: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "armed_away", + } + ) + if supported_features & SUPPORT_ALARM_ARM_NIGHT: + triggers.append( + { + CONF_PLATFORM: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "armed_night", + } + ) + + return triggers + + +async def async_attach_trigger( + hass: HomeAssistant, + config: ConfigType, + action: AutomationActionType, + automation_info: dict, +) -> CALLBACK_TYPE: + """Attach a trigger.""" + config = TRIGGER_SCHEMA(config) + from_state = None + + if config[CONF_TYPE] == "triggered": + to_state = STATE_ALARM_TRIGGERED + elif config[CONF_TYPE] == "disarmed": + to_state = STATE_ALARM_DISARMED + elif config[CONF_TYPE] == "arming": + from_state = STATE_ALARM_DISARMED + to_state = STATE_ALARM_ARMING + elif config[CONF_TYPE] == "armed_home": + from_state = STATE_ALARM_PENDING or STATE_ALARM_ARMING + to_state = STATE_ALARM_ARMED_HOME + elif config[CONF_TYPE] == "armed_away": + from_state = STATE_ALARM_PENDING or STATE_ALARM_ARMING + to_state = STATE_ALARM_ARMED_AWAY + elif config[CONF_TYPE] == "armed_night": + from_state = STATE_ALARM_PENDING or STATE_ALARM_ARMING + to_state = STATE_ALARM_ARMED_NIGHT + + state_config = { + state.CONF_PLATFORM: "state", + CONF_ENTITY_ID: config[CONF_ENTITY_ID], + state.CONF_TO: to_state, + } + if from_state: + state_config[state.CONF_FROM] = from_state + state_config = state.TRIGGER_SCHEMA(state_config) + return await state.async_attach_trigger( + hass, state_config, action, automation_info, platform_type="device" + ) diff --git a/homeassistant/components/alarm_control_panel/manifest.json b/homeassistant/components/alarm_control_panel/manifest.json index 95e26de53bcb3..e4cd0e27a39f4 100644 --- a/homeassistant/components/alarm_control_panel/manifest.json +++ b/homeassistant/components/alarm_control_panel/manifest.json @@ -1,10 +1,7 @@ { "domain": "alarm_control_panel", - "name": "Alarm control panel", - "documentation": "https://www.home-assistant.io/components/alarm_control_panel", - "requirements": [], - "dependencies": [], - "codeowners": [ - "@colinodell" - ] + "name": "Alarm Control Panel", + "documentation": "https://www.home-assistant.io/integrations/alarm_control_panel", + "codeowners": [], + "quality_scale": "internal" } diff --git a/homeassistant/components/alarm_control_panel/reproduce_state.py b/homeassistant/components/alarm_control_panel/reproduce_state.py new file mode 100644 index 0000000000000..9e7d8e6f1a76a --- /dev/null +++ b/homeassistant/components/alarm_control_panel/reproduce_state.py @@ -0,0 +1,97 @@ +"""Reproduce an Alarm control panel state.""" +import asyncio +import logging +from typing import Any, Dict, Iterable, Optional + +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_ALARM_ARM_AWAY, + SERVICE_ALARM_ARM_CUSTOM_BYPASS, + SERVICE_ALARM_ARM_HOME, + SERVICE_ALARM_ARM_NIGHT, + SERVICE_ALARM_DISARM, + SERVICE_ALARM_TRIGGER, + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_CUSTOM_BYPASS, + STATE_ALARM_ARMED_HOME, + STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_DISARMED, + STATE_ALARM_TRIGGERED, +) +from homeassistant.core import Context, State +from homeassistant.helpers.typing import HomeAssistantType + +from . import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +VALID_STATES = { + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_CUSTOM_BYPASS, + STATE_ALARM_ARMED_HOME, + STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_DISARMED, + STATE_ALARM_TRIGGERED, +} + + +async def _async_reproduce_state( + hass: HomeAssistantType, + state: State, + *, + context: Optional[Context] = None, + reproduce_options: Optional[Dict[str, Any]] = None, +) -> None: + """Reproduce a single state.""" + cur_state = hass.states.get(state.entity_id) + + if cur_state is None: + _LOGGER.warning("Unable to find entity %s", state.entity_id) + return + + if state.state not in VALID_STATES: + _LOGGER.warning( + "Invalid state specified for %s: %s", state.entity_id, state.state + ) + return + + # Return if we are already at the right state. + if cur_state.state == state.state: + return + + service_data = {ATTR_ENTITY_ID: state.entity_id} + + if state.state == STATE_ALARM_ARMED_AWAY: + service = SERVICE_ALARM_ARM_AWAY + elif state.state == STATE_ALARM_ARMED_CUSTOM_BYPASS: + service = SERVICE_ALARM_ARM_CUSTOM_BYPASS + elif state.state == STATE_ALARM_ARMED_HOME: + service = SERVICE_ALARM_ARM_HOME + elif state.state == STATE_ALARM_ARMED_NIGHT: + service = SERVICE_ALARM_ARM_NIGHT + elif state.state == STATE_ALARM_DISARMED: + service = SERVICE_ALARM_DISARM + elif state.state == STATE_ALARM_TRIGGERED: + service = SERVICE_ALARM_TRIGGER + + await hass.services.async_call( + DOMAIN, service, service_data, context=context, blocking=True + ) + + +async def async_reproduce_states( + hass: HomeAssistantType, + states: Iterable[State], + *, + context: Optional[Context] = None, + reproduce_options: Optional[Dict[str, Any]] = None, +) -> None: + """Reproduce Alarm control panel states.""" + await asyncio.gather( + *( + _async_reproduce_state( + hass, state, context=context, reproduce_options=reproduce_options + ) + for state in states + ) + ) diff --git a/homeassistant/components/alarm_control_panel/services.yaml b/homeassistant/components/alarm_control_panel/services.yaml index 7918631464fee..fa5c573a7f898 100644 --- a/homeassistant/components/alarm_control_panel/services.yaml +++ b/homeassistant/components/alarm_control_panel/services.yaml @@ -5,17 +5,27 @@ alarm_disarm: fields: entity_id: description: Name of alarm control panel to disarm. - example: 'alarm_control_panel.downstairs' + example: "alarm_control_panel.downstairs" code: description: An optional code to disarm the alarm control panel with. example: 1234 +alarm_arm_custom_bypass: + description: Send arm custom bypass command. + fields: + entity_id: + description: Name of alarm control panel to arm custom bypass. + example: "alarm_control_panel.downstairs" + code: + description: An optional code to arm custom bypass 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' + example: "alarm_control_panel.downstairs" code: description: An optional code to arm home the alarm control panel with. example: 1234 @@ -25,7 +35,7 @@ alarm_arm_away: fields: entity_id: description: Name of alarm control panel to arm away. - example: 'alarm_control_panel.downstairs' + example: "alarm_control_panel.downstairs" code: description: An optional code to arm away the alarm control panel with. example: 1234 @@ -35,7 +45,7 @@ alarm_arm_night: fields: entity_id: description: Name of alarm control panel to arm night. - example: 'alarm_control_panel.downstairs' + example: "alarm_control_panel.downstairs" code: description: An optional code to arm night the alarm control panel with. example: 1234 @@ -45,89 +55,7 @@ alarm_trigger: fields: entity_id: description: Name of alarm control panel to trigger. - example: 'alarm_control_panel.downstairs' + 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/strings.json b/homeassistant/components/alarm_control_panel/strings.json new file mode 100644 index 0000000000000..de89d28082b90 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/strings.json @@ -0,0 +1,40 @@ +{ + "title": "Alarm control panel", + "device_automation": { + "action_type": { + "arm_away": "Arm {entity_name} away", + "arm_home": "Arm {entity_name} home", + "arm_night": "Arm {entity_name} night", + "disarm": "Disarm {entity_name}", + "trigger": "Trigger {entity_name}" + }, + "condition_type": { + "is_triggered": "{entity_name} is triggered", + "is_disarmed": "{entity_name} is disarmed", + "is_armed_home": "{entity_name} is armed home", + "is_armed_away": "{entity_name} is armed away", + "is_armed_night": "{entity_name} is armed night" + }, + "trigger_type": { + "triggered": "{entity_name} triggered", + "disarmed": "{entity_name} disarmed", + "armed_home": "{entity_name} armed home", + "armed_away": "{entity_name} armed away", + "armed_night": "{entity_name} armed night" + } + }, + "state": { + "_": { + "armed": "Armed", + "disarmed": "Disarmed", + "armed_home": "Armed home", + "armed_away": "Armed away", + "armed_night": "Armed night", + "armed_custom_bypass": "Armed custom bypass", + "pending": "Pending", + "arming": "Arming", + "disarming": "Disarming", + "triggered": "Triggered" + } + } +} diff --git a/homeassistant/components/alarm_control_panel/translations/af.json b/homeassistant/components/alarm_control_panel/translations/af.json new file mode 100644 index 0000000000000..6f6a5c51c9404 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/translations/af.json @@ -0,0 +1,17 @@ +{ + "state": { + "_": { + "armed": "Gewapen", + "armed_away": "Gewapend weg", + "armed_custom_bypass": "Gewapende pasgemaakte omseil", + "armed_home": "Gewapend tuis", + "armed_night": "Gewapend nag", + "arming": "Bewapen Tans", + "disarmed": "Ontwapen", + "disarming": "Ontwapen Tans", + "pending": "Hangende", + "triggered": "Geaktiveer" + } + }, + "title": "Alarm beheer paneel" +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/translations/ar.json b/homeassistant/components/alarm_control_panel/translations/ar.json new file mode 100644 index 0000000000000..427b30eebbe54 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/translations/ar.json @@ -0,0 +1,17 @@ +{ + "state": { + "_": { + "armed": "\u0645\u0633\u0644\u062d", + "armed_away": "\u0645\u0641\u0639\u0651\u0644 \u0641\u064a \u0627\u0644\u062e\u0627\u0631\u062c", + "armed_custom_bypass": "\u062a\u062c\u0627\u0648\u0632 \u0627\u0644\u062a\u0641\u0639\u064a\u0644", + "armed_home": "\u0645\u0641\u0639\u0651\u0644 \u0641\u064a \u0627\u0644\u0645\u0646\u0632\u0644", + "armed_night": "\u0645\u0641\u0639\u0651\u0644 \u0644\u064a\u0644", + "arming": "\u062c\u0627\u0631\u064a \u0627\u0644\u062a\u0641\u0639\u064a\u0644", + "disarmed": "\u063a\u064a\u0631 \u0645\u0641\u0639\u0651\u0644", + "disarming": "\u0625\u064a\u0642\u0627\u0641 \u0627\u0644\u0625\u0646\u0630\u0627\u0631", + "pending": "\u0642\u064a\u062f \u0627\u0644\u0625\u0646\u062a\u0638\u0627\u0631", + "triggered": "\u0645\u0641\u0639\u0651\u0644" + } + }, + "title": "\u0644\u0648\u062d\u0629 \u062a\u062d\u0643\u0645 \u0627\u0644\u0625\u0646\u0630\u0627\u0631" +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/translations/bg.json b/homeassistant/components/alarm_control_panel/translations/bg.json new file mode 100644 index 0000000000000..4eb04fa54fcf8 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/translations/bg.json @@ -0,0 +1,33 @@ +{ + "device_automation": { + "action_type": { + "arm_away": "\u0421\u043b\u043e\u0436\u0438 {entity_name} \u043f\u043e\u0434 \u043e\u0445\u0440\u0430\u043d\u0430 \u0432 \u0440\u0435\u0436\u0438\u043c \u043e\u0442\u0441\u044a\u0441\u0442\u0432\u0438\u0435", + "arm_home": "\u0421\u043b\u043e\u0436\u0438 {entity_name} \u043f\u043e\u0434 \u043e\u0445\u0440\u0430\u043d\u0430 \u0432 \u0440\u0435\u0436\u0438\u043c \u0432\u043a\u044a\u0449\u0438", + "arm_night": "\u0421\u043b\u043e\u0436\u0438 {entity_name} \u043f\u043e\u0434 \u043e\u0445\u0440\u0430\u043d\u0430 \u0432 \u043d\u043e\u0449\u0435\u043d \u0440\u0435\u0436\u0438\u043c", + "disarm": "\u0414\u0435\u0430\u043a\u0442\u0438\u0432\u0438\u0440\u0430\u0439 {entity_name}", + "trigger": "\u0417\u0430\u0434\u0435\u0439\u0441\u0442\u0432\u0430\u043d\u0435 {entity_name}" + }, + "trigger_type": { + "armed_away": "{entity_name} \u043f\u043e\u0434 \u043e\u0445\u0440\u0430\u043d\u0430", + "armed_home": "{entity_name} \u043f\u043e\u0434 \u043e\u0445\u0440\u0430\u043d\u0430 - \u0432\u043a\u044a\u0449\u0438", + "armed_night": "{entity_name} \u043f\u043e\u0434 \u043e\u0445\u0440\u0430\u043d\u0430 - \u043d\u043e\u0449", + "disarmed": "{entity_name} \u0434\u0435\u0430\u043a\u0442\u0438\u0432\u0438\u0440\u0430\u043d\u0430", + "triggered": "{entity_name} \u0437\u0430\u0434\u0435\u0439\u0441\u0442\u0432\u0430\u043d\u0430" + } + }, + "state": { + "_": { + "armed": "\u041f\u043e\u0434 \u043e\u0445\u0440\u0430\u043d\u0430", + "armed_away": "\u041f\u043e\u0434 \u043e\u0445\u0440\u0430\u043d\u0430", + "armed_custom_bypass": "\u041f\u043e\u0434 \u043e\u0445\u0440\u0430\u043d\u0430", + "armed_home": "\u041f\u043e\u0434 \u043e\u0445\u0440\u0430\u043d\u0430 - \u0432\u043a\u044a\u0449\u0438", + "armed_night": "\u041f\u043e\u0434 \u043e\u0445\u0440\u0430\u043d\u0430 - \u043d\u043e\u0449", + "arming": "\u0410\u043a\u0442\u0438\u0432\u0438\u0440\u0430\u043d\u0435", + "disarmed": "\u0414\u0435\u0430\u043a\u0442\u0438\u0432\u0438\u0440\u0430\u043d\u0430", + "disarming": "\u0414\u0435\u0430\u043a\u0442\u0438\u0432\u0438\u0440\u0430\u043d\u0435", + "pending": "\u0412 \u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0435", + "triggered": "\u0417\u0430\u0434\u0435\u0439\u0441\u0442\u0432\u0430\u043d" + } + }, + "title": "\u041a\u043e\u043d\u0442\u0440\u043e\u043b \u043d\u0430 \u0430\u043b\u0430\u0440\u043c\u0430" +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/translations/bs.json b/homeassistant/components/alarm_control_panel/translations/bs.json new file mode 100644 index 0000000000000..00012852b529d --- /dev/null +++ b/homeassistant/components/alarm_control_panel/translations/bs.json @@ -0,0 +1,17 @@ +{ + "state": { + "_": { + "armed": "Aktiviran", + "armed_away": "Aktiviran izvan ku\u0107e", + "armed_custom_bypass": "Aktiviran pod specijalnim rezimom", + "armed_home": "Aktiviran kod ku\u0107e", + "armed_night": "Aktiviran no\u0107u", + "arming": "Aktivacija", + "disarmed": "Deaktiviran", + "disarming": "Deaktivacija", + "pending": "U is\u010dekivanju", + "triggered": "Pokrenut" + } + }, + "title": "Centralni sistem za alarm" +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/translations/ca.json b/homeassistant/components/alarm_control_panel/translations/ca.json new file mode 100644 index 0000000000000..dafef96b09038 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/translations/ca.json @@ -0,0 +1,40 @@ +{ + "device_automation": { + "action_type": { + "arm_away": "Activa {entity_name} fora", + "arm_home": "Activa {entity_name} a casa", + "arm_night": "Activa {entity_name} nocturn", + "disarm": "Desactiva {entity_name}", + "trigger": "Dispara {entity_name}" + }, + "condition_type": { + "is_armed_away": "{entity_name} est\u00e0 activada en mode 'a fora'", + "is_armed_home": "{entity_name} est\u00e0 activada en mode 'a casa'", + "is_armed_night": "{entity_name} est\u00e0 activada en mode 'nocturn'", + "is_disarmed": "{entity_name} est\u00e0 desactivada", + "is_triggered": "{entity_name} est\u00e0 disparada" + }, + "trigger_type": { + "armed_away": "{entity_name} activada en mode 'a fora'", + "armed_home": "{entity_name} activada en mode 'a casa'", + "armed_night": "{entity_name} activada en mode 'nocturn'", + "disarmed": "{entity_name} desactivada", + "triggered": "{entity_name} disparat/ada" + } + }, + "state": { + "_": { + "armed": "Activada", + "armed_away": "Activada, mode fora", + "armed_custom_bypass": "Activada, bypass personalitzat", + "armed_home": "Activada, mode a casa", + "armed_night": "Activada, mode nocturn", + "arming": "Activant", + "disarmed": "Desactivada", + "disarming": "Desactivant", + "pending": "Pendent", + "triggered": "Disparada" + } + }, + "title": "Panell de control d'alarma" +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/translations/cs.json b/homeassistant/components/alarm_control_panel/translations/cs.json new file mode 100644 index 0000000000000..0eff1bebaaedf --- /dev/null +++ b/homeassistant/components/alarm_control_panel/translations/cs.json @@ -0,0 +1,26 @@ +{ + "device_automation": { + "action_type": { + "arm_away": "Aktivovat {entity_name} v re\u017eimu mimo domov", + "arm_home": "Aktivovat {entity_name} v re\u017eimu doma", + "arm_night": "Aktivovat {entity_name} v re\u017eimu noc", + "disarm": "Deaktivovat {entity_name}", + "trigger": "Spustit {entity_name}" + } + }, + "state": { + "_": { + "armed": "Aktivn\u00ed", + "armed_away": "Aktivn\u00ed re\u017eim mimo domov", + "armed_custom_bypass": "Aktivn\u00ed u\u017eivatelsk\u00fdm obejit\u00edm", + "armed_home": "Aktivn\u00ed re\u017eim doma", + "armed_night": "Aktivn\u00ed no\u010dn\u00ed re\u017eim", + "arming": "Aktivov\u00e1n\u00ed", + "disarmed": "Neaktivn\u00ed", + "disarming": "Deaktivov\u00e1n\u00ed", + "pending": "Nadch\u00e1zej\u00edc\u00ed", + "triggered": "Spu\u0161t\u011bno" + } + }, + "title": "Ovl\u00e1dac\u00ed panel alarmu" +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/translations/cy.json b/homeassistant/components/alarm_control_panel/translations/cy.json new file mode 100644 index 0000000000000..a8a7e52af3405 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/translations/cy.json @@ -0,0 +1,17 @@ +{ + "state": { + "_": { + "armed": "Arfogi", + "armed_away": "Arfog i ffwrdd", + "armed_custom_bypass": "Ffordd osgoi larwm personol", + "armed_home": "Arfogi gartref", + "armed_night": "Arfog nos", + "arming": "Arfogi", + "disarmed": "Diarfogi", + "disarming": "Ddiarfogi", + "pending": "Yn yr arfaeth", + "triggered": "Sbarduno" + } + }, + "title": "Panel rheoli larwm" +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/translations/da.json b/homeassistant/components/alarm_control_panel/translations/da.json new file mode 100644 index 0000000000000..f3b04e263609c --- /dev/null +++ b/homeassistant/components/alarm_control_panel/translations/da.json @@ -0,0 +1,33 @@ +{ + "device_automation": { + "action_type": { + "arm_away": "Tilkobl {entity_name} ude", + "arm_home": "Tilkobl {entity_name} hjemme", + "arm_night": "Tilkobl {entity_name} nat", + "disarm": "Frakobl {entity_name}", + "trigger": "Udl\u00f8s {entity_name}" + }, + "trigger_type": { + "armed_away": "{entity_name} tilkoblet ude", + "armed_home": "{entity_name} tilkoblet hjemme", + "armed_night": "{entity_name} tilkoblet nat", + "disarmed": "{entity_name} frakoblet", + "triggered": "{entity_name} udl\u00f8st" + } + }, + "state": { + "_": { + "armed": "Tilkoblet", + "armed_away": "Tilkoblet ude", + "armed_custom_bypass": "Tilkoblet brugerdefineret bypass", + "armed_home": "Tilkoblet hjemme", + "armed_night": "Tilkoblet nat", + "arming": "Tilkobler", + "disarmed": "Frakoblet", + "disarming": "Frakobler", + "pending": "Afventer", + "triggered": "Udl\u00f8st" + } + }, + "title": "Alarmkontrolpanel" +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/translations/de.json b/homeassistant/components/alarm_control_panel/translations/de.json new file mode 100644 index 0000000000000..a671c38893252 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/translations/de.json @@ -0,0 +1,40 @@ +{ + "device_automation": { + "action_type": { + "arm_away": "Aktiviere {entity_name} Unterwegs", + "arm_home": "Aktiviere {entity_name} Zuhause", + "arm_night": "Aktiviere {entity_name} Nacht-Modus", + "disarm": "Deaktivere {entity_name}", + "trigger": "Ausl\u00f6ser {entity_name}" + }, + "condition_type": { + "is_armed_away": "{entity_name} ist aktiviert - Unterwegs", + "is_armed_home": "{entity_name} ist aktiviert - Zuhause", + "is_armed_night": "{entity_name} ist aktiviert - Nacht", + "is_disarmed": "{entity_name} ist deaktiviert", + "is_triggered": "{entity_name} wurde ausgel\u00f6st" + }, + "trigger_type": { + "armed_away": "{entity_name} Unterwegs", + "armed_home": "{entity_name} Zuhause", + "armed_night": "{entity_name} Nacht-Modus", + "disarmed": "{entity_name} deaktiviert", + "triggered": "{entity_name} ausgel\u00f6st" + } + }, + "state": { + "_": { + "armed": "Aktiv", + "armed_away": "Aktiv, abwesend", + "armed_custom_bypass": "Aktiv, benutzerdefiniert", + "armed_home": "Aktiv, zu Hause", + "armed_night": "Aktiv, Nacht", + "arming": "Aktiviere", + "disarmed": "Inaktiv", + "disarming": "Deaktiviere", + "pending": "Ausstehend", + "triggered": "Ausgel\u00f6st" + } + }, + "title": "Alarmanlage" +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/translations/el.json b/homeassistant/components/alarm_control_panel/translations/el.json new file mode 100644 index 0000000000000..5b37be59d4741 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/translations/el.json @@ -0,0 +1,17 @@ +{ + "state": { + "_": { + "armed": "\u039f\u03c0\u03bb\u03b9\u03c3\u03bc\u03ad\u03bd\u03bf\u03c2", + "armed_away": "\u039f\u03c0\u03bb\u03b9\u03c3\u03bc\u03ad\u03bd\u03bf\u03c2 \u03bc\u03b1\u03ba\u03c1\u03b9\u03ac", + "armed_custom_bypass": "\u03a0\u03c1\u03bf\u03c3\u03b1\u03c1\u03bc\u03bf\u03c3\u03bc\u03ad\u03bd\u03b7 \u03c0\u03b1\u03c1\u03ac\u03ba\u03b1\u03bc\u03c8\u03b7 \u03b5\u03bd\u03b5\u03c1\u03b3\u03ae", + "armed_home": "\u03a3\u03c0\u03af\u03c4\u03b9 \u039f\u03c0\u03bb\u03b9\u03c3\u03bc\u03ad\u03bd\u03bf", + "armed_night": "\u039f\u03c0\u03bb\u03b9\u03c3\u03bc\u03ad\u03bd\u03bf \u03b2\u03c1\u03ac\u03b4\u03c5", + "arming": "\u038c\u03c0\u03bb\u03b9\u03c3\u03b7", + "disarmed": "\u0391\u03c6\u03bf\u03c0\u03bb\u03b9\u03c3\u03bc\u03ad\u03bd\u03bf\u03c2", + "disarming": "\u0391\u03c6\u03cc\u03c0\u03bb\u03b9\u03c3\u03b7", + "pending": "\u0395\u03ba\u03ba\u03c1\u03b5\u03bc\u03ae\u03c2", + "triggered": "\u03a0\u03b1\u03c1\u03b1\u03b2\u03af\u03b1\u03c3\u03b7" + } + }, + "title": "\u03a0\u03af\u03bd\u03b1\u03ba\u03b1\u03c2 \u03b5\u03bb\u03ad\u03b3\u03c7\u03bf\u03c5 \u03b5\u03b9\u03b4\u03bf\u03c0\u03bf\u03b9\u03ae\u03c3\u03b5\u03c9\u03bd" +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/translations/en.json b/homeassistant/components/alarm_control_panel/translations/en.json new file mode 100644 index 0000000000000..b364d8504618c --- /dev/null +++ b/homeassistant/components/alarm_control_panel/translations/en.json @@ -0,0 +1,40 @@ +{ + "device_automation": { + "action_type": { + "arm_away": "Arm {entity_name} away", + "arm_home": "Arm {entity_name} home", + "arm_night": "Arm {entity_name} night", + "disarm": "Disarm {entity_name}", + "trigger": "Trigger {entity_name}" + }, + "condition_type": { + "is_armed_away": "{entity_name} is armed away", + "is_armed_home": "{entity_name} is armed home", + "is_armed_night": "{entity_name} is armed night", + "is_disarmed": "{entity_name} is disarmed", + "is_triggered": "{entity_name} is triggered" + }, + "trigger_type": { + "armed_away": "{entity_name} armed away", + "armed_home": "{entity_name} armed home", + "armed_night": "{entity_name} armed night", + "disarmed": "{entity_name} disarmed", + "triggered": "{entity_name} triggered" + } + }, + "state": { + "_": { + "armed": "Armed", + "armed_away": "Armed away", + "armed_custom_bypass": "Armed custom bypass", + "armed_home": "Armed home", + "armed_night": "Armed night", + "arming": "Arming", + "disarmed": "Disarmed", + "disarming": "Disarming", + "pending": "Pending", + "triggered": "Triggered" + } + }, + "title": "Alarm control panel" +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/translations/es-419.json b/homeassistant/components/alarm_control_panel/translations/es-419.json new file mode 100644 index 0000000000000..7de15a9160837 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/translations/es-419.json @@ -0,0 +1,40 @@ +{ + "device_automation": { + "action_type": { + "arm_away": "Habilitar {entity_name} fuera de casa", + "arm_home": "Habilitar {entity_name} en casa", + "arm_night": "Habilitar {entity_name} de noche", + "disarm": "Deshabilitar {entity_name}", + "trigger": "Activar {entity_name}" + }, + "condition_type": { + "is_armed_away": "{entity_name} est\u00e1 habilitada fuera de casa", + "is_armed_home": "{entity_name} est\u00e1 habilitada en casa", + "is_armed_night": "{entity_name} est\u00e1 habilitada de noche", + "is_disarmed": "{entity_name} est\u00e1 deshabilitada", + "is_triggered": "{entity_name} est\u00e1 activada" + }, + "trigger_type": { + "armed_away": "{entity_name} habilitada fuera de casa", + "armed_home": "{entity_name} habilitada en casa", + "armed_night": "{entity_name} habilitada de noche", + "disarmed": "{entity_name} deshabilitada", + "triggered": "{entity_name} activada" + } + }, + "state": { + "_": { + "armed": "Armado", + "armed_away": "Armado Fuera de Casa", + "armed_custom_bypass": "Armada zona espec\u00edfica", + "armed_home": "Armado en Casa", + "armed_night": "Armado Nocturno", + "arming": "Armando", + "disarmed": "Desarmado", + "disarming": "Desarmando", + "pending": "Pendiente", + "triggered": "Activado" + } + }, + "title": "Panel de control de alarma" +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/translations/es.json b/homeassistant/components/alarm_control_panel/translations/es.json new file mode 100644 index 0000000000000..465dd0e899414 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/translations/es.json @@ -0,0 +1,40 @@ +{ + "device_automation": { + "action_type": { + "arm_away": "Armar {entity_name} exterior", + "arm_home": "Armar {entity_name} modo casa", + "arm_night": "Armar {entity_name} por la noche", + "disarm": "Desarmar {entity_name}", + "trigger": "Lanzar {entity_name}" + }, + "condition_type": { + "is_armed_away": "{entity_name} est\u00e1 armada fuera", + "is_armed_home": "{entity_name} est\u00e1 armada en casa", + "is_armed_night": "{entity_name} est\u00e1 armada noche", + "is_disarmed": "{entity_name} est\u00e1 desarmada", + "is_triggered": "{entity_name} est\u00e1 disparada" + }, + "trigger_type": { + "armed_away": "{entity_name} armado fuera", + "armed_home": "{entity_name} armado en casa", + "armed_night": "{entity_name} armado modo noche", + "disarmed": "{entity_name} desarmado", + "triggered": "{entity_name} activado" + } + }, + "state": { + "_": { + "armed": "Armado", + "armed_away": "Armado fuera de casa", + "armed_custom_bypass": "Armada Zona Espec\u00edfica", + "armed_home": "Armado en casa", + "armed_night": "Armado noche", + "arming": "Armando", + "disarmed": "Desarmado", + "disarming": "Desarmando", + "pending": "Pendiente", + "triggered": "Disparada" + } + }, + "title": "Panel de control de alarmas" +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/translations/et.json b/homeassistant/components/alarm_control_panel/translations/et.json new file mode 100644 index 0000000000000..28c47b5a06d97 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/translations/et.json @@ -0,0 +1,17 @@ +{ + "state": { + "_": { + "armed": "Valves", + "armed_away": "Valves eemal", + "armed_custom_bypass": "Valves, eranditega", + "armed_home": "Valves kodus", + "armed_night": "Valves \u00f6ine", + "arming": "Valvestab", + "disarmed": "Maas", + "disarming": "Maas...", + "pending": "Ootel", + "triggered": "H\u00e4ires" + } + }, + "title": "Valvekeskuse juhtpaneel" +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/translations/eu.json b/homeassistant/components/alarm_control_panel/translations/eu.json new file mode 100644 index 0000000000000..e483eeac44db0 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/translations/eu.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "pending": "Zain", + "triggered": "Abiarazita" + } + }, + "title": "Alarmen kontrol panela" +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/translations/fa.json b/homeassistant/components/alarm_control_panel/translations/fa.json new file mode 100644 index 0000000000000..1aa489f7d93dc --- /dev/null +++ b/homeassistant/components/alarm_control_panel/translations/fa.json @@ -0,0 +1,17 @@ +{ + "state": { + "_": { + "armed": "\u0645\u0635\u0644\u062d \u0634\u062f\u0647", + "armed_away": "\u0645\u0633\u0644\u062d \u0634\u062f\u0647 \u0628\u06cc\u0631\u0648\u0646", + "armed_custom_bypass": "\u0628\u0627\u06cc\u06af\u0627\u0646\u06cc \u0633\u0641\u0627\u0631\u0634\u06cc \u0645\u0633\u0644\u062d", + "armed_home": "\u0645\u0633\u0644\u062d \u0634\u062f\u0647 \u062e\u0627\u0646\u0647", + "armed_night": "\u0645\u0633\u0644\u062d \u0634\u062f\u0647 \u0634\u0628", + "arming": "\u062f\u0631 \u062d\u0627\u0644 \u0645\u0633\u0644\u062d \u06a9\u0631\u062f\u0646", + "disarmed": "\u063a\u06cc\u0631 \u0645\u0633\u0644\u062d", + "disarming": "\u062f\u0631 \u062d\u0627\u0644 \u063a\u06cc\u0631 \u0645\u0633\u0644\u062d \u06a9\u0631\u062f\u0646", + "pending": "\u062f\u0631 \u0627\u0646\u062a\u0638\u0627\u0631", + "triggered": "\u0631\u0627\u0647 \u0627\u0646\u062f\u0627\u062e\u062a\u0647 \u0634\u062f\u0647" + } + }, + "title": "\u06a9\u0646\u062a\u0631\u0644 \u067e\u0646\u0644 \u0622\u0644\u0627\u0631\u0645" +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/translations/fi.json b/homeassistant/components/alarm_control_panel/translations/fi.json new file mode 100644 index 0000000000000..1a77c62145825 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/translations/fi.json @@ -0,0 +1,17 @@ +{ + "state": { + "_": { + "armed": "Viritetty", + "armed_away": "Viritetty (poissa)", + "armed_custom_bypass": "Virityksen ohittaminen", + "armed_home": "Viritetty (kotona)", + "armed_night": "Viritetty (y\u00f6)", + "arming": "Viritys", + "disarmed": "Viritys pois", + "disarming": "Virityksen poisto", + "pending": "Odottaa", + "triggered": "Lauennut" + } + }, + "title": "H\u00e4lytysasetukset" +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/translations/fr.json b/homeassistant/components/alarm_control_panel/translations/fr.json new file mode 100644 index 0000000000000..597b3d0d2f20d --- /dev/null +++ b/homeassistant/components/alarm_control_panel/translations/fr.json @@ -0,0 +1,40 @@ +{ + "device_automation": { + "action_type": { + "arm_away": "Armer {entity_name} en mode \"sortie\"", + "arm_home": "Armer {entity_name} en mode \"maison\"", + "arm_night": "Armer {entity_name} en mode \"nuit\"", + "disarm": "D\u00e9sarmer {entity_name}", + "trigger": "D\u00e9clencheur {entity_name}" + }, + "condition_type": { + "is_armed_away": "{entity_name} est arm\u00e9", + "is_armed_home": "{entity_name} est arm\u00e9 \u00e0 la maison", + "is_armed_night": "{entity_name} est arm\u00e9 la nuit", + "is_disarmed": "{entity_name} est d\u00e9sarm\u00e9", + "is_triggered": "{entity_name} est d\u00e9clench\u00e9" + }, + "trigger_type": { + "armed_away": "Armer {entity_name} en mode \"sortie\"", + "armed_home": "Armer {entity_name} en mode \"maison\"", + "armed_night": "Armer {entity_name} en mode \"nuit\"", + "disarmed": "{entity_name} d\u00e9sarm\u00e9", + "triggered": "{entity_name} d\u00e9clench\u00e9" + } + }, + "state": { + "_": { + "armed": "Activ\u00e9", + "armed_away": "Enclench\u00e9e (absent)", + "armed_custom_bypass": "Activ\u00e9e avec exception", + "armed_home": "Enclench\u00e9e (pr\u00e9sent)", + "armed_night": "Enclench\u00e9 (nuit)", + "arming": "Activation", + "disarmed": "D\u00e9sactiv\u00e9e", + "disarming": "D\u00e9sactivation", + "pending": "En attente", + "triggered": "D\u00e9clench\u00e9" + } + }, + "title": "Panneau de contr\u00f4le d'alarme" +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/translations/gsw.json b/homeassistant/components/alarm_control_panel/translations/gsw.json new file mode 100644 index 0000000000000..615ad7dc95073 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/translations/gsw.json @@ -0,0 +1,15 @@ +{ + "state": { + "_": { + "armed": "Scharf", + "armed_away": "Scharf usswerts", + "armed_home": "Scharf dihei", + "armed_night": "Scharf Nacht", + "arming": "Scharf stel\u00e4", + "disarmed": "Nid scharf", + "disarming": "Entsperr\u00e4", + "pending": "Usstehehnd", + "triggered": "Usgl\u00f6sst" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/translations/he.json b/homeassistant/components/alarm_control_panel/translations/he.json new file mode 100644 index 0000000000000..544b23f5629fa --- /dev/null +++ b/homeassistant/components/alarm_control_panel/translations/he.json @@ -0,0 +1,17 @@ +{ + "state": { + "_": { + "armed": "\u05d3\u05e8\u05d5\u05da", + "armed_away": "\u05d3\u05e8\u05d5\u05da \u05dc\u05d0 \u05d1\u05d1\u05d9\u05ea", + "armed_custom_bypass": "\u05de\u05e2\u05e7\u05e3 \u05de\u05d5\u05ea\u05d0\u05dd \u05d0\u05d9\u05e9\u05d9\u05ea \u05d3\u05e8\u05d5\u05da", + "armed_home": "\u05d4\u05d1\u05d9\u05ea \u05d3\u05e8\u05d5\u05da", + "armed_night": "\u05d3\u05e8\u05d5\u05da \u05dc\u05d9\u05dc\u05d4", + "arming": "\u05de\u05e4\u05e2\u05d9\u05dc", + "disarmed": "\u05de\u05e0\u05d5\u05d8\u05e8\u05dc", + "disarming": "\u05de\u05e0\u05d8\u05e8\u05dc", + "pending": "\u05de\u05de\u05ea\u05d9\u05df", + "triggered": "\u05d4\u05d5\u05e4\u05e2\u05dc" + } + }, + "title": "\u05dc\u05d5\u05d7 \u05d1\u05e7\u05e8\u05d4 \u05e9\u05dc \u05d0\u05d6\u05e2\u05e7\u05d4" +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/translations/hr.json b/homeassistant/components/alarm_control_panel/translations/hr.json new file mode 100644 index 0000000000000..57308c14e3039 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/translations/hr.json @@ -0,0 +1,17 @@ +{ + "state": { + "_": { + "armed": "Aktiviran", + "armed_away": "Aktiviran odsutno", + "armed_custom_bypass": "Aktiviran", + "armed_home": "Aktiviran doma", + "armed_night": "Aktiviran no\u010dni", + "arming": "Aktiviranje", + "disarmed": "Deaktiviran", + "disarming": "Deaktiviranje", + "pending": "U tijeku", + "triggered": "Okinut" + } + }, + "title": "Upravlja\u010dka plo\u010da za alarm" +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/translations/hu.json b/homeassistant/components/alarm_control_panel/translations/hu.json new file mode 100644 index 0000000000000..81fa10311ef27 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/translations/hu.json @@ -0,0 +1,33 @@ +{ + "device_automation": { + "action_type": { + "arm_away": "{entity_name} \u00e9les\u00edt\u00e9se t\u00e1voz\u00f3 m\u00f3dban", + "arm_home": "{entity_name} \u00e9les\u00edt\u00e9se otthon marad\u00f3 m\u00f3dban", + "arm_night": "{entity_name} \u00e9les\u00edt\u00e9se \u00e9jszakai m\u00f3dban", + "disarm": "{entity_name} hat\u00e1stalan\u00edt\u00e1sa", + "trigger": "{entity_name} riaszt\u00e1si esem\u00e9ny ind\u00edt\u00e1sa" + }, + "trigger_type": { + "armed_away": "{entity_name} t\u00e1voz\u00f3 m\u00f3dban lett \u00e9les\u00edtve", + "armed_home": "{entity_name} otthon marad\u00f3 m\u00f3dban lett \u00e9les\u00edtve", + "armed_night": "{entity_name} \u00e9jszakai m\u00f3dban lett \u00e9les\u00edtve", + "disarmed": "{entity_name} hat\u00e1stalan\u00edtva lett", + "triggered": "{entity_name} riaszt\u00e1sba ker\u00fclt" + } + }, + "state": { + "_": { + "armed": "\u00c9les\u00edtve", + "armed_away": "\u00c9les\u00edtve t\u00e1vol", + "armed_custom_bypass": "\u00c9les\u00edtve \u00e1thidal\u00e1ssal", + "armed_home": "\u00c9les\u00edtve otthon", + "armed_night": "\u00c9les\u00edtve \u00e9jszaka", + "arming": "\u00c9les\u00edt\u00e9s", + "disarmed": "Hat\u00e1stalan\u00edtva", + "disarming": "Hat\u00e1stalan\u00edt\u00e1s", + "pending": "F\u00fcgg\u0151ben", + "triggered": "Riaszt\u00e1s" + } + }, + "title": "Riaszt\u00f3 k\u00f6zpont" +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/translations/hy.json b/homeassistant/components/alarm_control_panel/translations/hy.json new file mode 100644 index 0000000000000..58788b3357753 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/translations/hy.json @@ -0,0 +1,17 @@ +{ + "state": { + "_": { + "armed": "\u0536\u056b\u0576\u057e\u0561\u056e", + "armed_away": "\u0536\u056b\u0576\u057e\u0561\u056e", + "armed_custom_bypass": "\u0536\u056b\u0576\u0574\u0561\u0576 \u0561\u0576\u0570\u0561\u057f\u0561\u056f\u0561\u0576 \u056f\u0578\u0564", + "armed_home": "\u0536\u056b\u0576\u057e\u0561\u056e \u057f\u0578\u0582\u0576", + "armed_night": "\u0536\u056b\u0576\u057e\u0561\u056e \u0563\u056b\u0577\u0565\u0580", + "arming": "\u0536\u056b\u0576\u0565\u056c", + "disarmed": "\u0536\u056b\u0576\u0561\u0569\u0561\u0583\u057e\u0561\u056e", + "disarming": "\u0536\u056b\u0576\u0561\u0569\u0561\u0583\u0578\u0572", + "pending": "\u054d\u057a\u0561\u057d\u0578\u0582\u0574", + "triggered": "\u057a\u0561\u057f\u0573\u0561\u057c\u0568" + } + }, + "title": "\u054f\u0561\u0563\u0576\u0561\u057a\u056b \u056f\u0561\u057c\u0561\u057e\u0561\u0580\u0574\u0561\u0576 \u057e\u0561\u0570\u0561\u0576\u0561\u056f" +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/translations/id.json b/homeassistant/components/alarm_control_panel/translations/id.json new file mode 100644 index 0000000000000..cbc3d31370c9f --- /dev/null +++ b/homeassistant/components/alarm_control_panel/translations/id.json @@ -0,0 +1,17 @@ +{ + "state": { + "_": { + "armed": "Bersenjata", + "armed_away": "Armed away", + "armed_custom_bypass": "Armed custom bypass", + "armed_home": "Armed home", + "armed_night": "Armed night", + "arming": "Mempersenjatai", + "disarmed": "Dilucuti", + "disarming": "Melucuti", + "pending": "Tertunda", + "triggered": "Terpicu" + } + }, + "title": "Kontrol panel alarm" +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/translations/is.json b/homeassistant/components/alarm_control_panel/translations/is.json new file mode 100644 index 0000000000000..eda11e6177f65 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/translations/is.json @@ -0,0 +1,16 @@ +{ + "state": { + "_": { + "armed": "\u00c1 ver\u00f0i", + "armed_away": "\u00c1 ver\u00f0i \u00fati", + "armed_home": "\u00c1 ver\u00f0i heima", + "armed_night": "\u00c1 ver\u00f0i n\u00f3tt", + "arming": "Set \u00e1 v\u00f6r\u00f0", + "disarmed": "ekki \u00e1 ver\u00f0i", + "disarming": "tek af ver\u00f0i", + "pending": "B\u00ed\u00f0ur", + "triggered": "R\u00e6st" + } + }, + "title": "Stj\u00f3rnbor\u00f0 \u00f6ryggiskerfis" +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/translations/it.json b/homeassistant/components/alarm_control_panel/translations/it.json new file mode 100644 index 0000000000000..1574f88541b20 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/translations/it.json @@ -0,0 +1,40 @@ +{ + "device_automation": { + "action_type": { + "arm_away": "Armare {entity_name} uscito", + "arm_home": "Armare {entity_name} casa", + "arm_night": "Armare {entity_name} notte", + "disarm": "Disarmare {entity_name}", + "trigger": "Attivazione {entity_name}" + }, + "condition_type": { + "is_armed_away": "{entity_name} \u00e8 attivo in modalit\u00e0 fuori casa", + "is_armed_home": "{entity_name} \u00e8 attivo in modalit\u00e0 a casa", + "is_armed_night": "{entity_name} \u00e8 attivo in modalit\u00e0 notte", + "is_disarmed": "{entity_name} \u00e8 disattivo", + "is_triggered": "{entity_name} \u00e8 attivato" + }, + "trigger_type": { + "armed_away": "{entity_name} attivato in modalit\u00e0 fuori casa", + "armed_home": "{entity_name} attivato in modalit\u00e0 a casa", + "armed_night": "{entity_name} attivato in modalit\u00e0 notte", + "disarmed": "{entity_name} disattivato", + "triggered": "{entity_name} attivato" + } + }, + "state": { + "_": { + "armed": "Attivo", + "armed_away": "Attivo fuori casa", + "armed_custom_bypass": "Attivo con bypass personalizzato", + "armed_home": "Attivo in casa", + "armed_night": "Attivo Notte", + "arming": "In Attivazione", + "disarmed": "Disattivo", + "disarming": "In Disattivazione", + "pending": "In sospeso", + "triggered": "Attivato" + } + }, + "title": "Pannello di Controllo degli Allarmi" +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/translations/ja.json b/homeassistant/components/alarm_control_panel/translations/ja.json new file mode 100644 index 0000000000000..3eceb75b5973d --- /dev/null +++ b/homeassistant/components/alarm_control_panel/translations/ja.json @@ -0,0 +1,7 @@ +{ + "state": { + "_": { + "triggered": "\u30c8\u30ea\u30ac\u30fc" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/translations/ko.json b/homeassistant/components/alarm_control_panel/translations/ko.json new file mode 100644 index 0000000000000..f6adb68fe6609 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/translations/ko.json @@ -0,0 +1,40 @@ +{ + "device_automation": { + "action_type": { + "arm_away": "{entity_name} \uc678\ucd9c\uacbd\ube44", + "arm_home": "{entity_name} \uc7ac\uc2e4\uacbd\ube44", + "arm_night": "{entity_name} \uc57c\uac04\uacbd\ube44", + "disarm": "{entity_name} \uacbd\ube44\ud574\uc81c", + "trigger": "{entity_name} \ud2b8\ub9ac\uac70" + }, + "condition_type": { + "is_armed_away": "{entity_name} \uc774(\uac00) \uc678\ucd9c \uacbd\ube44\ubaa8\ub4dc \uc0c1\ud0dc\uc774\uba74", + "is_armed_home": "{entity_name} \uc774(\uac00) \uc7ac\uc2e4 \uacbd\ube44\ubaa8\ub4dc \uc0c1\ud0dc\uc774\uba74", + "is_armed_night": "{entity_name} \uc774(\uac00) \uc57c\uac04 \uacbd\ube44\ubaa8\ub4dc \uc0c1\ud0dc\uc774\uba74", + "is_disarmed": "{entity_name} \uc774(\uac00) \ud574\uc81c \uc0c1\ud0dc\uc774\uba74", + "is_triggered": "{entity_name} \uc774(\uac00) \ud2b8\ub9ac\uac70\ub418\uc5c8\uc73c\uba74" + }, + "trigger_type": { + "armed_away": "{entity_name} \uc774(\uac00) \uc678\ucd9c \uacbd\ube44\ubaa8\ub4dc\ub85c \uc124\uc815\ub420 \ub54c", + "armed_home": "{entity_name} \uc774(\uac00) \uc7ac\uc2e4 \uacbd\ube44\ubaa8\ub4dc\ub85c \uc124\uc815\ub420 \ub54c", + "armed_night": "{entity_name} \uc774(\uac00) \uc57c\uac04 \uacbd\ube44\ubaa8\ub4dc\ub85c \uc124\uc815\ub420 \ub54c", + "disarmed": "{entity_name} \uc774(\uac00) \ud574\uc81c\ub420 \ub54c", + "triggered": "{entity_name} \uc774(\uac00) \ud2b8\ub9ac\uac70\ub420 \ub54c" + } + }, + "state": { + "_": { + "armed": "\uacbd\ube44\uc911", + "armed_away": "\uacbd\ube44\uc911(\uc678\ucd9c)", + "armed_custom_bypass": "\uacbd\ube44\uc911(\uc0ac\uc6a9\uc790 \uc6b0\ud68c)", + "armed_home": "\uacbd\ube44\uc911(\uc7ac\uc2e4)", + "armed_night": "\uacbd\ube44\uc911(\uc57c\uac04)", + "arming": "\uacbd\ube44\uc911", + "disarmed": "\ud574\uc81c\ub428", + "disarming": "\ud574\uc81c\uc911", + "pending": "\ubcf4\ub958\uc911", + "triggered": "\uc791\ub3d9\ub428" + } + }, + "title": "\uc54c\ub78c\uc81c\uc5b4\ud310" +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/translations/lb.json b/homeassistant/components/alarm_control_panel/translations/lb.json new file mode 100644 index 0000000000000..5a4416937262a --- /dev/null +++ b/homeassistant/components/alarm_control_panel/translations/lb.json @@ -0,0 +1,40 @@ +{ + "device_automation": { + "action_type": { + "arm_away": "{entity_name} fir \u00ebnnerwee uschalten", + "arm_home": "{entity_name} fir doheem uschalten", + "arm_night": "{entity_name} fir Nuecht uschalten", + "disarm": "{entity_name} entsch\u00e4rfen", + "trigger": "{entity_name} ausl\u00e9isen" + }, + "condition_type": { + "is_armed_away": "{entity_name} ass ugeschalt fir Ennerwee", + "is_armed_home": "{entity_name} ass ugeschalt fir Doheem", + "is_armed_night": "{entity_name} ass ugeschalt fir Nuecht", + "is_disarmed": "{entity_name} ass entsch\u00e4rft", + "is_triggered": "{entity_name} ass ausgel\u00e9ist" + }, + "trigger_type": { + "armed_away": "{entity_name} ugeschalt fir Ennerwee", + "armed_home": "{entity_name} ugeschalt fir Doheem", + "armed_night": "{entity_name} ugeschalt fir Nuecht", + "disarmed": "{entity_name} entsch\u00e4rft", + "triggered": "{entity_name} ausgel\u00e9ist" + } + }, + "state": { + "_": { + "armed": "Aktiv\u00e9iert", + "armed_away": "Aktiv\u00e9iert \u00cbnnerwee", + "armed_custom_bypass": "Aktiv, Benotzerdefin\u00e9iert", + "armed_home": "Aktiv\u00e9iert Doheem", + "armed_night": "Aktiv\u00e9iert Nuecht", + "arming": "Aktiv\u00e9ieren", + "disarmed": "Desaktiv\u00e9iert", + "disarming": "Desaktiv\u00e9ieren", + "pending": "Ustoend", + "triggered": "Ausgel\u00e9ist" + } + }, + "title": "Kontroll Feld Alarm" +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/translations/lt.json b/homeassistant/components/alarm_control_panel/translations/lt.json new file mode 100644 index 0000000000000..c8a442460045b --- /dev/null +++ b/homeassistant/components/alarm_control_panel/translations/lt.json @@ -0,0 +1,13 @@ +{ + "state": { + "_": { + "armed": "U\u017erakinta", + "armed_home": "Nam\u0173 apsauga \u012fjungta", + "arming": "Saugojimo re\u017eimo \u012fjungimas", + "disarmed": "Atrakinta", + "disarming": "Saugojimo re\u017eimo i\u0161jungimas", + "pending": "Laukiama", + "triggered": "Aktyvinta" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/translations/lv.json b/homeassistant/components/alarm_control_panel/translations/lv.json new file mode 100644 index 0000000000000..e77f05f4812fb --- /dev/null +++ b/homeassistant/components/alarm_control_panel/translations/lv.json @@ -0,0 +1,17 @@ +{ + "state": { + "_": { + "armed": "Piesl\u0113gta", + "armed_away": "Piesl\u0113gta uz promb\u016btni", + "armed_custom_bypass": "Piesl\u0113gts piel\u0101gots apvedce\u013c\u0161", + "armed_home": "Piesl\u0113gta m\u0101j\u0101s", + "armed_night": "Piesl\u0113gta uz nakti", + "arming": "Piesl\u0113dzas", + "disarmed": "Atsl\u0113gta", + "disarming": "Atsl\u0113dzas", + "pending": "Gaida", + "triggered": "Aktiviz\u0113ta" + } + }, + "title": "Signaliz\u0101cijas vad\u012bbas panelis" +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/translations/nb.json b/homeassistant/components/alarm_control_panel/translations/nb.json new file mode 100644 index 0000000000000..ec2e8b92e1e08 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/translations/nb.json @@ -0,0 +1,17 @@ +{ + "state": { + "_": { + "armed": "Armert", + "armed_away": "Armert borte", + "armed_custom_bypass": "Armert tilpasset unntak", + "armed_home": "Armert hjemme", + "armed_night": "Armert natt", + "arming": "Armerer", + "disarmed": "Avsl\u00e5tt", + "disarming": "Skrur av", + "pending": "Venter", + "triggered": "Utl\u00f8st" + } + }, + "title": "Alarm kontrollpanel" +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/translations/nl.json b/homeassistant/components/alarm_control_panel/translations/nl.json new file mode 100644 index 0000000000000..15b5fd8457c2e --- /dev/null +++ b/homeassistant/components/alarm_control_panel/translations/nl.json @@ -0,0 +1,40 @@ +{ + "device_automation": { + "action_type": { + "arm_away": "Inschakelen {entity_name} afwezig", + "arm_home": "Inschakelen {entity_name} thuis", + "arm_night": "Inschakelen {entity_name} nacht", + "disarm": "Uitschakelen {entity_name}", + "trigger": "Trigger {entity_name}" + }, + "condition_type": { + "is_armed_away": "{entity_name} afwezig ingeschakeld", + "is_armed_home": "{entity_name} thuis ingeschakeld", + "is_armed_night": "{entity_name} nachtstand ingeschakeld", + "is_disarmed": "{entity_name} is uitgeschakeld", + "is_triggered": "{entity_name} wordt geactiveerd" + }, + "trigger_type": { + "armed_away": "{entity_name} afwezig ingeschakeld", + "armed_home": "{entity_name} thuis ingeschakeld", + "armed_night": "{entity_name} nachtstand ingeschakeld", + "disarmed": "{entity_name} uitgeschakeld", + "triggered": "{entity_name} geactiveerd" + } + }, + "state": { + "_": { + "armed": "Ingeschakeld", + "armed_away": "Afwezig Ingeschakeld", + "armed_custom_bypass": "Ingeschakeld met overbrugging(en)", + "armed_home": "Ingeschakeld thuis", + "armed_night": "Ingeschakeld nacht", + "arming": "Schakelt in", + "disarmed": "Uitgeschakeld", + "disarming": "Schakelt uit", + "pending": "In wacht", + "triggered": "Geactiveerd" + } + }, + "title": "Alarm bedieningspaneel" +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/translations/nn.json b/homeassistant/components/alarm_control_panel/translations/nn.json new file mode 100644 index 0000000000000..f8932a995b931 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/translations/nn.json @@ -0,0 +1,17 @@ +{ + "state": { + "_": { + "armed": "P\u00e5sl\u00e5tt", + "armed_away": "P\u00e5 for borte", + "armed_custom_bypass": "Armert tilpassa unntak", + "armed_home": "P\u00e5 for heime", + "armed_night": "P\u00e5 for natta", + "arming": "Skrur p\u00e5", + "disarmed": "Avsl\u00e5tt", + "disarming": "Skrur av", + "pending": "I vente av", + "triggered": "Utl\u00f8yst" + } + }, + "title": "Alarmkontrollpanel" +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/translations/no.json b/homeassistant/components/alarm_control_panel/translations/no.json new file mode 100644 index 0000000000000..465dd250086a2 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/translations/no.json @@ -0,0 +1,40 @@ +{ + "device_automation": { + "action_type": { + "arm_away": "Aktiver {entity_name} borte", + "arm_home": "Aktiver {entity_name} hjemme", + "arm_night": "Aktiver {entity_name} natt", + "disarm": "Deaktiver {entity_name}", + "trigger": "Utl\u00f8ser {entity_name}" + }, + "condition_type": { + "is_armed_away": "{entity_name} er aktivert borte", + "is_armed_home": "{entity_name} er aktivert hjemme", + "is_armed_night": "{entity_name} er aktivert natt", + "is_disarmed": "{entity_name} er deaktivert", + "is_triggered": "{entity_name} er utl\u00f8st" + }, + "trigger_type": { + "armed_away": "{entity_name} aktivert borte", + "armed_home": "{entity_name} aktivert hjemme", + "armed_night": "{entity_name} aktivert natt", + "disarmed": "{entity_name} deaktivert", + "triggered": "{entity_name} utl\u00f8st" + } + }, + "state": { + "_": { + "armed": "Armert", + "armed_away": "Armert borte", + "armed_custom_bypass": "Armert tilpasset unntak", + "armed_home": "Armert hjemme", + "armed_night": "Armert natt", + "arming": "Armerer", + "disarmed": "Avsl\u00e5tt", + "disarming": "Disarmer", + "pending": "Ventende", + "triggered": "Utl\u00f8st" + } + }, + "title": "Alarm kontrollpanel" +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/translations/pl.json b/homeassistant/components/alarm_control_panel/translations/pl.json new file mode 100644 index 0000000000000..ca61dc870ea76 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/translations/pl.json @@ -0,0 +1,40 @@ +{ + "device_automation": { + "action_type": { + "arm_away": "uzbr\u00f3j (poza domem) {entity_name}", + "arm_home": "uzbr\u00f3j (w domu) {entity_name}", + "arm_night": "uzbr\u00f3j (noc) {entity_name}", + "disarm": "rozbr\u00f3j {entity_name}", + "trigger": "wyzw\u00f3l {entity_name}" + }, + "condition_type": { + "is_armed_away": "{entity_name} jest uzbrojony (poza domem)", + "is_armed_home": "{entity_name} jest uzbrojony (w domu)", + "is_armed_night": "{entity_name} jest uzbrojony (noc)", + "is_disarmed": "{entity_name} jest rozbrojony", + "is_triggered": "{entity_name} jest wyzwolony" + }, + "trigger_type": { + "armed_away": "{entity_name} zostanie uzbrojony (poza domem)", + "armed_home": "{entity_name} zostanie uzbrojony (w domu)", + "armed_night": "{entity_name} zostanie uzbrojony (noc)", + "disarmed": "{entity_name} zostanie rozbrojony", + "triggered": "{entity_name} zostanie wyzwolony" + } + }, + "state": { + "_": { + "armed": "uzbrojony", + "armed_away": "uzbrojony (poza domem)", + "armed_custom_bypass": "uzbrojony (cz\u0119\u015bciowo)", + "armed_home": "uzbrojony (w domu)", + "armed_night": "uzbrojony (noc)", + "arming": "uzbrajanie", + "disarmed": "rozbrojony", + "disarming": "rozbrajanie", + "pending": "oczekuje", + "triggered": "wyzwolony" + } + }, + "title": "Panel kontrolny alarmu" +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/translations/pt-BR.json b/homeassistant/components/alarm_control_panel/translations/pt-BR.json new file mode 100644 index 0000000000000..a056e1f418773 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/translations/pt-BR.json @@ -0,0 +1,33 @@ +{ + "device_automation": { + "action_type": { + "arm_away": "Armar {entity_name} longe", + "arm_home": "Armar {entity_name} casa", + "arm_night": "Armar {entity_name} noite", + "disarm": "Desarmar {entity_name}", + "trigger": "Disparar {entidade_nome}" + }, + "trigger_type": { + "armed_away": "{entity_name} armado modo longe", + "armed_home": "{entity_name} armadado modo casa", + "armed_night": "{entity_name} armadado para noite", + "disarmed": "{entity_name} desarmado", + "triggered": "{entity_name} acionado" + } + }, + "state": { + "_": { + "armed": "Armado", + "armed_away": "Armado ausente", + "armed_custom_bypass": "Armado em \u00e1reas espec\u00edficas", + "armed_home": "Armado casa", + "armed_night": "Armado noite", + "arming": "Armando", + "disarmed": "Desarmado", + "disarming": "Desarmando", + "pending": "Pendente", + "triggered": "Acionado" + } + }, + "title": "Painel de controle do alarme" +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/translations/pt.json b/homeassistant/components/alarm_control_panel/translations/pt.json new file mode 100644 index 0000000000000..e4293b8173192 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/translations/pt.json @@ -0,0 +1,24 @@ +{ + "device_automation": { + "action_type": { + "arm_home": "Armar casa {entity_name}", + "arm_night": "Armar noite {entity_name}", + "disarm": "Desarmar {entity_name}" + } + }, + "state": { + "_": { + "armed": "Armado", + "armed_away": "Armado ausente", + "armed_custom_bypass": "Armado com desvio personalizado", + "armed_home": "Armado Casa", + "armed_night": "Armado noite", + "arming": "A armar", + "disarmed": "Desarmado", + "disarming": "A desarmar", + "pending": "Pendente", + "triggered": "Despoletado" + } + }, + "title": "Painel de controlo do alarme" +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/translations/ro.json b/homeassistant/components/alarm_control_panel/translations/ro.json new file mode 100644 index 0000000000000..57af2d045d3d8 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/translations/ro.json @@ -0,0 +1,17 @@ +{ + "state": { + "_": { + "armed": "Armat", + "armed_away": "Armat plecat", + "armed_custom_bypass": "Armare personalizat\u0103", + "armed_home": "Armat acas\u0103", + "armed_night": "Armat noaptea", + "arming": "Armare", + "disarmed": "Dezarmat", + "disarming": "Dezarmare", + "pending": "\u00cen a\u0219teptare", + "triggered": "Declan\u0219at" + } + }, + "title": "Panoul de control alarma" +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/translations/ru.json b/homeassistant/components/alarm_control_panel/translations/ru.json new file mode 100644 index 0000000000000..f390f017328f1 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/translations/ru.json @@ -0,0 +1,40 @@ +{ + "device_automation": { + "action_type": { + "arm_away": "\u0412\u043a\u043b\u044e\u0447\u0438\u0442\u044c \u0440\u0435\u0436\u0438\u043c \u043e\u0445\u0440\u0430\u043d\u044b \"\u041d\u0435 \u0434\u043e\u043c\u0430\" \u043d\u0430 \u043f\u0430\u043d\u0435\u043b\u0438 {entity_name}", + "arm_home": "\u0412\u043a\u043b\u044e\u0447\u0438\u0442\u044c \u0440\u0435\u0436\u0438\u043c \u043e\u0445\u0440\u0430\u043d\u044b \"\u0414\u043e\u043c\u0430\" \u043d\u0430 \u043f\u0430\u043d\u0435\u043b\u0438 {entity_name}", + "arm_night": "\u0412\u043a\u043b\u044e\u0447\u0438\u0442\u044c \u0440\u0435\u0436\u0438\u043c \u043e\u0445\u0440\u0430\u043d\u044b \"\u041d\u043e\u0447\u044c\" \u043d\u0430 \u043f\u0430\u043d\u0435\u043b\u0438 {entity_name}", + "disarm": "\u041e\u0442\u043a\u043b\u044e\u0447\u0438\u0442\u044c \u043e\u0445\u0440\u0430\u043d\u0443 \u043d\u0430 \u043f\u0430\u043d\u0435\u043b\u0438 {entity_name}", + "trigger": "{entity_name} \u0441\u0440\u0430\u0431\u0430\u0442\u044b\u0432\u0430\u0435\u0442" + }, + "condition_type": { + "is_armed_away": "\u0412\u043a\u043b\u044e\u0447\u0435\u043d \u0440\u0435\u0436\u0438\u043c \u043e\u0445\u0440\u0430\u043d\u044b \"\u041d\u0435 \u0434\u043e\u043c\u0430\" \u043d\u0430 \u043f\u0430\u043d\u0435\u043b\u0438 {entity_name}", + "is_armed_home": "\u0412\u043a\u043b\u044e\u0447\u0435\u043d \u0440\u0435\u0436\u0438\u043c \u043e\u0445\u0440\u0430\u043d\u044b \"\u0414\u043e\u043c\u0430\" \u043d\u0430 \u043f\u0430\u043d\u0435\u043b\u0438 {entity_name}", + "is_armed_night": "\u0412\u043a\u043b\u044e\u0447\u0435\u043d \u0440\u0435\u0436\u0438\u043c \u043e\u0445\u0440\u0430\u043d\u044b \"\u041d\u043e\u0447\u044c\" \u043d\u0430 \u043f\u0430\u043d\u0435\u043b\u0438 {entity_name}", + "is_disarmed": "\u041e\u0442\u043a\u043b\u044e\u0447\u0435\u043d\u0430 \u043e\u0445\u0440\u0430\u043d\u0430 \u043d\u0430 \u043f\u0430\u043d\u0435\u043b\u0438 {entity_name}", + "is_triggered": "{entity_name} \u0441\u0440\u0430\u0431\u0430\u0442\u044b\u0432\u0430\u0435\u0442" + }, + "trigger_type": { + "armed_away": "\u0412\u043a\u043b\u044e\u0447\u0435\u043d \u0440\u0435\u0436\u0438\u043c \u043e\u0445\u0440\u0430\u043d\u044b \"\u041d\u0435 \u0434\u043e\u043c\u0430\" \u043d\u0430 \u043f\u0430\u043d\u0435\u043b\u0438 {entity_name}", + "armed_home": "\u0412\u043a\u043b\u044e\u0447\u0435\u043d \u0440\u0435\u0436\u0438\u043c \u043e\u0445\u0440\u0430\u043d\u044b \"\u0414\u043e\u043c\u0430\" \u043d\u0430 \u043f\u0430\u043d\u0435\u043b\u0438 {entity_name}", + "armed_night": "\u0412\u043a\u043b\u044e\u0447\u0435\u043d \u0440\u0435\u0436\u0438\u043c \u043e\u0445\u0440\u0430\u043d\u044b \"\u041d\u043e\u0447\u044c\" \u043d\u0430 \u043f\u0430\u043d\u0435\u043b\u0438 {entity_name}", + "disarmed": "\u041e\u0442\u043a\u043b\u044e\u0447\u0435\u043d\u0430 \u043e\u0445\u0440\u0430\u043d\u0430 \u043d\u0430 \u043f\u0430\u043d\u0435\u043b\u0438 {entity_name}", + "triggered": "{entity_name} \u0441\u0440\u0430\u0431\u0430\u0442\u044b\u0432\u0430\u0435\u0442" + } + }, + "state": { + "_": { + "armed": "\u041f\u043e\u0434 \u043e\u0445\u0440\u0430\u043d\u043e\u0439", + "armed_away": "\u041e\u0445\u0440\u0430\u043d\u0430 (\u043d\u0435 \u0434\u043e\u043c\u0430)", + "armed_custom_bypass": "\u041e\u0445\u0440\u0430\u043d\u0430 \u0441 \u0438\u0441\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f\u043c\u0438", + "armed_home": "\u041e\u0445\u0440\u0430\u043d\u0430 (\u0434\u043e\u043c\u0430)", + "armed_night": "\u041e\u0445\u0440\u0430\u043d\u0430 (\u043d\u043e\u0447\u044c)", + "arming": "\u041f\u043e\u0441\u0442\u0430\u043d\u043e\u0432\u043a\u0430 \u043d\u0430 \u043e\u0445\u0440\u0430\u043d\u0443", + "disarmed": "\u0421\u043d\u044f\u0442\u043e \u0441 \u043e\u0445\u0440\u0430\u043d\u044b", + "disarming": "\u0421\u043d\u044f\u0442\u0438\u0435 \u0441 \u043e\u0445\u0440\u0430\u043d\u044b", + "pending": "\u041f\u0435\u0440\u0435\u0445\u043e\u0434 \u043d\u0430 \u043e\u0445\u0440\u0430\u043d\u0443", + "triggered": "\u0421\u0440\u0430\u0431\u0430\u0442\u044b\u0432\u0430\u043d\u0438\u0435" + } + }, + "title": "\u041f\u0430\u043d\u0435\u043b\u044c \u0441\u0438\u0433\u043d\u0430\u043b\u0438\u0437\u0430\u0446\u0438\u0438" +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/translations/sk.json b/homeassistant/components/alarm_control_panel/translations/sk.json new file mode 100644 index 0000000000000..ceff70c00a666 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/translations/sk.json @@ -0,0 +1,17 @@ +{ + "state": { + "_": { + "armed": "Akt\u00edvny", + "armed_away": "Akt\u00edvny v nepr\u00edtomnosti", + "armed_custom_bypass": "Zak\u00f3dovan\u00e9 prisp\u00f4soben\u00e9 vyl\u00fa\u010denie", + "armed_home": "Akt\u00edvny doma", + "armed_night": "Akt\u00edvny v noci", + "arming": "Aktivuje sa", + "disarmed": "Neakt\u00edvny", + "disarming": "Deaktivuje sa", + "pending": "\u010cak\u00e1 sa", + "triggered": "Spusten\u00fd" + } + }, + "title": "Ovl\u00e1dac\u00ed panel alarmu" +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/translations/sl.json b/homeassistant/components/alarm_control_panel/translations/sl.json new file mode 100644 index 0000000000000..6ccef2cead6bd --- /dev/null +++ b/homeassistant/components/alarm_control_panel/translations/sl.json @@ -0,0 +1,40 @@ +{ + "device_automation": { + "action_type": { + "arm_away": "Vklju\u010di {entity_name} zdoma", + "arm_home": "Vklju\u010di {entity_name} doma", + "arm_night": "Vklju\u010di {entity_name} no\u010d", + "disarm": "Razoro\u017ei {entity_name}", + "trigger": "Spro\u017ei {entity_name}" + }, + "condition_type": { + "is_armed_away": "{entity_name} je oboro\u017een na \"zdoma\"", + "is_armed_home": "{entity_name} je oboro\u017een na \"dom\"", + "is_armed_night": "{entity_name} je oboro\u017een na \"no\u010d\"", + "is_disarmed": "{entity_name} razoro\u017een", + "is_triggered": "{entity_name} spro\u017een" + }, + "trigger_type": { + "armed_away": "{entity_name} oboro\u017een - zdoma", + "armed_home": "{entity_name} oboro\u017een - dom", + "armed_night": "{entity_name} oboro\u017een - no\u010d", + "disarmed": "{entity_name} razoro\u017een", + "triggered": "{entity_name} spro\u017een" + } + }, + "state": { + "_": { + "armed": "Omogo\u010den", + "armed_away": "Omogo\u010den-zunaj", + "armed_custom_bypass": "Vklopljen izjeme po meri", + "armed_home": "Omogo\u010den-doma", + "armed_night": "Omogo\u010den-no\u010d", + "arming": "Omogo\u010danje", + "disarmed": "Onemogo\u010den", + "disarming": "Onemogo\u010danje", + "pending": "V teku", + "triggered": "Spro\u017een" + } + }, + "title": "Nadzorna plo\u0161\u010da Alarma" +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/translations/sv.json b/homeassistant/components/alarm_control_panel/translations/sv.json new file mode 100644 index 0000000000000..1f375eb5f1dd4 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/translations/sv.json @@ -0,0 +1,33 @@ +{ + "device_automation": { + "action_type": { + "arm_away": "Larma {entity_name} borta", + "arm_home": "Larma {entity_name} hemma", + "arm_night": "Larma {entity_name} natt", + "disarm": "Avlarma {entity_name}", + "trigger": "Utl\u00f6sare {entity_name}" + }, + "trigger_type": { + "armed_away": "{entity_name} larmad borta", + "armed_home": "{entity_name} larmad hemma", + "armed_night": "{entity_name} larmad natt", + "disarmed": "{entity_name} bortkopplad", + "triggered": "{entity_name} utl\u00f6st" + } + }, + "state": { + "_": { + "armed": "Larmat", + "armed_away": "Larmat", + "armed_custom_bypass": "Larm f\u00f6rbikopplat", + "armed_home": "Hemmalarmat", + "armed_night": "Nattlarmat", + "arming": "Tillkopplar", + "disarmed": "Avlarmat", + "disarming": "Fr\u00e5nkopplar", + "pending": "V\u00e4ntande", + "triggered": "Utl\u00f6st" + } + }, + "title": "Larmkontrollpanel" +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/translations/ta.json b/homeassistant/components/alarm_control_panel/translations/ta.json new file mode 100644 index 0000000000000..731c9815d9260 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/translations/ta.json @@ -0,0 +1,16 @@ +{ + "state": { + "_": { + "armed": "\u0b8e\u0b9a\u0bcd\u0b9a\u0bb0\u0bbf\u0b95\u0bcd\u0b95\u0bc8 \u0b92\u0bb2\u0bbf \u0b85\u0bae\u0bc8\u0b95\u0bcd\u0b95\u0baa\u0bcd\u0baa\u0b9f\u0bcd\u0b9f\u0bc1\u0bb3\u0bcd\u0bb3\u0ba4\u0bc1", + "armed_away": "\u0b8e\u0b9a\u0bcd\u0b9a\u0bb0\u0bbf\u0b95\u0bcd\u0b95\u0bc8 \u0b92\u0bb2\u0bbf \u0bb5\u0bc6\u0bb3\u0bbf\u0baf\u0bc7", + "armed_custom_bypass": "\u0bb5\u0bbf\u0bb0\u0bc1\u0baa\u0bcd\u0baa \u0b8e\u0b9a\u0bcd\u0b9a\u0bb0\u0bbf\u0b95\u0bcd\u0b95\u0bc8 \u0b92\u0bb2\u0bbf", + "armed_home": "\u0b8e\u0b9a\u0bcd\u0b9a\u0bb0\u0bbf\u0b95\u0bcd\u0b95\u0bc8 \u0b92\u0bb2\u0bbf \u0bae\u0bc1\u0b95\u0baa\u0bcd\u0baa\u0bc1", + "armed_night": "\u0b8e\u0b9a\u0bcd\u0b9a\u0bb0\u0bbf\u0b95\u0bcd\u0b95\u0bc8 \u0b92\u0bb2\u0bbf \u0b87\u0bb0\u0bb5\u0bbf\u0bb2\u0bcd", + "arming": "\u0b8e\u0b9a\u0bcd\u0b9a\u0bb0\u0bbf\u0b95\u0bcd\u0b95\u0bc8 \u0b92\u0bb2\u0bbf \u0b85\u0bae\u0bc8\u0b95\u0bcd\u0b95\u0bbf\u0bb1\u0ba4\u0bc1", + "disarmed": "\u0b8e\u0b9a\u0bcd\u0b9a\u0bb0\u0bbf\u0b95\u0bcd\u0b95\u0bc8 \u0b92\u0bb2\u0bbf \u0b85\u0bae\u0bc8\u0b95\u0bcd\u0b95\u0baa\u0bcd\u0baa\u0b9f\u0bb5\u0bbf\u0bb2\u0bcd\u0bb2\u0bc8", + "disarming": "\u0b8e\u0b9a\u0bcd\u0b9a\u0bb0\u0bbf\u0b95\u0bcd\u0b95\u0bc8 \u0b92\u0bb2\u0bbf \u0ba8\u0bc0\u0b95\u0bcd\u0b95\u0bae\u0bcd", + "pending": "\u0ba8\u0bbf\u0bb2\u0bc1\u0bb5\u0bc8\u0baf\u0bbf\u0bb2\u0bcd", + "triggered": "\u0ba4\u0bc2\u0ba3\u0bcd\u0b9f\u0baa\u0bcd\u0baa\u0b9f\u0bc1\u0b95\u0bbf\u0bb1\u0ba4\u0bc1" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/translations/te.json b/homeassistant/components/alarm_control_panel/translations/te.json new file mode 100644 index 0000000000000..dd5357238e308 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/translations/te.json @@ -0,0 +1,17 @@ +{ + "state": { + "_": { + "armed": "\u0c2d\u0c26\u0c4d\u0c30\u0c24 \u0c35\u0c41\u0c02\u0c26\u0c3f", + "armed_away": "\u0c07\u0c02\u0c1f \u0c2c\u0c2f\u0c1f \u0c2d\u0c26\u0c4d\u0c30\u0c24", + "armed_custom_bypass": "\u0c2d\u0c26\u0c4d\u0c30\u0c24 \u0c15\u0c38\u0c4d\u0c1f\u0c2e\u0c4d \u0c2c\u0c48\u0c2a\u0c3e\u0c38\u0c4d", + "armed_home": "\u0c38\u0c46\u0c15\u0c4d\u0c2f\u0c42\u0c30\u0c3f\u0c1f\u0c40 \u0c38\u0c3f\u0c38\u0c4d\u0c1f\u0c2e\u0c4d \u0c06\u0c28\u0c4d \u0c1a\u0c47\u0c2f\u0c2c\u0c21\u0c3f\u0c02\u0c26\u0c3f", + "armed_night": "\u0c30\u0c3e\u0c24\u0c4d\u0c30\u0c3f \u0c2a\u0c42\u0c1f \u0c2d\u0c26\u0c4d\u0c30\u0c24", + "arming": "\u0c2d\u0c26\u0c4d\u0c30\u0c3f\u0c02\u0c1a\u0c41\u0c1f", + "disarmed": "\u0c2d\u0c26\u0c4d\u0c30\u0c24 \u0c32\u0c47\u0c26\u0c41", + "disarming": "\u0c2d\u0c26\u0c4d\u0c30\u0c24 \u0c24\u0c40\u0c38\u0c3f\u0c35\u0c47\u0c2f\u0c41\u0c1f", + "pending": "\u0c2a\u0c46\u0c02\u0c21\u0c3f\u0c02\u0c17\u0c4d", + "triggered": "\u0c0a\u0c2a\u0c02\u0c26\u0c41\u0c15\u0c41\u0c02\u0c26\u0c3f" + } + }, + "title": "\u0c05\u0c32\u0c3e\u0c30\u0c02 \u0c28\u0c3f\u0c2f\u0c02\u0c24\u0c4d\u0c30\u0c23 \u0c2a\u0c4d\u0c2f\u0c3e\u0c28\u0c46\u0c32\u0c4d" +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/translations/th.json b/homeassistant/components/alarm_control_panel/translations/th.json new file mode 100644 index 0000000000000..ada983bba1698 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/translations/th.json @@ -0,0 +1,17 @@ +{ + "state": { + "_": { + "armed": "\u0e40\u0e1b\u0e34\u0e14\u0e01\u0e32\u0e23\u0e1b\u0e49\u0e2d\u0e07\u0e01\u0e31\u0e19", + "armed_away": "\u0e40\u0e1b\u0e34\u0e14\u0e01\u0e32\u0e23\u0e1b\u0e49\u0e2d\u0e07\u0e01\u0e31\u0e19-\u0e42\u0e2b\u0e21\u0e14\u0e44\u0e21\u0e48\u0e2d\u0e22\u0e39\u0e48\u0e1a\u0e49\u0e32\u0e19", + "armed_custom_bypass": "\u0e1b\u0e49\u0e2d\u0e07\u0e01\u0e31\u0e19\u0e42\u0e14\u0e22\u0e01\u0e33\u0e2b\u0e19\u0e14\u0e40\u0e2d\u0e07", + "armed_home": "\u0e40\u0e1b\u0e34\u0e14\u0e01\u0e32\u0e23\u0e1b\u0e49\u0e2d\u0e07\u0e01\u0e31\u0e19-\u0e42\u0e2b\u0e21\u0e14\u0e2d\u0e22\u0e39\u0e48\u0e1a\u0e49\u0e32\u0e19", + "armed_night": "\u0e40\u0e1b\u0e34\u0e14\u0e01\u0e32\u0e23\u0e1b\u0e49\u0e2d\u0e07\u0e01\u0e31\u0e19-\u0e42\u0e2b\u0e21\u0e14\u0e01\u0e25\u0e32\u0e07\u0e04\u0e37\u0e19", + "arming": "\u0e40\u0e1b\u0e34\u0e14\u0e01\u0e32\u0e23\u0e1b\u0e49\u0e2d\u0e07\u0e01\u0e31\u0e19", + "disarmed": "\u0e1b\u0e25\u0e14\u0e01\u0e32\u0e23\u0e1b\u0e49\u0e2d\u0e07\u0e01\u0e31\u0e19", + "disarming": "\u0e1b\u0e25\u0e14\u0e01\u0e32\u0e23\u0e1b\u0e49\u0e2d\u0e07\u0e01\u0e31\u0e19", + "pending": "\u0e04\u0e49\u0e32\u0e07\u0e2d\u0e22\u0e39\u0e48", + "triggered": "\u0e16\u0e39\u0e01\u0e01\u0e23\u0e30\u0e15\u0e38\u0e49\u0e19" + } + }, + "title": "\u0e41\u0e1c\u0e07\u0e04\u0e27\u0e1a\u0e04\u0e38\u0e21\u0e2a\u0e31\u0e0d\u0e0d\u0e32\u0e13\u0e40\u0e15\u0e37\u0e2d\u0e19\u0e20\u0e31\u0e22" +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/translations/tr.json b/homeassistant/components/alarm_control_panel/translations/tr.json new file mode 100644 index 0000000000000..e352755fdf363 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/translations/tr.json @@ -0,0 +1,17 @@ +{ + "state": { + "_": { + "armed": "Etkin", + "armed_away": "Etkin d\u0131\u015far\u0131da", + "armed_custom_bypass": "\u00d6zel alarm atlatmas\u0131", + "armed_home": "Etkin evde", + "armed_night": "Etkin gece", + "arming": "Etkinle\u015fiyor", + "disarmed": "Etkisiz", + "disarming": "Etkisizle\u015ftiriliyor", + "pending": "Beklemede", + "triggered": "Tetiklendi" + } + }, + "title": "Alarm kontrol paneli" +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/translations/uk.json b/homeassistant/components/alarm_control_panel/translations/uk.json new file mode 100644 index 0000000000000..e618e29701955 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/translations/uk.json @@ -0,0 +1,17 @@ +{ + "state": { + "_": { + "armed": "\u041e\u0445\u043e\u0440\u043e\u043d\u0430", + "armed_away": "\u041e\u0445\u043e\u0440\u043e\u043d\u0430 (\u043d\u0435 \u0432\u0434\u043e\u043c\u0430)", + "armed_custom_bypass": "\u041e\u0445\u043e\u0440\u043e\u043d\u0430 \u0437 \u0432\u0438\u043d\u044f\u0442\u043a\u0430\u043c\u0438", + "armed_home": "\u0411\u0443\u0434\u0438\u043d\u043a\u043e\u0432\u0430 \u043e\u0445\u043e\u0440\u043e\u043d\u0430", + "armed_night": "\u041d\u0456\u0447\u043d\u0430 \u043e\u0445\u043e\u0440\u043e\u043d\u0430", + "arming": "\u0421\u0442\u0430\u0432\u043b\u044e \u043d\u0430 \u043e\u0445\u043e\u0440\u043e\u043d\u0443", + "disarmed": "\u0417\u043d\u044f\u0442\u043e", + "disarming": "\u0417\u043d\u044f\u0442\u0442\u044f", + "pending": "\u041e\u0447\u0456\u043a\u0443\u044e", + "triggered": "\u0422\u0440\u0438\u0432\u043e\u0433\u0430" + } + }, + "title": "\u041f\u0430\u043d\u0435\u043b\u044c \u043a\u0435\u0440\u0443\u0432\u0430\u043d\u043d\u044f \u0441\u0438\u0433\u043d\u0430\u043b\u0456\u0437\u0430\u0446\u0456\u0454\u044e" +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/translations/vi.json b/homeassistant/components/alarm_control_panel/translations/vi.json new file mode 100644 index 0000000000000..3a0fb34950b0a --- /dev/null +++ b/homeassistant/components/alarm_control_panel/translations/vi.json @@ -0,0 +1,17 @@ +{ + "state": { + "_": { + "armed": "K\u00edch ho\u1ea1t an ninh", + "armed_away": "B\u1ea3o v\u1ec7 \u0111i v\u1eafng", + "armed_custom_bypass": "T\u00f9y ch\u1ec9nh b\u1ecf qua An ninh", + "armed_home": "B\u1ea3o v\u1ec7 \u1edf nh\u00e0", + "armed_night": "Ban \u0111\u00eam", + "arming": "K\u00edch ho\u1ea1t", + "disarmed": "V\u00f4 hi\u1ec7u h\u00f3a", + "disarming": "Gi\u1ea3i gi\u00e1p", + "pending": "\u0110ang ch\u1edd x\u1eed l\u00fd", + "triggered": "K\u00edch ho\u1ea1t" + } + }, + "title": "B\u1ea3ng \u0111i\u1ec1u khi\u1ec3n an ninh" +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/translations/zh-Hans.json b/homeassistant/components/alarm_control_panel/translations/zh-Hans.json new file mode 100644 index 0000000000000..749674e8e6e62 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/translations/zh-Hans.json @@ -0,0 +1,17 @@ +{ + "state": { + "_": { + "armed": "\u8b66\u6212", + "armed_away": "\u79bb\u5bb6\u8b66\u6212", + "armed_custom_bypass": "\u81ea\u5b9a\u4e49\u533a\u57df\u8b66\u6212", + "armed_home": "\u5728\u5bb6\u8b66\u6212", + "armed_night": "\u591c\u95f4\u8b66\u6212", + "arming": "\u8b66\u6212\u4e2d", + "disarmed": "\u8b66\u6212\u89e3\u9664", + "disarming": "\u8b66\u6212\u89e3\u9664", + "pending": "\u6302\u8d77", + "triggered": "\u5df2\u89e6\u53d1" + } + }, + "title": "\u8b66\u62a5\u63a7\u5236\u5668" +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/translations/zh-Hant.json b/homeassistant/components/alarm_control_panel/translations/zh-Hant.json new file mode 100644 index 0000000000000..2dac00f99902d --- /dev/null +++ b/homeassistant/components/alarm_control_panel/translations/zh-Hant.json @@ -0,0 +1,40 @@ +{ + "device_automation": { + "action_type": { + "arm_away": "\u8a2d\u5b9a{entity_name}\u5916\u51fa\u6a21\u5f0f", + "arm_home": "\u8a2d\u5b9a{entity_name}\u8fd4\u5bb6\u6a21\u5f0f", + "arm_night": "\u8a2d\u5b9a{entity_name}\u591c\u9593\u6a21\u5f0f", + "disarm": "\u89e3\u9664{entity_name}", + "trigger": "\u89f8\u767c{entity_name}" + }, + "condition_type": { + "is_armed_away": "{entity_name}\u8a2d\u5b9a\u5916\u51fa", + "is_armed_home": "{entity_name}\u8a2d\u5b9a\u5728\u5bb6", + "is_armed_night": "{entity_name}\u8a2d\u5b9a\u591c\u9593", + "is_disarmed": "{entity_name}\u5df2\u89e3\u9664", + "is_triggered": "{entity_name}\u5df2\u89f8\u767c" + }, + "trigger_type": { + "armed_away": "{entity_name}\u8a2d\u5b9a\u5916\u51fa", + "armed_home": "{entity_name}\u8a2d\u5b9a\u5728\u5bb6", + "armed_night": "{entity_name}\u8a2d\u5b9a\u591c\u9593", + "disarmed": "{entity_name}\u5df2\u89e3\u9664", + "triggered": "{entity_name}\u5df2\u89f8\u767c" + } + }, + "state": { + "_": { + "armed": "\u5df2\u8b66\u6212", + "armed_away": "\u96e2\u5bb6\u8b66\u6212", + "armed_custom_bypass": "\u8b66\u6212\u6a21\u5f0f\u72c0\u614b", + "armed_home": "\u5728\u5bb6\u8b66\u6212", + "armed_night": "\u591c\u9593\u8b66\u6212", + "arming": "\u8b66\u6212\u4e2d", + "disarmed": "\u8b66\u6212\u89e3\u9664", + "disarming": "\u89e3\u9664\u4e2d", + "pending": "\u7b49\u5f85\u4e2d", + "triggered": "\u5df2\u89f8\u767c" + } + }, + "title": "\u8b66\u6212\u63a7\u5236\u9762\u677f" +} \ No newline at end of file diff --git a/homeassistant/components/alarmdecoder/__init__.py b/homeassistant/components/alarmdecoder/__init__.py index b4d1a2e0b9f7d..c70bcdcc45cc8 100644 --- a/homeassistant/components/alarmdecoder/__init__.py +++ b/homeassistant/components/alarmdecoder/__init__.py @@ -1,105 +1,135 @@ """Support for AlarmDecoder devices.""" +from datetime import timedelta import logging -from datetime import timedelta +from alarmdecoder import AlarmDecoder +from alarmdecoder.devices import SerialDevice, SocketDevice, USBDevice +from alarmdecoder.util import NoDeviceError import voluptuous as vol +from homeassistant.components.binary_sensor import DEVICE_CLASSES_SCHEMA +from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP 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' +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_AUTO_BYPASS = "autobypass" +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" +CONF_CODE_ARM_REQUIRED = "code_arm_required" + +DEFAULT_DEVICE_TYPE = "socket" +DEFAULT_DEVICE_HOST = "localhost" DEFAULT_DEVICE_PORT = 10000 -DEFAULT_DEVICE_PATH = '/dev/ttyUSB0' +DEFAULT_DEVICE_PATH = "/dev/ttyUSB0" DEFAULT_DEVICE_BAUD = 115200 +DEFAULT_AUTO_BYPASS = False 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) +DEFAULT_CODE_ARM_REQUIRED = True + +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_AUTO_BYPASS, default=DEFAULT_AUTO_BYPASS): cv.boolean, + vol.Optional( + CONF_CODE_ARM_REQUIRED, default=DEFAULT_CODE_ARM_REQUIRED + ): 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) + device = conf[CONF_DEVICE] + display = conf[CONF_PANEL_DISPLAY] + auto_bypass = conf[CONF_AUTO_BYPASS] + code_arm_required = conf[CONF_CODE_ARM_REQUIRED] zones = conf.get(CONF_ZONES) - device_type = device.get(CONF_DEVICE_TYPE) + device_type = device[CONF_DEVICE_TYPE] host = DEFAULT_DEVICE_HOST port = DEFAULT_DEVICE_PORT path = DEFAULT_DEVICE_PATH @@ -114,14 +144,14 @@ def stop_alarmdecoder(event): 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)) + open_connection, dt_util.utcnow() + timedelta(seconds=5) + ) return _LOGGER.debug("Established a connection with the alarmdecoder") restart = True @@ -137,39 +167,34 @@ def handle_closed_connection(event): def handle_message(sender, message): """Handle message from AlarmDecoder.""" - hass.helpers.dispatcher.dispatcher_send( - SIGNAL_PANEL_MESSAGE, message) + 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) + 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) + 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) + 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) + """Handle relay or zone expander 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) + if device_type == "socket": + host = device[CONF_HOST] + port = device[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) + elif device_type == "serial": + path = device[CONF_DEVICE_PATH] + baud = device[CONF_DEVICE_BAUD] controller = AlarmDecoder(SerialDevice(interface=path)) - elif device_type == 'usb': + elif device_type == "usb": AlarmDecoder(USBDevice.find()) return False @@ -178,7 +203,7 @@ def handle_rel_message(sender, 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 + controller.on_expander_message += handle_rel_message hass.data[DATA_AD] = controller @@ -186,13 +211,18 @@ def handle_rel_message(sender, message): hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_alarmdecoder) - load_platform(hass, 'alarm_control_panel', DOMAIN, conf, config) + load_platform( + hass, + "alarm_control_panel", + DOMAIN, + {CONF_AUTO_BYPASS: auto_bypass, CONF_CODE_ARM_REQUIRED: code_arm_required}, + config, + ) if zones: - load_platform( - hass, 'binary_sensor', DOMAIN, {CONF_ZONES: zones}, config) + load_platform(hass, "binary_sensor", DOMAIN, {CONF_ZONES: zones}, config) if display: - load_platform(hass, 'sensor', DOMAIN, conf, config) + 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 index 51645b516b98d..ac90ea1796f12 100644 --- a/homeassistant/components/alarmdecoder/alarm_control_panel.py +++ b/homeassistant/components/alarmdecoder/alarm_control_panel.py @@ -3,41 +3,81 @@ import voluptuous as vol -import homeassistant.components.alarm_control_panel as alarm +from homeassistant.components.alarm_control_panel import ( + FORMAT_NUMBER, + AlarmControlPanelEntity, +) +from homeassistant.components.alarm_control_panel.const import ( + SUPPORT_ALARM_ARM_AWAY, + SUPPORT_ALARM_ARM_HOME, + SUPPORT_ALARM_ARM_NIGHT, +) from homeassistant.const import ( - ATTR_CODE, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, - STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED) + 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 +from . import ( + CONF_AUTO_BYPASS, + CONF_CODE_ARM_REQUIRED, + DATA_AD, + DOMAIN, + 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, -}) +SERVICE_ALARM_TOGGLE_CHIME = "alarm_toggle_chime" +ALARM_TOGGLE_CHIME_SCHEMA = vol.Schema({vol.Required(ATTR_CODE): cv.string}) + +SERVICE_ALARM_KEYPRESS = "alarm_keypress" +ATTR_KEYPRESS = "keypress" +ALARM_KEYPRESS_SCHEMA = vol.Schema({vol.Required(ATTR_KEYPRESS): cv.string}) def setup_platform(hass, config, add_entities, discovery_info=None): """Set up for AlarmDecoder alarm panels.""" - device = AlarmDecoderAlarmPanel() - add_entities([device]) + if discovery_info is None: + return + + auto_bypass = discovery_info[CONF_AUTO_BYPASS] + code_arm_required = discovery_info[CONF_CODE_ARM_REQUIRED] + entity = AlarmDecoderAlarmPanel(auto_bypass, code_arm_required) + add_entities([entity]) def alarm_toggle_chime_handler(service): """Register toggle chime handler.""" code = service.data.get(ATTR_CODE) - device.alarm_toggle_chime(code) + entity.alarm_toggle_chime(code) + + hass.services.register( + DOMAIN, + SERVICE_ALARM_TOGGLE_CHIME, + alarm_toggle_chime_handler, + schema=ALARM_TOGGLE_CHIME_SCHEMA, + ) + + def alarm_keypress_handler(service): + """Register keypress handler.""" + keypress = service.data[ATTR_KEYPRESS] + entity.alarm_keypress(keypress) hass.services.register( - alarm.DOMAIN, SERVICE_ALARM_TOGGLE_CHIME, alarm_toggle_chime_handler, - schema=ALARM_TOGGLE_CHIME_SCHEMA) + DOMAIN, + SERVICE_ALARM_KEYPRESS, + alarm_keypress_handler, + schema=ALARM_KEYPRESS_SCHEMA, + ) -class AlarmDecoderAlarmPanel(alarm.AlarmControlPanel): +class AlarmDecoderAlarmPanel(AlarmControlPanelEntity): """Representation of an AlarmDecoder-based alarm panel.""" - def __init__(self): + def __init__(self, auto_bypass, code_arm_required): """Initialize the alarm panel.""" self._display = "" self._name = "Alarm Panel" @@ -51,11 +91,16 @@ def __init__(self): self._programming_mode = None self._ready = None self._zone_bypassed = None + self._auto_bypass = auto_bypass + self._code_arm_required = code_arm_required async def async_added_to_hass(self): """Register callbacks.""" - self.hass.helpers.dispatcher.async_dispatcher_connect( - SIGNAL_PANEL_MESSAGE, self._message_callback) + self.async_on_remove( + self.hass.helpers.dispatcher.async_dispatcher_connect( + SIGNAL_PANEL_MESSAGE, self._message_callback + ) + ) def _message_callback(self, message): """Handle received messages.""" @@ -93,49 +138,75 @@ def should_poll(self): @property def code_format(self): """Return one or more digits/characters.""" - return alarm.FORMAT_NUMBER + return FORMAT_NUMBER @property def state(self): """Return the state of the device.""" return self._state + @property + def supported_features(self) -> int: + """Return the list of supported features.""" + return SUPPORT_ALARM_ARM_HOME | SUPPORT_ALARM_ARM_AWAY | SUPPORT_ALARM_ARM_NIGHT + + @property + def code_arm_required(self): + """Whether the code is required for arm actions.""" + return self._code_arm_required + @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, + "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, + "code_arm_required": self._code_arm_required, } def alarm_disarm(self, code=None): """Send disarm command.""" if code: - self.hass.data[DATA_AD].send("{!s}1".format(code)) + self.hass.data[DATA_AD].send(f"{code!s}1") def alarm_arm_away(self, code=None): """Send arm away command.""" if code: - self.hass.data[DATA_AD].send("{!s}2".format(code)) + if self._auto_bypass: + self.hass.data[DATA_AD].send(f"{code!s}6#") + self.hass.data[DATA_AD].send(f"{code!s}2") + elif not self._code_arm_required: + self.hass.data[DATA_AD].send("#2") def alarm_arm_home(self, code=None): """Send arm home command.""" if code: - self.hass.data[DATA_AD].send("{!s}3".format(code)) + if self._auto_bypass: + self.hass.data[DATA_AD].send(f"{code!s}6#") + self.hass.data[DATA_AD].send(f"{code!s}3") + elif not self._code_arm_required: + self.hass.data[DATA_AD].send("#3") def alarm_arm_night(self, code=None): """Send arm night command.""" if code: - self.hass.data[DATA_AD].send("{!s}33".format(code)) + self.hass.data[DATA_AD].send(f"{code!s}7") + elif not self._code_arm_required: + self.hass.data[DATA_AD].send("#7") def alarm_toggle_chime(self, code=None): """Send toggle chime command.""" if code: - self.hass.data[DATA_AD].send("{!s}9".format(code)) + self.hass.data[DATA_AD].send(f"{code!s}9") + + def alarm_keypress(self, keypress): + """Send custom keypresses.""" + if keypress: + self.hass.data[DATA_AD].send(keypress) diff --git a/homeassistant/components/alarmdecoder/binary_sensor.py b/homeassistant/components/alarmdecoder/binary_sensor.py index 91ff8b381b57b..cec1b8356b023 100644 --- a/homeassistant/components/alarmdecoder/binary_sensor.py +++ b/homeassistant/components/alarmdecoder/binary_sensor.py @@ -1,23 +1,33 @@ """Support for AlarmDecoder zone states- represented as binary sensors.""" import logging -from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.components.binary_sensor import BinarySensorEntity 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) + 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' +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): @@ -34,8 +44,8 @@ def setup_platform(hass, config, add_entities, discovery_info=None): 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) + zone_num, zone_name, zone_type, zone_rfid, zone_loop, relay_addr, relay_chan + ) devices.append(device) add_entities(devices) @@ -43,11 +53,19 @@ def setup_platform(hass, config, add_entities, discovery_info=None): return True -class AlarmDecoderBinarySensor(BinarySensorDevice): +class AlarmDecoderBinarySensor(BinarySensorEntity): """Representation of an AlarmDecoder binary sensor.""" - def __init__(self, zone_number, zone_name, zone_type, zone_rfid, zone_loop, - relay_addr, relay_chan): + 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 @@ -61,17 +79,29 @@ def __init__(self, zone_number, zone_name, zone_type, zone_rfid, zone_loop, 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) + self.async_on_remove( + self.hass.helpers.dispatcher.async_dispatcher_connect( + SIGNAL_ZONE_FAULT, self._fault_callback + ) + ) + + self.async_on_remove( + self.hass.helpers.dispatcher.async_dispatcher_connect( + SIGNAL_ZONE_RESTORE, self._restore_callback + ) + ) + + self.async_on_remove( + self.hass.helpers.dispatcher.async_dispatcher_connect( + SIGNAL_RFX_MESSAGE, self._rfx_message_callback + ) + ) + + self.async_on_remove( + self.hass.helpers.dispatcher.async_dispatcher_connect( + SIGNAL_REL_MESSAGE, self._rel_message_callback + ) + ) @property def name(self): @@ -116,7 +146,7 @@ def _fault_callback(self, zone): def _restore_callback(self, zone): """Update the zone's state, if needed.""" - if zone is None or int(zone) == self._zone_number: + if zone is None or (int(zone) == self._zone_number and not self._loop): self._state = 0 self.schedule_update_ha_state() @@ -129,10 +159,15 @@ def _rfx_message_callback(self, message): 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) + """Update relay / expander state.""" + + if self._relay_addr == message.address and self._relay_chan == message.channel: + _LOGGER.debug( + "%s %d:%d value:%d", + "Relay" if message.type == message.RELAY else "ZoneExpander", + 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 index 3e0d4112d2735..48c5cb824addd 100644 --- a/homeassistant/components/alarmdecoder/manifest.json +++ b/homeassistant/components/alarmdecoder/manifest.json @@ -1,10 +1,7 @@ { "domain": "alarmdecoder", - "name": "Alarmdecoder", - "documentation": "https://www.home-assistant.io/components/alarmdecoder", - "requirements": [ - "alarmdecoder==1.13.2" - ], - "dependencies": [], - "codeowners": [] + "name": "AlarmDecoder", + "documentation": "https://www.home-assistant.io/integrations/alarmdecoder", + "requirements": ["alarmdecoder==1.13.2"], + "codeowners": ["@ajschmidt8"] } diff --git a/homeassistant/components/alarmdecoder/sensor.py b/homeassistant/components/alarmdecoder/sensor.py index 9fb37d62376bc..96e5feb532d31 100644 --- a/homeassistant/components/alarmdecoder/sensor.py +++ b/homeassistant/components/alarmdecoder/sensor.py @@ -24,13 +24,16 @@ def __init__(self, hass): """Initialize the alarm panel.""" self._display = "" self._state = None - self._icon = 'mdi:alarm-check' - self._name = 'Alarm Panel Display' + 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) + self.async_on_remove( + self.hass.helpers.dispatcher.async_dispatcher_connect( + SIGNAL_PANEL_MESSAGE, self._message_callback + ) + ) def _message_callback(self, message): if self._display != message.text: diff --git a/homeassistant/components/alarmdecoder/services.yaml b/homeassistant/components/alarmdecoder/services.yaml index e69de29bb2d1d..bcf5a927713aa 100644 --- a/homeassistant/components/alarmdecoder/services.yaml +++ b/homeassistant/components/alarmdecoder/services.yaml @@ -0,0 +1,13 @@ +alarm_keypress: + description: Send custom keypresses to the alarm. + fields: + keypress: + description: "String to send to the alarm panel." + example: "*71" + +alarm_toggle_chime: + description: Send the alarm the toggle chime command. + fields: + code: + description: A required code to toggle the alarm control panel chime with. + example: 1234 diff --git a/homeassistant/components/alarmdotcom/__init__.py b/homeassistant/components/alarmdotcom/__init__.py deleted file mode 100644 index 0a715230e9fb2..0000000000000 --- a/homeassistant/components/alarmdotcom/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""The alarmdotcom component.""" diff --git a/homeassistant/components/alarmdotcom/alarm_control_panel.py b/homeassistant/components/alarmdotcom/alarm_control_panel.py deleted file mode 100644 index 5919bf84f41eb..0000000000000 --- a/homeassistant/components/alarmdotcom/alarm_control_panel.py +++ /dev/null @@ -1,118 +0,0 @@ -"""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 deleted file mode 100644 index 9d2c0a2056e36..0000000000000 --- a/homeassistant/components/alarmdotcom/manifest.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "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 index 4c990d62d4b62..f3b15a7af572d 100644 --- a/homeassistant/components/alert/__init__.py +++ b/homeassistant/components/alert/__init__.py @@ -1,56 +1,69 @@ """Support for repeating alerts when conditions are met.""" -import asyncio +from datetime import timedelta import logging -from datetime import datetime, 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) + ATTR_DATA, + ATTR_MESSAGE, + ATTR_TITLE, + 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 + ATTR_ENTITY_ID, + CONF_ENTITY_ID, + CONF_NAME, + CONF_STATE, + SERVICE_TOGGLE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_IDLE, + STATE_OFF, + STATE_ON, +) +from homeassistant.helpers import event, service +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import ToggleEntity +from homeassistant.util.dt import now _LOGGER = logging.getLogger(__name__) -DOMAIN = 'alert' -ENTITY_ID_FORMAT = DOMAIN + '.{}' +DOMAIN = "alert" -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' +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, -}) +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): @@ -66,23 +79,35 @@ async def async_setup(hass, config): 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) + name = cfg[CONF_NAME] + watched_entity_id = cfg[CONF_ENTITY_ID] + alert_state = cfg[CONF_STATE] + repeat = cfg[CONF_REPEAT] + skip_first = cfg[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) + notifiers = cfg[CONF_NOTIFIERS] + can_ack = cfg[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)) + 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 @@ -106,18 +131,20 @@ async def async_handle_alert_service(service_call): # Setup service calls hass.services.async_register( - DOMAIN, SERVICE_TURN_OFF, async_handle_alert_service, - schema=ALERT_SERVICE_SCHEMA) + 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) + 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) + 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, loop=hass.loop) + for alert in entities: + alert.async_write_ha_state() return True @@ -125,10 +152,22 @@ async def async_handle_alert_service(service_call): 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): + 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 @@ -158,10 +197,11 @@ def __init__(self, hass, entity_id, name, watched_entity_id, self._ack = False self._cancel = None self._send_done_message = False - self.entity_id = ENTITY_ID_FORMAT.format(entity_id) + self.entity_id = f"{DOMAIN}.{entity_id}" event.async_track_state_change( - hass, watched_entity_id, self.watched_entity_change) + hass, watched_entity_id, self.watched_entity_change + ) @property def name(self): @@ -170,7 +210,7 @@ def name(self): @property def should_poll(self): - """HASS need not poll these entities.""" + """Home Assistant need not poll these entities.""" return False @property @@ -207,7 +247,7 @@ async def begin_alerting(self): else: await self._schedule_notify() - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def end_alerting(self): """End the alert procedures.""" @@ -217,14 +257,15 @@ async def end_alerting(self): self._firing = False if self._send_done_message: await self._notify_done_message() - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def _schedule_notify(self): """Schedule a notification.""" delay = self._delay[self._next_delay] - next_msg = datetime.now() + delay - self._cancel = \ - event.async_track_point_in_time(self.hass, self._notify, next_msg) + 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): @@ -269,20 +310,19 @@ async def _send_notification_message(self, message): _LOGGER.debug(msg_payload) for target in self._notifiers: - await self.hass.services.async_call( - DOMAIN_NOTIFY, target, msg_payload) + 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() + self.async_write_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() + self.async_write_ha_state() async def async_toggle(self, **kwargs): """Async toggle alert.""" diff --git a/homeassistant/components/alert/manifest.json b/homeassistant/components/alert/manifest.json index f3dcc18208c36..ff1faf3982780 100644 --- a/homeassistant/components/alert/manifest.json +++ b/homeassistant/components/alert/manifest.json @@ -1,8 +1,8 @@ { "domain": "alert", "name": "Alert", - "documentation": "https://www.home-assistant.io/components/alert", - "requirements": [], - "dependencies": [], - "codeowners": [] + "documentation": "https://www.home-assistant.io/integrations/alert", + "after_dependencies": ["notify"], + "codeowners": [], + "quality_scale": "internal" } diff --git a/homeassistant/components/alert/services.yaml b/homeassistant/components/alert/services.yaml index 1cdd1f02e7eed..995302005460e 100644 --- a/homeassistant/components/alert/services.yaml +++ b/homeassistant/components/alert/services.yaml @@ -1,12 +1,18 @@ toggle: description: Toggle alert's notifications. fields: - entity_id: {description: Name of the alert to toggle., example: alert.garage_door_open} + 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} + 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} + entity_id: + description: Name of the alert to reset. + example: alert.garage_door_open diff --git a/homeassistant/components/alexa/__init__.py b/homeassistant/components/alexa/__init__.py index 862605b64b570..de5a67087cab6 100644 --- a/homeassistant/components/alexa/__init__.py +++ b/homeassistant/components/alexa/__init__.py @@ -3,55 +3,112 @@ import voluptuous as vol -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers import entityfilter +from homeassistant.const import CONF_NAME +from homeassistant.core import callback +from homeassistant.helpers import config_validation as cv, entityfilter -from . import flash_briefings, intent, smart_home +from . import flash_briefings, intent, smart_home_http 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) + CONF_AUDIO, + CONF_CLIENT_ID, + CONF_CLIENT_SECRET, + CONF_DESCRIPTION, + CONF_DISPLAY_CATEGORIES, + CONF_DISPLAY_URL, + CONF_ENDPOINT, + CONF_ENTITY_CONFIG, + CONF_FILTER, + CONF_LOCALE, + CONF_SUPPORTED_LOCALES, + CONF_TEXT, + CONF_TITLE, + CONF_UID, + DOMAIN, + EVENT_ALEXA_SMART_HOME, +) _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), +CONF_FLASH_BRIEFINGS = "flash_briefings" +CONF_SMART_HOME = "smart_home" +DEFAULT_LOCALE = "en-US" + +ALEXA_ENTITY_SCHEMA = vol.Schema( + { + vol.Optional(CONF_DESCRIPTION): cv.string, + vol.Optional(CONF_DISPLAY_CATEGORIES): cv.string, + vol.Optional(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_LOCALE, default=DEFAULT_LOCALE): vol.In( + CONF_SUPPORTED_LOCALES + ), + vol.Optional(CONF_FILTER, default={}): entityfilter.FILTER_SCHEMA, + vol.Optional(CONF_ENTITY_CONFIG): {cv.entity_id: ALEXA_ENTITY_SCHEMA}, } -}, extra=vol.ALLOW_EXTRA) +) + +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, {}) + + @callback + def async_describe_logbook_event(event): + """Describe a logbook event.""" + data = event.data + entity_id = data["request"].get("entity_id") + + if entity_id: + state = hass.states.get(entity_id) + name = state.name if state else entity_id + message = f"send command {data['request']['namespace']}/{data['request']['name']} for {name}" + else: + message = ( + f"send command {data['request']['namespace']}/{data['request']['name']}" + ) + + return {"name": "Amazon Alexa", "message": message, "entity_id": entity_id} + + hass.components.logbook.async_describe_event( + DOMAIN, EVENT_ALEXA_SMART_HOME, async_describe_logbook_event + ) + + if DOMAIN not in config: + return True + + config = config[DOMAIN] + flash_briefings_config = config.get(CONF_FLASH_BRIEFINGS) intent.async_setup(hass) @@ -65,6 +122,6 @@ async def async_setup(hass, config): pass else: smart_home_config = smart_home_config or SMART_HOME_SCHEMA({}) - await smart_home.async_setup(hass, smart_home_config) + await smart_home_http.async_setup(hass, smart_home_config) return True diff --git a/homeassistant/components/alexa/auth.py b/homeassistant/components/alexa/auth.py index 6918ec1e54f04..3b7984f56d3c1 100644 --- a/homeassistant/components/alexa/auth.py +++ b/homeassistant/components/alexa/auth.py @@ -1,25 +1,24 @@ """Support for Alexa skill auth.""" import asyncio +from datetime import timedelta import json import logging -from datetime import timedelta + import aiohttp import async_timeout +from homeassistant.const import HTTP_OK 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" -} +LWA_HEADERS = {"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8"} PREEMPTIVE_REFRESH_TTL_IN_SECONDS = 300 -STORAGE_KEY = 'alexa_auth' +STORAGE_KEY = "alexa_auth" STORAGE_VERSION = 1 STORAGE_EXPIRE_TIME = "expire_time" STORAGE_ACCESS_TOKEN = "access_token" @@ -39,7 +38,7 @@ def __init__(self, hass, client_id, client_secret): self._prefs = None self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) - self._get_token_lock = asyncio.Lock(loop=hass.loop) + self._get_token_lock = asyncio.Lock() async def async_do_auth(self, accept_grant_code): """Do authentication with an AcceptGrant code.""" @@ -50,13 +49,20 @@ async def async_do_auth(self, accept_grant_code): "grant_type": "authorization_code", "code": accept_grant_code, "client_id": self.client_id, - "client_secret": self.client_secret + "client_secret": self.client_secret, } - _LOGGER.debug("Calling LWA to get the access token (first time), " - "with: %s", json.dumps(lwa_params)) + _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) + @callback + def async_invalidate_access_token(self): + """Invalidate access token.""" + self._prefs[STORAGE_ACCESS_TOKEN] = None + async def async_get_access_token(self): """Perform access token or token refresh request.""" async with self._get_token_lock: @@ -75,7 +81,7 @@ async def async_get_access_token(self): "grant_type": "refresh_token", "refresh_token": self._prefs[STORAGE_REFRESH_TOKEN], "client_id": self.client_id, - "client_secret": self.client_secret + "client_secret": self.client_secret, } _LOGGER.debug("Calling LWA to refresh the access token.") @@ -89,7 +95,8 @@ def is_token_valid(self): expire_time = dt.parse_datetime(self._prefs[STORAGE_EXPIRE_TIME]) preemptive_expire_time = expire_time - timedelta( - seconds=PREEMPTIVE_REFRESH_TTL_IN_SECONDS) + seconds=PREEMPTIVE_REFRESH_TTL_IN_SECONDS + ) return dt.utcnow() < preemptive_expire_time @@ -97,11 +104,13 @@ async def _async_request_new_token(self, lwa_params): try: session = aiohttp_client.async_get_clientsession(self.hass) - with async_timeout.timeout(DEFAULT_TIMEOUT, loop=self.hass.loop): - response = await session.post(LWA_TOKEN_URI, - headers=LWA_HEADERS, - data=lwa_params, - allow_redirects=True) + with async_timeout.timeout(10): + 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.") @@ -110,7 +119,7 @@ async def _async_request_new_token(self, lwa_params): _LOGGER.debug("LWA response header: %s", response.headers) _LOGGER.debug("LWA response status: %s", response.status) - if response.status != 200: + if response.status != HTTP_OK: _LOGGER.error("Error calling LWA to get auth token.") return None @@ -122,8 +131,9 @@ async def _async_request_new_token(self, lwa_params): 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()) + await self._async_update_preferences( + access_token, refresh_token, expire_time.isoformat() + ) return access_token @@ -135,11 +145,10 @@ async def async_load_preferences(self): self._prefs = { STORAGE_ACCESS_TOKEN: None, STORAGE_REFRESH_TOKEN: None, - STORAGE_EXPIRE_TIME: None + STORAGE_EXPIRE_TIME: None, } - async def _async_update_preferences(self, access_token, refresh_token, - expire_time): + 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() diff --git a/homeassistant/components/alexa/capabilities.py b/homeassistant/components/alexa/capabilities.py new file mode 100644 index 0000000000000..7451d15eb1c46 --- /dev/null +++ b/homeassistant/components/alexa/capabilities.py @@ -0,0 +1,1899 @@ +"""Alexa capabilities.""" +import logging + +from homeassistant.components import ( + cover, + fan, + image_processing, + input_number, + light, + timer, + vacuum, +) +from homeassistant.components.alarm_control_panel import ATTR_CODE_FORMAT, FORMAT_NUMBER +from homeassistant.components.alarm_control_panel.const import ( + SUPPORT_ALARM_ARM_AWAY, + SUPPORT_ALARM_ARM_HOME, + SUPPORT_ALARM_ARM_NIGHT, +) +import homeassistant.components.climate.const as climate +import homeassistant.components.media_player.const as media_player +from homeassistant.const import ( + ATTR_SUPPORTED_FEATURES, + ATTR_TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT, + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_CUSTOM_BYPASS, + STATE_ALARM_ARMED_HOME, + STATE_ALARM_ARMED_NIGHT, + STATE_IDLE, + STATE_LOCKED, + STATE_OFF, + STATE_ON, + STATE_PAUSED, + STATE_PLAYING, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + STATE_UNLOCKED, +) +import homeassistant.util.color as color_util +import homeassistant.util.dt as dt_util + +from .const import ( + API_TEMP_UNITS, + API_THERMOSTAT_MODES, + API_THERMOSTAT_PRESETS, + DATE_FORMAT, + PERCENTAGE_FAN_MAP, + Inputs, +) +from .errors import UnsupportedProperty +from .resources import ( + AlexaCapabilityResource, + AlexaGlobalCatalog, + AlexaModeResource, + AlexaPresetResource, + AlexaSemantics, +) + +_LOGGER = logging.getLogger(__name__) + + +class AlexaCapability: + """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 + """ + + supported_locales = {"en-US"} + + def __init__(self, entity, instance=None): + """Initialize an Alexa capability.""" + self.entity = entity + self.instance = instance + + 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 properties_non_controllable(): + """Return True if non controllable.""" + return None + + @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 + + @staticmethod + def capability_proactively_reported(): + """Return True if the capability is proactively reported. + + Set properties_proactively_reported() for proactively reported properties. + Applicable to DoorbellEventSource. + """ + return None + + @staticmethod + def capability_resources(): + """Return the capability object. + + Applicable to ToggleController, RangeController, and ModeController interfaces. + """ + return [] + + @staticmethod + def configuration(): + """Return the configuration object. + + Applicable to the ThermostatController, SecurityControlPanel, ModeController, RangeController, + and EventDetectionSensor. + """ + return [] + + @staticmethod + def configurations(): + """Return the configurations object. + + The plural configurations object is different that the singular configuration object. + Applicable to EqualizerController interface. + """ + return [] + + @staticmethod + def inputs(): + """Applicable only to media players.""" + return [] + + @staticmethod + def semantics(): + """Return the semantics object. + + Applicable to ToggleController, RangeController, and ModeController interfaces. + """ + return [] + + @staticmethod + def supported_operations(): + """Return the supportedOperations object.""" + return [] + + @staticmethod + def camera_stream_configurations(): + """Applicable only to CameraStreamController.""" + return None + + def serialize_discovery(self): + """Serialize according to the Discovery API.""" + result = {"type": "AlexaInterface", "interface": self.name(), "version": "3"} + + instance = self.instance + if instance is not None: + result["instance"] = instance + + properties_supported = self.properties_supported() + if properties_supported: + result["properties"] = { + "supported": self.properties_supported(), + "proactivelyReported": self.properties_proactively_reported(), + "retrievable": self.properties_retrievable(), + } + + proactively_reported = self.capability_proactively_reported() + if proactively_reported is not None: + result["proactivelyReported"] = proactively_reported + + non_controllable = self.properties_non_controllable() + if non_controllable is not None: + result["properties"]["nonControllable"] = non_controllable + + supports_deactivation = self.supports_deactivation() + if supports_deactivation is not None: + result["supportsDeactivation"] = supports_deactivation + + capability_resources = self.capability_resources() + if capability_resources: + result["capabilityResources"] = capability_resources + + configuration = self.configuration() + if configuration: + result["configuration"] = configuration + + # The plural configurations object is different than the singular configuration object above. + configurations = self.configurations() + if configurations: + result["configurations"] = configurations + + semantics = self.semantics() + if semantics: + result["semantics"] = semantics + + supported_operations = self.supported_operations() + if supported_operations: + result["supportedOperations"] = supported_operations + + inputs = self.inputs() + if inputs: + result["inputs"] = inputs + + camera_stream_configurations = self.camera_stream_configurations() + if camera_stream_configurations: + result["cameraStreamConfigurations"] = camera_stream_configurations + + return result + + def serialize_properties(self): + """Return properties serialized for an API response.""" + for prop in self.properties_supported(): + prop_name = prop["name"] + prop_value = self.get_property(prop_name) + if prop_value is not None: + result = { + "name": prop_name, + "namespace": self.name(), + "value": prop_value, + "timeOfSample": dt_util.utcnow().strftime(DATE_FORMAT), + "uncertaintyInMilliseconds": 0, + } + instance = self.instance + if instance is not None: + result["instance"] = instance + + yield result + + +class Alexa(AlexaCapability): + """Implements Alexa Interface. + + Although endpoints implement this interface implicitly, + The API suggests you should explicitly include this interface. + + https://developer.amazon.com/docs/device-apis/alexa-interface.html + """ + + supported_locales = { + "de-DE", + "en-AU", + "en-CA", + "en-GB", + "en-IN", + "en-US", + "es-ES", + "es-MX", + "fr-CA", + "fr-FR", + "it-IT", + "ja-JP", + } + + def name(self): + """Return the Alexa API name of this interface.""" + return "Alexa" + + +class AlexaEndpointHealth(AlexaCapability): + """Implements Alexa.EndpointHealth. + + https://developer.amazon.com/docs/smarthome/state-reporting-for-a-smart-home-skill.html#report-state-when-alexa-requests-it + """ + + supported_locales = { + "de-DE", + "en-AU", + "en-CA", + "en-GB", + "en-IN", + "en-US", + "es-ES", + "fr-FR", + "it-IT", + "ja-JP", + } + + def __init__(self, hass, entity): + """Initialize the entity.""" + super().__init__(entity) + self.hass = hass + + def name(self): + """Return the Alexa API name of this interface.""" + return "Alexa.EndpointHealth" + + def properties_supported(self): + """Return what properties this entity supports.""" + return [{"name": "connectivity"}] + + def properties_proactively_reported(self): + """Return True if properties asynchronously reported.""" + return True + + def properties_retrievable(self): + """Return True if properties can be retrieved.""" + return True + + def get_property(self, name): + """Read and return a property.""" + if name != "connectivity": + raise UnsupportedProperty(name) + + if self.entity.state == STATE_UNAVAILABLE: + return {"value": "UNREACHABLE"} + return {"value": "OK"} + + +class AlexaPowerController(AlexaCapability): + """Implements Alexa.PowerController. + + https://developer.amazon.com/docs/device-apis/alexa-powercontroller.html + """ + + supported_locales = { + "de-DE", + "en-AU", + "en-CA", + "en-GB", + "en-IN", + "en-US", + "es-ES", + "fr-FR", + "it-IT", + "ja-JP", + } + + def name(self): + """Return the Alexa API name of this interface.""" + return "Alexa.PowerController" + + def properties_supported(self): + """Return what properties this entity supports.""" + return [{"name": "powerState"}] + + def properties_proactively_reported(self): + """Return True if properties asynchronously reported.""" + return True + + def properties_retrievable(self): + """Return True if properties can be retrieved.""" + return True + + def get_property(self, name): + """Read and return a property.""" + if name != "powerState": + raise UnsupportedProperty(name) + + if self.entity.domain == climate.DOMAIN: + is_on = self.entity.state != climate.HVAC_MODE_OFF + elif self.entity.domain == vacuum.DOMAIN: + is_on = self.entity.state == vacuum.STATE_CLEANING + elif self.entity.domain == timer.DOMAIN: + is_on = self.entity.state != STATE_IDLE + + else: + is_on = self.entity.state != STATE_OFF + + return "ON" if is_on else "OFF" + + +class AlexaLockController(AlexaCapability): + """Implements Alexa.LockController. + + https://developer.amazon.com/docs/device-apis/alexa-lockcontroller.html + """ + + supported_locales = { + "de-DE", + "en-AU", + "en-CA", + "en-GB", + "en-US", + "es-ES", + "it-IT", + "ja-JP", + } + + def name(self): + """Return the Alexa API name of this interface.""" + return "Alexa.LockController" + + def properties_supported(self): + """Return what properties this entity supports.""" + return [{"name": "lockState"}] + + def properties_retrievable(self): + """Return True if properties can be retrieved.""" + return True + + def properties_proactively_reported(self): + """Return True if properties asynchronously reported.""" + return True + + def get_property(self, name): + """Read and return a property.""" + 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(AlexaCapability): + """Implements Alexa.SceneController. + + https://developer.amazon.com/docs/device-apis/alexa-scenecontroller.html + """ + + supported_locales = { + "de-DE", + "en-CA", + "en-GB", + "en-IN", + "en-US", + "es-ES", + "fr-FR", + "it-IT", + } + + def __init__(self, entity, supports_deactivation): + """Initialize the entity.""" + super().__init__(entity) + self.supports_deactivation = lambda: supports_deactivation + + def name(self): + """Return the Alexa API name of this interface.""" + return "Alexa.SceneController" + + +class AlexaBrightnessController(AlexaCapability): + """Implements Alexa.BrightnessController. + + https://developer.amazon.com/docs/device-apis/alexa-brightnesscontroller.html + """ + + supported_locales = { + "de-DE", + "en-AU", + "en-CA", + "en-GB", + "en-IN", + "en-US", + "es-ES", + "fr-FR", + "it-IT", + "ja-JP", + } + + def name(self): + """Return the Alexa API name of this interface.""" + return "Alexa.BrightnessController" + + def properties_supported(self): + """Return what properties this entity supports.""" + return [{"name": "brightness"}] + + def properties_proactively_reported(self): + """Return True if properties asynchronously reported.""" + return True + + def properties_retrievable(self): + """Return True if properties can be retrieved.""" + return True + + def get_property(self, name): + """Read and return a property.""" + 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(AlexaCapability): + """Implements Alexa.ColorController. + + https://developer.amazon.com/docs/device-apis/alexa-colorcontroller.html + """ + + supported_locales = { + "de-DE", + "en-AU", + "en-CA", + "en-GB", + "en-IN", + "en-US", + "es-ES", + "fr-FR", + "it-IT", + "ja-JP", + } + + def name(self): + """Return the Alexa API name of this interface.""" + return "Alexa.ColorController" + + def properties_supported(self): + """Return what properties this entity supports.""" + return [{"name": "color"}] + + def properties_proactively_reported(self): + """Return True if properties asynchronously reported.""" + return True + + def properties_retrievable(self): + """Return True if properties can be retrieved.""" + return True + + def get_property(self, name): + """Read and return a property.""" + 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(AlexaCapability): + """Implements Alexa.ColorTemperatureController. + + https://developer.amazon.com/docs/device-apis/alexa-colortemperaturecontroller.html + """ + + supported_locales = { + "de-DE", + "en-AU", + "en-CA", + "en-GB", + "en-IN", + "en-US", + "es-ES", + "fr-FR", + "it-IT", + "ja-JP", + } + + def name(self): + """Return the Alexa API name of this interface.""" + return "Alexa.ColorTemperatureController" + + def properties_supported(self): + """Return what properties this entity supports.""" + return [{"name": "colorTemperatureInKelvin"}] + + def properties_proactively_reported(self): + """Return True if properties asynchronously reported.""" + return True + + def properties_retrievable(self): + """Return True if properties can be retrieved.""" + return True + + def get_property(self, name): + """Read and return a property.""" + 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 None + + +class AlexaPercentageController(AlexaCapability): + """Implements Alexa.PercentageController. + + https://developer.amazon.com/docs/device-apis/alexa-percentagecontroller.html + """ + + supported_locales = { + "de-DE", + "en-AU", + "en-CA", + "en-GB", + "en-IN", + "en-US", + "es-ES", + "fr-FR", + "it-IT", + "ja-JP", + } + + def name(self): + """Return the Alexa API name of this interface.""" + return "Alexa.PercentageController" + + def properties_supported(self): + """Return what properties this entity supports.""" + return [{"name": "percentage"}] + + def properties_proactively_reported(self): + """Return True if properties asynchronously reported.""" + return True + + def properties_retrievable(self): + """Return True if properties can be retrieved.""" + return True + + def get_property(self, name): + """Read and return a property.""" + 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(AlexaCapability): + """Implements Alexa.Speaker. + + https://developer.amazon.com/docs/device-apis/alexa-speaker.html + """ + + supported_locales = {"de-DE", "en-AU", "en-CA", "en-GB", "en-IN", "en-US"} + + def name(self): + """Return the Alexa API name of this interface.""" + return "Alexa.Speaker" + + def properties_supported(self): + """Return what properties this entity supports.""" + properties = [{"name": "volume"}] + + supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if supported & media_player.SUPPORT_VOLUME_MUTE: + properties.append({"name": "muted"}) + + return properties + + def properties_proactively_reported(self): + """Return True if properties asynchronously reported.""" + return True + + def properties_retrievable(self): + """Return True if properties can be retrieved.""" + return True + + def get_property(self, name): + """Read and return a property.""" + if name == "volume": + current_level = self.entity.attributes.get( + media_player.ATTR_MEDIA_VOLUME_LEVEL + ) + if current_level is not None: + return round(float(current_level) * 100) + + if name == "muted": + return bool( + self.entity.attributes.get(media_player.ATTR_MEDIA_VOLUME_MUTED) + ) + + return None + + +class AlexaStepSpeaker(AlexaCapability): + """Implements Alexa.StepSpeaker. + + https://developer.amazon.com/docs/device-apis/alexa-stepspeaker.html + """ + + supported_locales = {"de-DE", "en-AU", "en-CA", "en-GB", "en-IN", "en-US"} + + def name(self): + """Return the Alexa API name of this interface.""" + return "Alexa.StepSpeaker" + + +class AlexaPlaybackController(AlexaCapability): + """Implements Alexa.PlaybackController. + + https://developer.amazon.com/docs/device-apis/alexa-playbackcontroller.html + """ + + supported_locales = {"de-DE", "en-AU", "en-CA", "en-GB", "en-IN", "en-US", "fr-FR"} + + def name(self): + """Return the Alexa API name of this interface.""" + return "Alexa.PlaybackController" + + def supported_operations(self): + """Return the supportedOperations object. + + Supported Operations: FastForward, Next, Pause, Play, Previous, Rewind, StartOver, Stop + """ + supported_features = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + + operations = { + media_player.SUPPORT_NEXT_TRACK: "Next", + media_player.SUPPORT_PAUSE: "Pause", + media_player.SUPPORT_PLAY: "Play", + media_player.SUPPORT_PREVIOUS_TRACK: "Previous", + media_player.SUPPORT_STOP: "Stop", + } + + return [ + value + for operation, value in operations.items() + if operation & supported_features + ] + + +class AlexaInputController(AlexaCapability): + """Implements Alexa.InputController. + + https://developer.amazon.com/docs/device-apis/alexa-inputcontroller.html + """ + + supported_locales = {"de-DE", "en-AU", "en-CA", "en-GB", "en-IN", "en-US"} + + def name(self): + """Return the Alexa API name of this interface.""" + return "Alexa.InputController" + + def inputs(self): + """Return the list of valid supported inputs.""" + source_list = self.entity.attributes.get( + media_player.ATTR_INPUT_SOURCE_LIST, [] + ) + return AlexaInputController.get_valid_inputs(source_list) + + @staticmethod + def get_valid_inputs(source_list): + """Return list of supported inputs.""" + input_list = [] + for source in source_list: + formatted_source = ( + source.lower().replace("-", "").replace("_", "").replace(" ", "") + ) + if formatted_source in Inputs.VALID_SOURCE_NAME_MAP.keys(): + input_list.append( + {"name": Inputs.VALID_SOURCE_NAME_MAP[formatted_source]} + ) + + return input_list + + +class AlexaTemperatureSensor(AlexaCapability): + """Implements Alexa.TemperatureSensor. + + https://developer.amazon.com/docs/device-apis/alexa-temperaturesensor.html + """ + + supported_locales = { + "de-DE", + "en-AU", + "en-CA", + "en-GB", + "en-IN", + "en-US", + "es-ES", + "fr-FR", + "it-IT", + "ja-JP", + } + + def __init__(self, hass, entity): + """Initialize the entity.""" + super().__init__(entity) + self.hass = hass + + def name(self): + """Return the Alexa API name of this interface.""" + return "Alexa.TemperatureSensor" + + def properties_supported(self): + """Return what properties this entity supports.""" + return [{"name": "temperature"}] + + def properties_proactively_reported(self): + """Return True if properties asynchronously reported.""" + return True + + def properties_retrievable(self): + """Return True if properties can be retrieved.""" + return True + + def get_property(self, name): + """Read and return a property.""" + 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) + + if temp in (STATE_UNAVAILABLE, STATE_UNKNOWN, None): + return None + + try: + temp = float(temp) + except ValueError: + _LOGGER.warning("Invalid temp value %s for %s", temp, self.entity.entity_id) + return None + + return {"value": temp, "scale": API_TEMP_UNITS[unit]} + + +class AlexaContactSensor(AlexaCapability): + """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 + """ + + supported_locales = {"en-CA", "en-US"} + + def __init__(self, hass, entity): + """Initialize the entity.""" + super().__init__(entity) + self.hass = hass + + def name(self): + """Return the Alexa API name of this interface.""" + return "Alexa.ContactSensor" + + def properties_supported(self): + """Return what properties this entity supports.""" + return [{"name": "detectionState"}] + + def properties_proactively_reported(self): + """Return True if properties asynchronously reported.""" + return True + + def properties_retrievable(self): + """Return True if properties can be retrieved.""" + return True + + def get_property(self, name): + """Read and return a property.""" + if name != "detectionState": + raise UnsupportedProperty(name) + + if self.entity.state == STATE_ON: + return "DETECTED" + return "NOT_DETECTED" + + +class AlexaMotionSensor(AlexaCapability): + """Implements Alexa.MotionSensor. + + https://developer.amazon.com/docs/device-apis/alexa-motionsensor.html + """ + + supported_locales = {"en-CA", "en-US"} + + def __init__(self, hass, entity): + """Initialize the entity.""" + super().__init__(entity) + self.hass = hass + + def name(self): + """Return the Alexa API name of this interface.""" + return "Alexa.MotionSensor" + + def properties_supported(self): + """Return what properties this entity supports.""" + return [{"name": "detectionState"}] + + def properties_proactively_reported(self): + """Return True if properties asynchronously reported.""" + return True + + def properties_retrievable(self): + """Return True if properties can be retrieved.""" + return True + + def get_property(self, name): + """Read and return a property.""" + if name != "detectionState": + raise UnsupportedProperty(name) + + if self.entity.state == STATE_ON: + return "DETECTED" + return "NOT_DETECTED" + + +class AlexaThermostatController(AlexaCapability): + """Implements Alexa.ThermostatController. + + https://developer.amazon.com/docs/device-apis/alexa-thermostatcontroller.html + """ + + supported_locales = { + "de-DE", + "en-AU", + "en-CA", + "en-GB", + "en-IN", + "en-US", + "es-ES", + "fr-FR", + "it-IT", + "ja-JP", + } + + def __init__(self, hass, entity): + """Initialize the entity.""" + super().__init__(entity) + self.hass = hass + + def name(self): + """Return the Alexa API name of this interface.""" + return "Alexa.ThermostatController" + + def properties_supported(self): + """Return what properties this entity supports.""" + properties = [{"name": "thermostatMode"}] + 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_RANGE: + properties.append({"name": "lowerSetpoint"}) + properties.append({"name": "upperSetpoint"}) + return properties + + def properties_proactively_reported(self): + """Return True if properties asynchronously reported.""" + return True + + def properties_retrievable(self): + """Return True if properties can be retrieved.""" + return True + + def get_property(self, name): + """Read and return a property.""" + if self.entity.state == STATE_UNAVAILABLE: + return None + + if name == "thermostatMode": + preset = self.entity.attributes.get(climate.ATTR_PRESET_MODE) + + if preset in API_THERMOSTAT_PRESETS: + mode = API_THERMOSTAT_PRESETS[preset] + else: + mode = API_THERMOSTAT_MODES.get(self.entity.state) + if mode is None: + _LOGGER.error( + "%s (%s) has unsupported state value '%s'", + self.entity.entity_id, + type(self.entity), + self.entity.state, + ) + 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 + + try: + temp = float(temp) + except ValueError: + _LOGGER.warning( + "Invalid temp value %s for %s in %s", temp, name, self.entity.entity_id + ) + return None + + return {"value": temp, "scale": API_TEMP_UNITS[unit]} + + def configuration(self): + """Return configuration object. + + Translates climate HVAC_MODES and PRESETS to supported Alexa ThermostatMode Values. + ThermostatMode Value must be AUTO, COOL, HEAT, ECO, OFF, or CUSTOM. + """ + supported_modes = [] + hvac_modes = self.entity.attributes.get(climate.ATTR_HVAC_MODES) + for mode in hvac_modes: + thermostat_mode = API_THERMOSTAT_MODES.get(mode) + if thermostat_mode: + supported_modes.append(thermostat_mode) + + preset_modes = self.entity.attributes.get(climate.ATTR_PRESET_MODES) + if preset_modes: + for mode in preset_modes: + thermostat_mode = API_THERMOSTAT_PRESETS.get(mode) + if thermostat_mode: + supported_modes.append(thermostat_mode) + + # Return False for supportsScheduling until supported with event listener in handler. + configuration = {"supportsScheduling": False} + + if supported_modes: + configuration["supportedModes"] = supported_modes + + return configuration + + +class AlexaPowerLevelController(AlexaCapability): + """Implements Alexa.PowerLevelController. + + https://developer.amazon.com/docs/device-apis/alexa-powerlevelcontroller.html + """ + + supported_locales = { + "de-DE", + "en-AU", + "en-CA", + "en-GB", + "en-IN", + "en-US", + "es-ES", + "fr-FR", + "it-IT", + "ja-JP", + } + + def name(self): + """Return the Alexa API name of this interface.""" + return "Alexa.PowerLevelController" + + def properties_supported(self): + """Return what properties this entity supports.""" + return [{"name": "powerLevel"}] + + def properties_proactively_reported(self): + """Return True if properties asynchronously reported.""" + return True + + def properties_retrievable(self): + """Return True if properties can be retrieved.""" + return True + + def get_property(self, name): + """Read and return a property.""" + if name != "powerLevel": + raise UnsupportedProperty(name) + + if self.entity.domain == fan.DOMAIN: + speed = self.entity.attributes.get(fan.ATTR_SPEED) + + return PERCENTAGE_FAN_MAP.get(speed) + + return None + + +class AlexaSecurityPanelController(AlexaCapability): + """Implements Alexa.SecurityPanelController. + + https://developer.amazon.com/docs/device-apis/alexa-securitypanelcontroller.html + """ + + supported_locales = {"en-AU", "en-CA", "en-IN", "en-US"} + + def __init__(self, hass, entity): + """Initialize the entity.""" + super().__init__(entity) + self.hass = hass + + def name(self): + """Return the Alexa API name of this interface.""" + return "Alexa.SecurityPanelController" + + def properties_supported(self): + """Return what properties this entity supports.""" + return [{"name": "armState"}] + + def properties_proactively_reported(self): + """Return True if properties asynchronously reported.""" + return True + + def properties_retrievable(self): + """Return True if properties can be retrieved.""" + return True + + def get_property(self, name): + """Read and return a property.""" + if name != "armState": + raise UnsupportedProperty(name) + + arm_state = self.entity.state + if arm_state == STATE_ALARM_ARMED_HOME: + return "ARMED_STAY" + if arm_state == STATE_ALARM_ARMED_AWAY: + return "ARMED_AWAY" + if arm_state == STATE_ALARM_ARMED_NIGHT: + return "ARMED_NIGHT" + if arm_state == STATE_ALARM_ARMED_CUSTOM_BYPASS: + return "ARMED_STAY" + return "DISARMED" + + def configuration(self): + """Return configuration object with supported authorization types.""" + code_format = self.entity.attributes.get(ATTR_CODE_FORMAT) + supported = self.entity.attributes[ATTR_SUPPORTED_FEATURES] + configuration = {} + + supported_arm_states = [{"value": "DISARMED"}] + if supported & SUPPORT_ALARM_ARM_AWAY: + supported_arm_states.append({"value": "ARMED_AWAY"}) + if supported & SUPPORT_ALARM_ARM_HOME: + supported_arm_states.append({"value": "ARMED_STAY"}) + if supported & SUPPORT_ALARM_ARM_NIGHT: + supported_arm_states.append({"value": "ARMED_NIGHT"}) + + configuration["supportedArmStates"] = supported_arm_states + + if code_format == FORMAT_NUMBER: + configuration["supportedAuthorizationTypes"] = [{"type": "FOUR_DIGIT_PIN"}] + + return configuration + + +class AlexaModeController(AlexaCapability): + """Implements Alexa.ModeController. + + The instance property must be unique across ModeController, RangeController, ToggleController within the same device. + The instance property should be a concatenated string of device domain period and single word. + e.g. fan.speed & fan.direction. + + The instance property must not contain words from other instance property strings within the same device. + e.g. Instance property cover.position & cover.tilt_position will cause the Alexa.Discovery directive to fail. + + An instance property string value may be reused for different devices. + + https://developer.amazon.com/docs/device-apis/alexa-modecontroller.html + """ + + supported_locales = { + "de-DE", + "en-AU", + "en-CA", + "en-GB", + "en-IN", + "en-US", + "es-ES", + "es-MX", + "fr-CA", + "fr-FR", + "it-IT", + "ja-JP", + } + + def __init__(self, entity, instance, non_controllable=False): + """Initialize the entity.""" + super().__init__(entity, instance) + self._resource = None + self._semantics = None + self.properties_non_controllable = lambda: non_controllable + + def name(self): + """Return the Alexa API name of this interface.""" + return "Alexa.ModeController" + + def properties_supported(self): + """Return what properties this entity supports.""" + return [{"name": "mode"}] + + def properties_proactively_reported(self): + """Return True if properties asynchronously reported.""" + return True + + def properties_retrievable(self): + """Return True if properties can be retrieved.""" + return True + + def get_property(self, name): + """Read and return a property.""" + if name != "mode": + raise UnsupportedProperty(name) + + # Fan Direction + if self.instance == f"{fan.DOMAIN}.{fan.ATTR_DIRECTION}": + mode = self.entity.attributes.get(fan.ATTR_DIRECTION, None) + if mode in (fan.DIRECTION_FORWARD, fan.DIRECTION_REVERSE, STATE_UNKNOWN): + return f"{fan.ATTR_DIRECTION}.{mode}" + + # Cover Position + if self.instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": + # Return state instead of position when using ModeController. + mode = self.entity.state + if mode in ( + cover.STATE_OPEN, + cover.STATE_OPENING, + cover.STATE_CLOSED, + cover.STATE_CLOSING, + STATE_UNKNOWN, + ): + return f"{cover.ATTR_POSITION}.{mode}" + + return None + + def configuration(self): + """Return configuration with modeResources.""" + if isinstance(self._resource, AlexaCapabilityResource): + return self._resource.serialize_configuration() + + return None + + def capability_resources(self): + """Return capabilityResources object.""" + + # Fan Direction Resource + if self.instance == f"{fan.DOMAIN}.{fan.ATTR_DIRECTION}": + self._resource = AlexaModeResource( + [AlexaGlobalCatalog.SETTING_DIRECTION], False + ) + self._resource.add_mode( + f"{fan.ATTR_DIRECTION}.{fan.DIRECTION_FORWARD}", [fan.DIRECTION_FORWARD] + ) + self._resource.add_mode( + f"{fan.ATTR_DIRECTION}.{fan.DIRECTION_REVERSE}", [fan.DIRECTION_REVERSE] + ) + return self._resource.serialize_capability_resources() + + # Cover Position Resources + if self.instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": + self._resource = AlexaModeResource( + ["Position", AlexaGlobalCatalog.SETTING_OPENING], False + ) + self._resource.add_mode( + f"{cover.ATTR_POSITION}.{cover.STATE_OPEN}", + [AlexaGlobalCatalog.VALUE_OPEN], + ) + self._resource.add_mode( + f"{cover.ATTR_POSITION}.{cover.STATE_CLOSED}", + [AlexaGlobalCatalog.VALUE_CLOSE], + ) + self._resource.add_mode( + f"{cover.ATTR_POSITION}.custom", + ["Custom", AlexaGlobalCatalog.SETTING_PRESET], + ) + return self._resource.serialize_capability_resources() + + return None + + def semantics(self): + """Build and return semantics object.""" + supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + + # Cover Position + if self.instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": + lower_labels = [AlexaSemantics.ACTION_LOWER] + raise_labels = [AlexaSemantics.ACTION_RAISE] + self._semantics = AlexaSemantics() + + # Add open/close semantics if tilt is not supported. + if not supported & cover.SUPPORT_SET_TILT_POSITION: + lower_labels.append(AlexaSemantics.ACTION_CLOSE) + raise_labels.append(AlexaSemantics.ACTION_OPEN) + self._semantics.add_states_to_value( + [AlexaSemantics.STATES_CLOSED], + f"{cover.ATTR_POSITION}.{cover.STATE_CLOSED}", + ) + self._semantics.add_states_to_value( + [AlexaSemantics.STATES_OPEN], + f"{cover.ATTR_POSITION}.{cover.STATE_OPEN}", + ) + + self._semantics.add_action_to_directive( + lower_labels, + "SetMode", + {"mode": f"{cover.ATTR_POSITION}.{cover.STATE_CLOSED}"}, + ) + self._semantics.add_action_to_directive( + raise_labels, + "SetMode", + {"mode": f"{cover.ATTR_POSITION}.{cover.STATE_OPEN}"}, + ) + + return self._semantics.serialize_semantics() + + return None + + +class AlexaRangeController(AlexaCapability): + """Implements Alexa.RangeController. + + The instance property must be unique across ModeController, RangeController, ToggleController within the same device. + The instance property should be a concatenated string of device domain period and single word. + e.g. fan.speed & fan.direction. + + The instance property must not contain words from other instance property strings within the same device. + e.g. Instance property cover.position & cover.tilt_position will cause the Alexa.Discovery directive to fail. + + An instance property string value may be reused for different devices. + + https://developer.amazon.com/docs/device-apis/alexa-rangecontroller.html + """ + + supported_locales = { + "de-DE", + "en-AU", + "en-CA", + "en-GB", + "en-IN", + "en-US", + "es-ES", + "es-MX", + "fr-CA", + "fr-FR", + "it-IT", + "ja-JP", + } + + def __init__(self, entity, instance, non_controllable=False): + """Initialize the entity.""" + super().__init__(entity, instance) + self._resource = None + self._semantics = None + self.properties_non_controllable = lambda: non_controllable + + def name(self): + """Return the Alexa API name of this interface.""" + return "Alexa.RangeController" + + def properties_supported(self): + """Return what properties this entity supports.""" + return [{"name": "rangeValue"}] + + def properties_proactively_reported(self): + """Return True if properties asynchronously reported.""" + return True + + def properties_retrievable(self): + """Return True if properties can be retrieved.""" + return True + + def get_property(self, name): + """Read and return a property.""" + if name != "rangeValue": + raise UnsupportedProperty(name) + + # Return None for unavailable and unknown states. + # Allows the Alexa.EndpointHealth Interface to handle the unavailable state in a stateReport. + if self.entity.state in (STATE_UNAVAILABLE, STATE_UNKNOWN, None): + return None + + # Fan Speed + if self.instance == f"{fan.DOMAIN}.{fan.ATTR_SPEED}": + speed_list = self.entity.attributes.get(fan.ATTR_SPEED_LIST) + speed = self.entity.attributes.get(fan.ATTR_SPEED) + if speed_list is not None and speed is not None: + speed_index = next( + (i for i, v in enumerate(speed_list) if v == speed), None + ) + return speed_index + + # Cover Position + if self.instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": + return self.entity.attributes.get(cover.ATTR_CURRENT_POSITION) + + # Cover Tilt + if self.instance == f"{cover.DOMAIN}.tilt": + return self.entity.attributes.get(cover.ATTR_CURRENT_TILT_POSITION) + + # Input Number Value + if self.instance == f"{input_number.DOMAIN}.{input_number.ATTR_VALUE}": + return float(self.entity.state) + + # Vacuum Fan Speed + if self.instance == f"{vacuum.DOMAIN}.{vacuum.ATTR_FAN_SPEED}": + speed_list = self.entity.attributes.get(vacuum.ATTR_FAN_SPEED_LIST) + speed = self.entity.attributes.get(vacuum.ATTR_FAN_SPEED) + if speed_list is not None and speed is not None: + speed_index = next( + (i for i, v in enumerate(speed_list) if v == speed), None + ) + return speed_index + + return None + + def configuration(self): + """Return configuration with presetResources.""" + if isinstance(self._resource, AlexaCapabilityResource): + return self._resource.serialize_configuration() + + return None + + def capability_resources(self): + """Return capabilityResources object.""" + + # Fan Speed Resources + if self.instance == f"{fan.DOMAIN}.{fan.ATTR_SPEED}": + speed_list = self.entity.attributes[fan.ATTR_SPEED_LIST] + max_value = len(speed_list) - 1 + self._resource = AlexaPresetResource( + labels=[AlexaGlobalCatalog.SETTING_FAN_SPEED], + min_value=0, + max_value=max_value, + precision=1, + ) + for index, speed in enumerate(speed_list): + labels = [] + if isinstance(speed, str): + labels.append(speed.replace("_", " ")) + if index == 1: + labels.append(AlexaGlobalCatalog.VALUE_MINIMUM) + if index == max_value: + labels.append(AlexaGlobalCatalog.VALUE_MAXIMUM) + + if len(labels) > 0: + self._resource.add_preset(value=index, labels=labels) + + return self._resource.serialize_capability_resources() + + # Cover Position Resources + if self.instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": + self._resource = AlexaPresetResource( + ["Position", AlexaGlobalCatalog.SETTING_OPENING], + min_value=0, + max_value=100, + precision=1, + unit=AlexaGlobalCatalog.UNIT_PERCENT, + ) + return self._resource.serialize_capability_resources() + + # Cover Tilt Resources + if self.instance == f"{cover.DOMAIN}.tilt": + self._resource = AlexaPresetResource( + ["Tilt", "Angle", AlexaGlobalCatalog.SETTING_DIRECTION], + min_value=0, + max_value=100, + precision=1, + unit=AlexaGlobalCatalog.UNIT_PERCENT, + ) + return self._resource.serialize_capability_resources() + + # Input Number Value + if self.instance == f"{input_number.DOMAIN}.{input_number.ATTR_VALUE}": + min_value = float(self.entity.attributes[input_number.ATTR_MIN]) + max_value = float(self.entity.attributes[input_number.ATTR_MAX]) + precision = float(self.entity.attributes.get(input_number.ATTR_STEP, 1)) + unit = self.entity.attributes.get(input_number.ATTR_UNIT_OF_MEASUREMENT) + + self._resource = AlexaPresetResource( + ["Value", AlexaGlobalCatalog.SETTING_PRESET], + min_value=min_value, + max_value=max_value, + precision=precision, + unit=unit, + ) + self._resource.add_preset( + value=min_value, labels=[AlexaGlobalCatalog.VALUE_MINIMUM] + ) + self._resource.add_preset( + value=max_value, labels=[AlexaGlobalCatalog.VALUE_MAXIMUM] + ) + return self._resource.serialize_capability_resources() + + # Vacuum Fan Speed Resources + if self.instance == f"{vacuum.DOMAIN}.{vacuum.ATTR_FAN_SPEED}": + speed_list = self.entity.attributes[vacuum.ATTR_FAN_SPEED_LIST] + max_value = len(speed_list) - 1 + self._resource = AlexaPresetResource( + labels=[AlexaGlobalCatalog.SETTING_FAN_SPEED], + min_value=0, + max_value=max_value, + precision=1, + ) + for index, speed in enumerate(speed_list): + labels = [speed.replace("_", " ")] + if index == 1: + labels.append(AlexaGlobalCatalog.VALUE_MINIMUM) + if index == max_value: + labels.append(AlexaGlobalCatalog.VALUE_MAXIMUM) + self._resource.add_preset(value=index, labels=labels) + + return self._resource.serialize_capability_resources() + + return None + + def semantics(self): + """Build and return semantics object.""" + supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + + # Cover Position + if self.instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": + lower_labels = [AlexaSemantics.ACTION_LOWER] + raise_labels = [AlexaSemantics.ACTION_RAISE] + self._semantics = AlexaSemantics() + + # Add open/close semantics if tilt is not supported. + if not supported & cover.SUPPORT_SET_TILT_POSITION: + lower_labels.append(AlexaSemantics.ACTION_CLOSE) + raise_labels.append(AlexaSemantics.ACTION_OPEN) + self._semantics.add_states_to_value( + [AlexaSemantics.STATES_CLOSED], value=0 + ) + self._semantics.add_states_to_range( + [AlexaSemantics.STATES_OPEN], min_value=1, max_value=100 + ) + + self._semantics.add_action_to_directive( + lower_labels, "SetRangeValue", {"rangeValue": 0} + ) + self._semantics.add_action_to_directive( + raise_labels, "SetRangeValue", {"rangeValue": 100} + ) + return self._semantics.serialize_semantics() + + # Cover Tilt + if self.instance == f"{cover.DOMAIN}.tilt": + self._semantics = AlexaSemantics() + self._semantics.add_action_to_directive( + [AlexaSemantics.ACTION_CLOSE], "SetRangeValue", {"rangeValue": 0} + ) + self._semantics.add_action_to_directive( + [AlexaSemantics.ACTION_OPEN], "SetRangeValue", {"rangeValue": 100} + ) + self._semantics.add_states_to_value([AlexaSemantics.STATES_CLOSED], value=0) + self._semantics.add_states_to_range( + [AlexaSemantics.STATES_OPEN], min_value=1, max_value=100 + ) + return self._semantics.serialize_semantics() + + return None + + +class AlexaToggleController(AlexaCapability): + """Implements Alexa.ToggleController. + + The instance property must be unique across ModeController, RangeController, ToggleController within the same device. + The instance property should be a concatenated string of device domain period and single word. + e.g. fan.speed & fan.direction. + + The instance property must not contain words from other instance property strings within the same device. + e.g. Instance property cover.position & cover.tilt_position will cause the Alexa.Discovery directive to fail. + + An instance property string value may be reused for different devices. + + https://developer.amazon.com/docs/device-apis/alexa-togglecontroller.html + """ + + supported_locales = { + "de-DE", + "en-AU", + "en-CA", + "en-GB", + "en-IN", + "en-US", + "es-ES", + "es-MX", + "fr-CA", + "fr-FR", + "it-IT", + "ja-JP", + } + + def __init__(self, entity, instance, non_controllable=False): + """Initialize the entity.""" + super().__init__(entity, instance) + self._resource = None + self._semantics = None + self.properties_non_controllable = lambda: non_controllable + + def name(self): + """Return the Alexa API name of this interface.""" + return "Alexa.ToggleController" + + def properties_supported(self): + """Return what properties this entity supports.""" + return [{"name": "toggleState"}] + + def properties_proactively_reported(self): + """Return True if properties asynchronously reported.""" + return True + + def properties_retrievable(self): + """Return True if properties can be retrieved.""" + return True + + def get_property(self, name): + """Read and return a property.""" + if name != "toggleState": + raise UnsupportedProperty(name) + + # Fan Oscillating + if self.instance == f"{fan.DOMAIN}.{fan.ATTR_OSCILLATING}": + is_on = bool(self.entity.attributes.get(fan.ATTR_OSCILLATING)) + return "ON" if is_on else "OFF" + + return None + + def capability_resources(self): + """Return capabilityResources object.""" + + # Fan Oscillating Resource + if self.instance == f"{fan.DOMAIN}.{fan.ATTR_OSCILLATING}": + self._resource = AlexaCapabilityResource( + [AlexaGlobalCatalog.SETTING_OSCILLATE, "Rotate", "Rotation"] + ) + return self._resource.serialize_capability_resources() + + return None + + +class AlexaChannelController(AlexaCapability): + """Implements Alexa.ChannelController. + + https://developer.amazon.com/docs/device-apis/alexa-channelcontroller.html + """ + + supported_locales = {"de-DE", "en-AU", "en-CA", "en-GB", "en-IN", "en-US"} + + def name(self): + """Return the Alexa API name of this interface.""" + return "Alexa.ChannelController" + + +class AlexaDoorbellEventSource(AlexaCapability): + """Implements Alexa.DoorbellEventSource. + + https://developer.amazon.com/docs/device-apis/alexa-doorbelleventsource.html + """ + + supported_locales = {"en-US"} + + def name(self): + """Return the Alexa API name of this interface.""" + return "Alexa.DoorbellEventSource" + + def capability_proactively_reported(self): + """Return True for proactively reported capability.""" + return True + + +class AlexaPlaybackStateReporter(AlexaCapability): + """Implements Alexa.PlaybackStateReporter. + + https://developer.amazon.com/docs/device-apis/alexa-playbackstatereporter.html + """ + + supported_locales = {"de-DE", "en-GB", "en-US", "fr-FR"} + + def name(self): + """Return the Alexa API name of this interface.""" + return "Alexa.PlaybackStateReporter" + + def properties_supported(self): + """Return what properties this entity supports.""" + return [{"name": "playbackState"}] + + def properties_proactively_reported(self): + """Return True if properties asynchronously reported.""" + return True + + def properties_retrievable(self): + """Return True if properties can be retrieved.""" + return True + + def get_property(self, name): + """Read and return a property.""" + if name != "playbackState": + raise UnsupportedProperty(name) + + playback_state = self.entity.state + if playback_state == STATE_PLAYING: + return {"state": "PLAYING"} + if playback_state == STATE_PAUSED: + return {"state": "PAUSED"} + + return {"state": "STOPPED"} + + +class AlexaSeekController(AlexaCapability): + """Implements Alexa.SeekController. + + https://developer.amazon.com/docs/device-apis/alexa-seekcontroller.html + """ + + supported_locales = {"de-DE", "en-GB", "en-US"} + + def name(self): + """Return the Alexa API name of this interface.""" + return "Alexa.SeekController" + + +class AlexaEventDetectionSensor(AlexaCapability): + """Implements Alexa.EventDetectionSensor. + + https://developer.amazon.com/docs/device-apis/alexa-eventdetectionsensor.html + """ + + supported_locales = {"en-US"} + + def __init__(self, hass, entity): + """Initialize the entity.""" + super().__init__(entity) + self.hass = hass + + def name(self): + """Return the Alexa API name of this interface.""" + return "Alexa.EventDetectionSensor" + + def properties_supported(self): + """Return what properties this entity supports.""" + return [{"name": "humanPresenceDetectionState"}] + + def properties_proactively_reported(self): + """Return True if properties asynchronously reported.""" + return True + + def get_property(self, name): + """Read and return a property.""" + if name != "humanPresenceDetectionState": + raise UnsupportedProperty(name) + + human_presence = "NOT_DETECTED" + state = self.entity.state + + # Return None for unavailable and unknown states. + # Allows the Alexa.EndpointHealth Interface to handle the unavailable state in a stateReport. + if state in (STATE_UNAVAILABLE, STATE_UNKNOWN, None): + return None + + if self.entity.domain == image_processing.DOMAIN: + if int(state): + human_presence = "DETECTED" + elif state == STATE_ON: + human_presence = "DETECTED" + + return {"value": human_presence} + + def configuration(self): + """Return supported detection types.""" + return { + "detectionMethods": ["AUDIO", "VIDEO"], + "detectionModes": { + "humanPresence": { + "featureAvailability": "ENABLED", + "supportsNotDetected": True, + } + }, + } + + +class AlexaEqualizerController(AlexaCapability): + """Implements Alexa.EqualizerController. + + https://developer.amazon.com/en-US/docs/alexa/device-apis/alexa-equalizercontroller.html + """ + + supported_locales = {"en-US"} + + def name(self): + """Return the Alexa API name of this interface.""" + return "Alexa.EqualizerController" + + def properties_supported(self): + """Return what properties this entity supports. + + Either bands, mode or both can be specified. Only mode is supported at this time. + """ + return [{"name": "mode"}] + + def get_property(self, name): + """Read and return a property.""" + if name != "mode": + raise UnsupportedProperty(name) + + sound_mode = self.entity.attributes.get(media_player.ATTR_SOUND_MODE) + if sound_mode and sound_mode.upper() in ( + "MOVIE", + "MUSIC", + "NIGHT", + "SPORT", + "TV", + ): + return sound_mode.upper() + + return None + + def configurations(self): + """Return the sound modes supported in the configurations object. + + Valid Values for modes are: MOVIE, MUSIC, NIGHT, SPORT, TV. + """ + configurations = None + sound_mode_list = self.entity.attributes.get(media_player.ATTR_SOUND_MODE_LIST) + if sound_mode_list: + supported_sound_modes = [ + {"name": sound_mode.upper()} + for sound_mode in sound_mode_list + if sound_mode.upper() in ("MOVIE", "MUSIC", "NIGHT", "SPORT", "TV") + ] + + configurations = {"modes": {"supported": supported_sound_modes}} + + return configurations + + +class AlexaTimeHoldController(AlexaCapability): + """Implements Alexa.TimeHoldController. + + https://developer.amazon.com/docs/device-apis/alexa-timeholdcontroller.html + """ + + supported_locales = {"en-US"} + + def __init__(self, entity, allow_remote_resume=False): + """Initialize the entity.""" + super().__init__(entity) + self._allow_remote_resume = allow_remote_resume + + def name(self): + """Return the Alexa API name of this interface.""" + return "Alexa.TimeHoldController" + + def configuration(self): + """Return configuration object. + + Set allowRemoteResume to True if Alexa can restart the operation on the device. + When false, Alexa does not send the Resume directive. + """ + return {"allowRemoteResume": self._allow_remote_resume} + + +class AlexaCameraStreamController(AlexaCapability): + """Implements Alexa.CameraStreamController. + + https://developer.amazon.com/docs/device-apis/alexa-camerastreamcontroller.html + """ + + supported_locales = { + "de-DE", + "en-AU", + "en-CA", + "en-GB", + "en-IN", + "en-US", + "es-ES", + "fr-FR", + "it-IT", + "ja-JP", + } + + def name(self): + """Return the Alexa API name of this interface.""" + return "Alexa.CameraStreamController" + + def camera_stream_configurations(self): + """Return cameraStreamConfigurations object.""" + return [ + { + "protocols": ["HLS"], + "resolutions": [{"width": 1280, "height": 720}], + "authorizationTypes": ["NONE"], + "videoCodecs": ["H264"], + "audioCodecs": ["AAC"], + } + ] diff --git a/homeassistant/components/alexa/config.py b/homeassistant/components/alexa/config.py new file mode 100644 index 0000000000000..7d3a3994acef5 --- /dev/null +++ b/homeassistant/components/alexa/config.py @@ -0,0 +1,84 @@ +"""Config helpers for Alexa.""" +from abc import ABC, abstractmethod + +from homeassistant.core import callback + +from .state_report import async_enable_proactive_mode + + +class AbstractConfig(ABC): + """Hold the configuration for Alexa.""" + + _unsub_proactive_report = None + + def __init__(self, hass): + """Initialize abstract config.""" + self.hass = hass + + @property + def supports_auth(self): + """Return if config supports auth.""" + return False + + @property + def should_report_state(self): + """Return if states should be proactively reported.""" + return False + + @property + def endpoint(self): + """Endpoint for report state.""" + return None + + @property + @abstractmethod + def locale(self): + """Return config locale.""" + + @property + def entity_config(self): + """Return entity config.""" + return {} + + @property + def is_reporting_states(self): + """Return if proactive mode is enabled.""" + return self._unsub_proactive_report is not None + + async def async_enable_proactive_mode(self): + """Enable proactive mode.""" + if self._unsub_proactive_report is None: + self._unsub_proactive_report = self.hass.async_create_task( + async_enable_proactive_mode(self.hass, self) + ) + try: + await self._unsub_proactive_report + except Exception: + self._unsub_proactive_report = None + raise + + async def async_disable_proactive_mode(self): + """Disable proactive mode.""" + unsub_func = await self._unsub_proactive_report + if unsub_func: + unsub_func() + self._unsub_proactive_report = None + + @callback + def should_expose(self, entity_id): + """If an entity should be exposed.""" + # pylint: disable=no-self-use + return False + + @callback + def async_invalidate_access_token(self): + """Invalidate access token.""" + raise NotImplementedError + + async def async_get_access_token(self): + """Get an access token.""" + raise NotImplementedError + + async def async_accept_grant(self, code): + """Accept a grant.""" + raise NotImplementedError diff --git a/homeassistant/components/alexa/const.py b/homeassistant/components/alexa/const.py index 78f7d02f5f03e..ca1c6236fe6e9 100644 --- a/homeassistant/components/alexa/const.py +++ b/homeassistant/components/alexa/const.py @@ -1,28 +1,208 @@ """Constants for the Alexa integration.""" -DOMAIN = 'alexa' +from collections import OrderedDict + +from homeassistant.components import fan +from homeassistant.components.climate import const as climate +from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT + +DOMAIN = "alexa" +EVENT_ALEXA_SMART_HOME = "alexa_smart_home" # 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 +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" +CONF_LOCALE = "locale" + +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" + +API_DIRECTIVE = "directive" +API_ENDPOINT = "endpoint" +API_EVENT = "event" +API_CONTEXT = "context" +API_HEADER = "header" +API_PAYLOAD = "payload" +API_SCOPE = "scope" +API_CHANGE = "change" + +CONF_DESCRIPTION = "description" +CONF_DISPLAY_CATEGORIES = "display_categories" +CONF_SUPPORTED_LOCALES = ( + "de-DE", + "en-AU", + "en-CA", + "en-GB", + "en-IN", + "en-US", + "es-ES", + "es-MX", + "fr-CA", + "fr-FR", + "it-IT", + "ja-JP", +) + +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 occurrence of OFF +# back to HA state. +API_THERMOSTAT_MODES = OrderedDict( + [ + (climate.HVAC_MODE_HEAT, "HEAT"), + (climate.HVAC_MODE_COOL, "COOL"), + (climate.HVAC_MODE_HEAT_COOL, "AUTO"), + (climate.HVAC_MODE_AUTO, "AUTO"), + (climate.HVAC_MODE_OFF, "OFF"), + (climate.HVAC_MODE_FAN_ONLY, "OFF"), + (climate.HVAC_MODE_DRY, "CUSTOM"), + ] +) +API_THERMOSTAT_MODES_CUSTOM = {climate.HVAC_MODE_DRY: "DEHUMIDIFY"} +API_THERMOSTAT_PRESETS = {climate.PRESET_ECO: "ECO"} + +PERCENTAGE_FAN_MAP = { + fan.SPEED_OFF: 0, + fan.SPEED_LOW: 33, + fan.SPEED_MEDIUM: 66, + fan.SPEED_HIGH: 100, +} + + +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 Inputs: + """Valid names for the InputController. + + https://developer.amazon.com/docs/device-apis/alexa-property-schemas.html#input + """ + + VALID_SOURCE_NAME_MAP = { + "aux": "AUX 1", + "aux1": "AUX 1", + "aux2": "AUX 2", + "aux3": "AUX 3", + "aux4": "AUX 4", + "aux5": "AUX 5", + "aux6": "AUX 6", + "aux7": "AUX 7", + "bluray": "BLURAY", + "cable": "CABLE", + "cd": "CD", + "coax": "COAX 1", + "coax1": "COAX 1", + "coax2": "COAX 2", + "composite": "COMPOSITE 1", + "composite1": "COMPOSITE 1", + "dvd": "DVD", + "game": "GAME", + "gameconsole": "GAME", + "hdradio": "HD RADIO", + "hdmi": "HDMI 1", + "hdmi1": "HDMI 1", + "hdmi2": "HDMI 2", + "hdmi3": "HDMI 3", + "hdmi4": "HDMI 4", + "hdmi5": "HDMI 5", + "hdmi6": "HDMI 6", + "hdmi7": "HDMI 7", + "hdmi8": "HDMI 8", + "hdmi9": "HDMI 9", + "hdmi10": "HDMI 10", + "hdmiarc": "HDMI ARC", + "input": "INPUT 1", + "input1": "INPUT 1", + "input2": "INPUT 2", + "input3": "INPUT 3", + "input4": "INPUT 4", + "input5": "INPUT 5", + "input6": "INPUT 6", + "input7": "INPUT 7", + "input8": "INPUT 8", + "input9": "INPUT 9", + "input10": "INPUT 10", + "ipod": "IPOD", + "line": "LINE 1", + "line1": "LINE 1", + "line2": "LINE 2", + "line3": "LINE 3", + "line4": "LINE 4", + "line5": "LINE 5", + "line6": "LINE 6", + "line7": "LINE 7", + "mediaplayer": "MEDIA PLAYER", + "optical": "OPTICAL 1", + "optical1": "OPTICAL 1", + "optical2": "OPTICAL 2", + "phono": "PHONO", + "playstation": "PLAYSTATION", + "playstation3": "PLAYSTATION 3", + "playstation4": "PLAYSTATION 4", + "satellite": "SATELLITE", + "satellitetv": "SATELLITE", + "smartcast": "SMARTCAST", + "tuner": "TUNER", + "tv": "TV", + "usbdac": "USB DAC", + "video": "VIDEO 1", + "video1": "VIDEO 1", + "video2": "VIDEO 2", + "video3": "VIDEO 3", + "xbox": "XBOX", + } + + VALID_SOUND_MODE_MAP = { + "movie": "MOVIE", + "music": "MUSIC", + "night": "NIGHT", + "sport": "SPORT", + "tv": "TV", + } diff --git a/homeassistant/components/alexa/entities.py b/homeassistant/components/alexa/entities.py new file mode 100644 index 0000000000000..e22c5c62db907 --- /dev/null +++ b/homeassistant/components/alexa/entities.py @@ -0,0 +1,809 @@ +"""Alexa entity adapters.""" +import logging +from typing import List +from urllib.parse import urlparse + +from homeassistant.components import ( + alarm_control_panel, + alert, + automation, + binary_sensor, + camera, + cover, + fan, + group, + image_processing, + input_boolean, + input_number, + light, + lock, + media_player, + scene, + script, + sensor, + switch, + timer, + vacuum, +) +from homeassistant.components.climate import const as climate +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_SUPPORTED_FEATURES, + ATTR_UNIT_OF_MEASUREMENT, + CLOUD_NEVER_EXPOSED_ENTITIES, + CONF_NAME, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, +) +from homeassistant.core import callback +from homeassistant.helpers import network +from homeassistant.util.decorator import Registry + +from .capabilities import ( + Alexa, + AlexaBrightnessController, + AlexaCameraStreamController, + AlexaChannelController, + AlexaColorController, + AlexaColorTemperatureController, + AlexaContactSensor, + AlexaDoorbellEventSource, + AlexaEndpointHealth, + AlexaEqualizerController, + AlexaEventDetectionSensor, + AlexaInputController, + AlexaLockController, + AlexaModeController, + AlexaMotionSensor, + AlexaPercentageController, + AlexaPlaybackController, + AlexaPlaybackStateReporter, + AlexaPowerController, + AlexaPowerLevelController, + AlexaRangeController, + AlexaSceneController, + AlexaSecurityPanelController, + AlexaSeekController, + AlexaSpeaker, + AlexaStepSpeaker, + AlexaTemperatureSensor, + AlexaThermostatController, + AlexaTimeHoldController, + AlexaToggleController, +) +from .const import CONF_DESCRIPTION, CONF_DISPLAY_CATEGORIES + +_LOGGER = logging.getLogger(__name__) + +ENTITY_ADAPTERS = Registry() + +TRANSLATION_TABLE = dict.fromkeys(map(ord, r"}{\/|\"()[]+~!><*%"), None) + + +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 a non-mobile computer, such as a desktop computer. + COMPUTER = "COMPUTER" + + # Indicates an endpoint that detects and reports contact. + CONTACT_SENSOR = "CONTACT_SENSOR" + + # Indicates a door. + DOOR = "DOOR" + + # Indicates a doorbell. + DOORBELL = "DOORBELL" + + # Indicates a window covering on the outside of a structure. + EXTERIOR_BLIND = "EXTERIOR_BLIND" + + # Indicates a fan. + FAN = "FAN" + + # Indicates a game console, such as Microsoft Xbox or Nintendo Switch + GAME_CONSOLE = "GAME_CONSOLE" + + # Indicates a garage door. Garage doors must implement the ModeController interface to open and close the door. + GARAGE_DOOR = "GARAGE_DOOR" + + # Indicates a window covering on the inside of a structure. + INTERIOR_BLIND = "INTERIOR_BLIND" + + # Indicates a laptop or other mobile computer. + LAPTOP = "LAPTOP" + + # Indicates light sources or fixtures. + LIGHT = "LIGHT" + + # Indicates a microwave oven. + MICROWAVE = "MICROWAVE" + + # Indicates a mobile phone. + MOBILE_PHONE = "MOBILE_PHONE" + + # Indicates an endpoint that detects and reports motion. + MOTION_SENSOR = "MOTION_SENSOR" + + # Indicates a network-connected music system. + MUSIC_SYSTEM = "MUSIC_SYSTEM" + + # An endpoint that cannot be described in on of the other categories. + OTHER = "OTHER" + + # Indicates a network router. + NETWORK_HARDWARE = "NETWORK_HARDWARE" + + # Indicates an oven cooking appliance. + OVEN = "OVEN" + + # Indicates a non-mobile phone, such as landline or an IP phone. + PHONE = "PHONE" + + # 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 a projector screen. + SCREEN = "SCREEN" + + # Indicates a security panel. + SECURITY_PANEL = "SECURITY_PANEL" + + # 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 a streaming device such as Apple TV, Chromecast, or Roku. + STREAMING_DEVICE = "STREAMING_DEVICE" + + # Indicates in-wall switches wired to the electrical system. Can control a + # variety of devices. + SWITCH = "SWITCH" + + # Indicates a tablet computer. + TABLET = "TABLET" + + # 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" + + # Indicates a network-connected wearable device, such as an Apple Watch, Fitbit, or Samsung Gear. + WEARABLE = "WEARABLE" + + +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): + """Initialize Alexa Entity.""" + self.hass = hass + self.config = config + self.entity = entity + self.entity_conf = config.entity_config.get(entity.entity_id, {}) + + @property + def entity_id(self): + """Return the Entity ID.""" + return self.entity.entity_id + + def friendly_name(self): + """Return the Alexa API friendly name.""" + return self.entity_conf.get(CONF_NAME, self.entity.name).translate( + TRANSLATION_TABLE + ) + + def description(self): + """Return the Alexa API description.""" + description = self.entity_conf.get(CONF_DESCRIPTION) or self.entity_id + return f"{description} via Home Assistant".translate(TRANSLATION_TABLE) + + def alexa_id(self): + """Return the Alexa API entity id.""" + return self.entity.entity_id.replace(".", "#").translate(TRANSLATION_TABLE) + + 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. + """ + + 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(): + if not interface.properties_proactively_reported(): + continue + + yield from interface.serialize_properties() + + def serialize_discovery(self): + """Serialize the entity for discovery.""" + result = { + "displayCategories": self.display_categories(), + "cookie": {}, + "endpointId": self.alexa_id(), + "friendlyName": self.friendly_name(), + "description": self.description(), + "manufacturerName": "Home Assistant", + } + + locale = self.config.locale + capabilities = [ + i.serialize_discovery() + for i in self.interfaces() + if locale in i.supported_locales + ] + + result["capabilities"] = capabilities + + return result + + +@callback +def async_get_entities(hass, config) -> List[AlexaEntity]: + """Return all entities that are supported by Alexa.""" + entities = [] + for state in hass.states.async_all(): + if state.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: + continue + + if state.domain not in ENTITY_ADAPTERS: + continue + + alexa_entity = ENTITY_ADAPTERS[state.domain](hass, config, state) + + if not list(alexa_entity.interfaces()): + continue + + entities.append(alexa_entity) + + return entities + + +@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 the display categories for this entity.""" + return [DisplayCategory.OTHER] + + def interfaces(self): + """Yield the supported interfaces.""" + return [ + AlexaPowerController(self.entity), + AlexaEndpointHealth(self.hass, self.entity), + Alexa(self.hass), + ] + + +@ENTITY_ADAPTERS.register(switch.DOMAIN) +class SwitchCapabilities(AlexaEntity): + """Class to represent Switch capabilities.""" + + def default_display_categories(self): + """Return the display categories for this entity.""" + device_class = self.entity.attributes.get(ATTR_DEVICE_CLASS) + if device_class == switch.DEVICE_CLASS_OUTLET: + return [DisplayCategory.SMARTPLUG] + + return [DisplayCategory.SWITCH] + + def interfaces(self): + """Yield the supported interfaces.""" + return [ + AlexaPowerController(self.entity), + AlexaEndpointHealth(self.hass, self.entity), + Alexa(self.hass), + ] + + +@ENTITY_ADAPTERS.register(climate.DOMAIN) +class ClimateCapabilities(AlexaEntity): + """Class to represent Climate capabilities.""" + + def default_display_categories(self): + """Return the display categories for this entity.""" + return [DisplayCategory.THERMOSTAT] + + def interfaces(self): + """Yield the supported interfaces.""" + # If we support two modes, one being off, we allow turning on too. + if climate.HVAC_MODE_OFF in self.entity.attributes.get( + climate.ATTR_HVAC_MODES, [] + ): + yield AlexaPowerController(self.entity) + + yield AlexaThermostatController(self.hass, self.entity) + yield AlexaTemperatureSensor(self.hass, self.entity) + yield AlexaEndpointHealth(self.hass, self.entity) + yield Alexa(self.hass) + + +@ENTITY_ADAPTERS.register(cover.DOMAIN) +class CoverCapabilities(AlexaEntity): + """Class to represent Cover capabilities.""" + + def default_display_categories(self): + """Return the display categories for this entity.""" + device_class = self.entity.attributes.get(ATTR_DEVICE_CLASS) + if device_class == cover.DEVICE_CLASS_GARAGE: + return [DisplayCategory.GARAGE_DOOR] + if device_class == cover.DEVICE_CLASS_DOOR: + return [DisplayCategory.DOOR] + if device_class in ( + cover.DEVICE_CLASS_BLIND, + cover.DEVICE_CLASS_SHADE, + cover.DEVICE_CLASS_CURTAIN, + ): + return [DisplayCategory.INTERIOR_BLIND] + if device_class in ( + cover.DEVICE_CLASS_WINDOW, + cover.DEVICE_CLASS_AWNING, + cover.DEVICE_CLASS_SHUTTER, + ): + return [DisplayCategory.EXTERIOR_BLIND] + + return [DisplayCategory.OTHER] + + def interfaces(self): + """Yield the supported interfaces.""" + device_class = self.entity.attributes.get(ATTR_DEVICE_CLASS) + if device_class != cover.DEVICE_CLASS_GARAGE: + yield AlexaPowerController(self.entity) + + supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if supported & cover.SUPPORT_SET_POSITION: + yield AlexaRangeController( + self.entity, instance=f"{cover.DOMAIN}.{cover.ATTR_POSITION}" + ) + elif supported & (cover.SUPPORT_CLOSE | cover.SUPPORT_OPEN): + yield AlexaModeController( + self.entity, instance=f"{cover.DOMAIN}.{cover.ATTR_POSITION}" + ) + if supported & cover.SUPPORT_SET_TILT_POSITION: + yield AlexaRangeController(self.entity, instance=f"{cover.DOMAIN}.tilt") + yield AlexaEndpointHealth(self.hass, self.entity) + yield Alexa(self.hass) + + +@ENTITY_ADAPTERS.register(light.DOMAIN) +class LightCapabilities(AlexaEntity): + """Class to represent Light capabilities.""" + + def default_display_categories(self): + """Return the display categories for this entity.""" + return [DisplayCategory.LIGHT] + + def interfaces(self): + """Yield the supported interfaces.""" + 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) + yield Alexa(self.hass) + + +@ENTITY_ADAPTERS.register(fan.DOMAIN) +class FanCapabilities(AlexaEntity): + """Class to represent Fan capabilities.""" + + def default_display_categories(self): + """Return the display categories for this entity.""" + return [DisplayCategory.FAN] + + def interfaces(self): + """Yield the supported interfaces.""" + yield AlexaPowerController(self.entity) + + supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if supported & fan.SUPPORT_SET_SPEED: + yield AlexaPercentageController(self.entity) + yield AlexaPowerLevelController(self.entity) + yield AlexaRangeController( + self.entity, instance=f"{fan.DOMAIN}.{fan.ATTR_SPEED}" + ) + if supported & fan.SUPPORT_OSCILLATE: + yield AlexaToggleController( + self.entity, instance=f"{fan.DOMAIN}.{fan.ATTR_OSCILLATING}" + ) + if supported & fan.SUPPORT_DIRECTION: + yield AlexaModeController( + self.entity, instance=f"{fan.DOMAIN}.{fan.ATTR_DIRECTION}" + ) + + yield AlexaEndpointHealth(self.hass, self.entity) + yield Alexa(self.hass) + + +@ENTITY_ADAPTERS.register(lock.DOMAIN) +class LockCapabilities(AlexaEntity): + """Class to represent Lock capabilities.""" + + def default_display_categories(self): + """Return the display categories for this entity.""" + return [DisplayCategory.SMARTLOCK] + + def interfaces(self): + """Yield the supported interfaces.""" + return [ + AlexaLockController(self.entity), + AlexaEndpointHealth(self.hass, self.entity), + Alexa(self.hass), + ] + + +@ENTITY_ADAPTERS.register(media_player.const.DOMAIN) +class MediaPlayerCapabilities(AlexaEntity): + """Class to represent MediaPlayer capabilities.""" + + def default_display_categories(self): + """Return the display categories for this entity.""" + device_class = self.entity.attributes.get(ATTR_DEVICE_CLASS) + if device_class == media_player.DEVICE_CLASS_SPEAKER: + return [DisplayCategory.SPEAKER] + + return [DisplayCategory.TV] + + def interfaces(self): + """Yield the supported interfaces.""" + yield AlexaPowerController(self.entity) + + supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if supported & media_player.const.SUPPORT_VOLUME_SET: + yield AlexaSpeaker(self.entity) + elif supported & media_player.const.SUPPORT_VOLUME_STEP: + 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) + yield AlexaPlaybackStateReporter(self.entity) + + if supported & media_player.const.SUPPORT_SEEK: + yield AlexaSeekController(self.entity) + + if supported & media_player.SUPPORT_SELECT_SOURCE: + inputs = AlexaInputController.get_valid_inputs( + self.entity.attributes.get( + media_player.const.ATTR_INPUT_SOURCE_LIST, [] + ) + ) + if len(inputs) > 0: + yield AlexaInputController(self.entity) + + if supported & media_player.const.SUPPORT_PLAY_MEDIA: + yield AlexaChannelController(self.entity) + + if supported & media_player.const.SUPPORT_SELECT_SOUND_MODE: + yield AlexaEqualizerController(self.entity) + + yield AlexaEndpointHealth(self.hass, self.entity) + yield Alexa(self.hass) + + +@ENTITY_ADAPTERS.register(scene.DOMAIN) +class SceneCapabilities(AlexaEntity): + """Class to represent Scene capabilities.""" + + def description(self): + """Return the Alexa API description.""" + description = AlexaEntity.description(self) + if "scene" not in description.casefold(): + return f"{description} (Scene)" + return description + + def default_display_categories(self): + """Return the display categories for this entity.""" + return [DisplayCategory.SCENE_TRIGGER] + + def interfaces(self): + """Yield the supported interfaces.""" + return [ + AlexaSceneController(self.entity, supports_deactivation=False), + Alexa(self.hass), + ] + + +@ENTITY_ADAPTERS.register(script.DOMAIN) +class ScriptCapabilities(AlexaEntity): + """Class to represent Script capabilities.""" + + def default_display_categories(self): + """Return the display categories for this entity.""" + return [DisplayCategory.ACTIVITY_TRIGGER] + + def interfaces(self): + """Yield the supported interfaces.""" + can_cancel = bool(self.entity.attributes.get("can_cancel")) + return [ + AlexaSceneController(self.entity, supports_deactivation=can_cancel), + Alexa(self.hass), + ] + + +@ENTITY_ADAPTERS.register(sensor.DOMAIN) +class SensorCapabilities(AlexaEntity): + """Class to represent Sensor capabilities.""" + + def default_display_categories(self): + """Return the display categories for this entity.""" + # although there are other kinds of sensors, all but temperature + # sensors are currently ignored. + return [DisplayCategory.TEMPERATURE_SENSOR] + + def interfaces(self): + """Yield the supported interfaces.""" + 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) + yield Alexa(self.hass) + + +@ENTITY_ADAPTERS.register(binary_sensor.DOMAIN) +class BinarySensorCapabilities(AlexaEntity): + """Class to represent BinarySensor capabilities.""" + + TYPE_CONTACT = "contact" + TYPE_MOTION = "motion" + TYPE_PRESENCE = "presence" + + def default_display_categories(self): + """Return the display categories for this entity.""" + 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] + if sensor_type is self.TYPE_PRESENCE: + return [DisplayCategory.CAMERA] + + def interfaces(self): + """Yield the supported interfaces.""" + 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) + elif sensor_type is self.TYPE_PRESENCE: + yield AlexaEventDetectionSensor(self.hass, self.entity) + + # yield additional interfaces based on specified display category in config. + entity_conf = self.config.entity_config.get(self.entity.entity_id, {}) + if CONF_DISPLAY_CATEGORIES in entity_conf: + if entity_conf[CONF_DISPLAY_CATEGORIES] == DisplayCategory.DOORBELL: + yield AlexaDoorbellEventSource(self.entity) + elif entity_conf[CONF_DISPLAY_CATEGORIES] == DisplayCategory.CONTACT_SENSOR: + yield AlexaContactSensor(self.hass, self.entity) + elif entity_conf[CONF_DISPLAY_CATEGORIES] == DisplayCategory.MOTION_SENSOR: + yield AlexaMotionSensor(self.hass, self.entity) + elif entity_conf[CONF_DISPLAY_CATEGORIES] == DisplayCategory.CAMERA: + yield AlexaEventDetectionSensor(self.hass, self.entity) + + yield AlexaEndpointHealth(self.hass, self.entity) + yield Alexa(self.hass) + + def get_type(self): + """Return the type of binary sensor.""" + attrs = self.entity.attributes + if attrs.get(ATTR_DEVICE_CLASS) in ( + binary_sensor.DEVICE_CLASS_DOOR, + binary_sensor.DEVICE_CLASS_GARAGE_DOOR, + binary_sensor.DEVICE_CLASS_OPENING, + binary_sensor.DEVICE_CLASS_WINDOW, + ): + return self.TYPE_CONTACT + + if attrs.get(ATTR_DEVICE_CLASS) == binary_sensor.DEVICE_CLASS_MOTION: + return self.TYPE_MOTION + + if attrs.get(ATTR_DEVICE_CLASS) == binary_sensor.DEVICE_CLASS_PRESENCE: + return self.TYPE_PRESENCE + + +@ENTITY_ADAPTERS.register(alarm_control_panel.DOMAIN) +class AlarmControlPanelCapabilities(AlexaEntity): + """Class to represent Alarm capabilities.""" + + def default_display_categories(self): + """Return the display categories for this entity.""" + return [DisplayCategory.SECURITY_PANEL] + + def interfaces(self): + """Yield the supported interfaces.""" + if not self.entity.attributes.get("code_arm_required"): + yield AlexaSecurityPanelController(self.hass, self.entity) + yield AlexaEndpointHealth(self.hass, self.entity) + yield Alexa(self.hass) + + +@ENTITY_ADAPTERS.register(image_processing.DOMAIN) +class ImageProcessingCapabilities(AlexaEntity): + """Class to represent image_processing capabilities.""" + + def default_display_categories(self): + """Return the display categories for this entity.""" + return [DisplayCategory.CAMERA] + + def interfaces(self): + """Yield the supported interfaces.""" + yield AlexaEventDetectionSensor(self.hass, self.entity) + yield AlexaEndpointHealth(self.hass, self.entity) + yield Alexa(self.hass) + + +@ENTITY_ADAPTERS.register(input_number.DOMAIN) +class InputNumberCapabilities(AlexaEntity): + """Class to represent input_number capabilities.""" + + def default_display_categories(self): + """Return the display categories for this entity.""" + return [DisplayCategory.OTHER] + + def interfaces(self): + """Yield the supported interfaces.""" + + yield AlexaRangeController( + self.entity, instance=f"{input_number.DOMAIN}.{input_number.ATTR_VALUE}" + ) + yield AlexaEndpointHealth(self.hass, self.entity) + yield Alexa(self.hass) + + +@ENTITY_ADAPTERS.register(timer.DOMAIN) +class TimerCapabilities(AlexaEntity): + """Class to represent Timer capabilities.""" + + def default_display_categories(self): + """Return the display categories for this entity.""" + return [DisplayCategory.OTHER] + + def interfaces(self): + """Yield the supported interfaces.""" + yield AlexaTimeHoldController(self.entity, allow_remote_resume=True) + yield AlexaPowerController(self.entity) + yield Alexa(self.entity) + + +@ENTITY_ADAPTERS.register(vacuum.DOMAIN) +class VacuumCapabilities(AlexaEntity): + """Class to represent vacuum capabilities.""" + + def default_display_categories(self): + """Return the display categories for this entity.""" + return [DisplayCategory.OTHER] + + def interfaces(self): + """Yield the supported interfaces.""" + supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if ( + (supported & vacuum.SUPPORT_TURN_ON) or (supported & vacuum.SUPPORT_START) + ) and ( + (supported & vacuum.SUPPORT_TURN_OFF) + or (supported & vacuum.SUPPORT_RETURN_HOME) + ): + yield AlexaPowerController(self.entity) + + if supported & vacuum.SUPPORT_FAN_SPEED: + yield AlexaRangeController( + self.entity, instance=f"{vacuum.DOMAIN}.{vacuum.ATTR_FAN_SPEED}" + ) + + if supported & vacuum.SUPPORT_PAUSE: + support_resume = bool(supported & vacuum.SUPPORT_START) + yield AlexaTimeHoldController( + self.entity, allow_remote_resume=support_resume + ) + + yield AlexaEndpointHealth(self.hass, self.entity) + yield Alexa(self.hass) + + +@ENTITY_ADAPTERS.register(camera.DOMAIN) +class CameraCapabilities(AlexaEntity): + """Class to represent Camera capabilities.""" + + def default_display_categories(self): + """Return the display categories for this entity.""" + return [DisplayCategory.CAMERA] + + def interfaces(self): + """Yield the supported interfaces.""" + if self._check_requirements(): + supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if supported & camera.SUPPORT_STREAM: + yield AlexaCameraStreamController(self.entity) + + yield AlexaEndpointHealth(self.hass, self.entity) + yield Alexa(self.hass) + + def _check_requirements(self): + """Check the hass URL for HTTPS scheme.""" + if "stream" not in self.hass.config.components: + _LOGGER.debug( + "%s requires stream component for AlexaCameraStreamController", + self.entity_id, + ) + return False + + url = urlparse(network.async_get_external_url(self.hass)) + if url.scheme != "https": + _LOGGER.debug( + "%s requires HTTPS for AlexaCameraStreamController", self.entity_id + ) + return False + + return True diff --git a/homeassistant/components/alexa/errors.py b/homeassistant/components/alexa/errors.py new file mode 100644 index 0000000000000..29643bacc53e5 --- /dev/null +++ b/homeassistant/components/alexa/errors.py @@ -0,0 +1,120 @@ +"""Alexa related errors.""" +from homeassistant.exceptions import HomeAssistantError + +from .const import API_TEMP_UNITS + + +class UnsupportedInterface(HomeAssistantError): + """This entity does not support the requested Smart Home API interface.""" + + +class UnsupportedProperty(HomeAssistantError): + """This entity does not support the requested Smart Home API property.""" + + +class NoTokenAvailable(HomeAssistantError): + """There is no access token available.""" + + +class AlexaError(Exception): + """Base class for errors that can be serialized for 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): + """Initialize an alexa error.""" + 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): + """Initialize invalid endpoint error.""" + msg = f"The endpoint {endpoint_id} does not exist" + AlexaError.__init__(self, msg) + self.endpoint_id = endpoint_id + + +class AlexaInvalidValueError(AlexaError): + """Class to represent InvalidValue errors.""" + + namespace = "Alexa" + error_type = "INVALID_VALUE" + + +class AlexaUnsupportedThermostatModeError(AlexaError): + """Class to represent UnsupportedThermostatMode errors.""" + + namespace = "Alexa.ThermostatController" + error_type = "UNSUPPORTED_THERMOSTAT_MODE" + + +class AlexaTempRangeError(AlexaError): + """Class to represent TempRange errors.""" + + namespace = "Alexa" + error_type = "TEMPERATURE_VALUE_OUT_OF_RANGE" + + def __init__(self, hass, temp, min_temp, max_temp): + """Initialize TempRange error.""" + 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 = f"The requested temperature {temp} is out of range" + + AlexaError.__init__(self, msg, payload) + + +class AlexaBridgeUnreachableError(AlexaError): + """Class to represent BridgeUnreachable errors.""" + + namespace = "Alexa" + error_type = "BRIDGE_UNREACHABLE" + + +class AlexaSecurityPanelUnauthorizedError(AlexaError): + """Class to represent SecurityPanelController Unauthorized errors.""" + + namespace = "Alexa.SecurityPanelController" + error_type = "UNAUTHORIZED" + + +class AlexaSecurityPanelAuthorizationRequired(AlexaError): + """Class to represent SecurityPanelController AuthorizationRequired errors.""" + + namespace = "Alexa.SecurityPanelController" + error_type = "AUTHORIZATION_REQUIRED" + + +class AlexaAlreadyInOperationError(AlexaError): + """Class to represent AlreadyInOperation errors.""" + + namespace = "Alexa" + error_type = "ALREADY_IN_OPERATION" + + +class AlexaInvalidDirectiveError(AlexaError): + """Class to represent InvalidDirective errors.""" + + namespace = "Alexa" + error_type = "INVALID_DIRECTIVE" + + +class AlexaVideoActionNotPermittedForContentError(AlexaError): + """Class to represent action not permitted for content errors.""" + + namespace = "Alexa.Video" + error_type = "ACTION_NOT_PERMITTED_FOR_CONTENT" diff --git a/homeassistant/components/alexa/flash_briefings.py b/homeassistant/components/alexa/flash_briefings.py index 537f04b20be4d..1205fd5809134 100644 --- a/homeassistant/components/alexa/flash_briefings.py +++ b/homeassistant/components/alexa/flash_briefings.py @@ -1,35 +1,45 @@ """Support for Alexa skill service end point.""" import copy -from datetime import datetime import logging import uuid from homeassistant.components import http +from homeassistant.const import HTTP_NOT_FOUND from homeassistant.core import callback from homeassistant.helpers import template +import homeassistant.util.dt as dt_util 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) + 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}' +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)) + 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' + name = "api:alexa:flash_briefings" def __init__(self, hass, flash_briefings): """Initialize Alexa view.""" @@ -40,13 +50,12 @@ def __init__(self, hass, 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) + _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 + return b"", HTTP_NOT_FOUND briefing = [] @@ -76,14 +85,12 @@ def get(self, request, briefing_id): 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() + 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) + output[ATTR_UPDATE_DATE] = dt_util.utcnow().strftime(DATE_FORMAT) briefing.append(output) diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py new file mode 100644 index 0000000000000..6b903665c174c --- /dev/null +++ b/homeassistant/components/alexa/handlers.py @@ -0,0 +1,1552 @@ +"""Alexa message handlers.""" +import logging +import math + +from homeassistant import core as ha +from homeassistant.components import ( + camera, + cover, + fan, + group, + input_number, + light, + media_player, + timer, + vacuum, +) +from homeassistant.components.climate import const as climate +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + ATTR_TEMPERATURE, + SERVICE_ALARM_ARM_AWAY, + SERVICE_ALARM_ARM_HOME, + SERVICE_ALARM_ARM_NIGHT, + SERVICE_ALARM_DISARM, + SERVICE_LOCK, + SERVICE_MEDIA_NEXT_TRACK, + SERVICE_MEDIA_PAUSE, + SERVICE_MEDIA_PLAY, + SERVICE_MEDIA_PREVIOUS_TRACK, + SERVICE_MEDIA_STOP, + SERVICE_SET_COVER_POSITION, + SERVICE_SET_COVER_TILT_POSITION, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + SERVICE_UNLOCK, + SERVICE_VOLUME_DOWN, + SERVICE_VOLUME_MUTE, + SERVICE_VOLUME_SET, + SERVICE_VOLUME_UP, + STATE_ALARM_DISARMED, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, +) +from homeassistant.helpers import network +import homeassistant.util.color as color_util +from homeassistant.util.decorator import Registry +import homeassistant.util.dt as dt_util +from homeassistant.util.temperature import convert as convert_temperature + +from .const import ( + API_TEMP_UNITS, + API_THERMOSTAT_MODES, + API_THERMOSTAT_MODES_CUSTOM, + API_THERMOSTAT_PRESETS, + PERCENTAGE_FAN_MAP, + Cause, + Inputs, +) +from .entities import async_get_entities +from .errors import ( + AlexaInvalidDirectiveError, + AlexaInvalidValueError, + AlexaSecurityPanelAuthorizationRequired, + AlexaSecurityPanelUnauthorizedError, + AlexaTempRangeError, + AlexaUnsupportedThermostatModeError, + AlexaVideoActionNotPermittedForContentError, +) +from .state_report import async_enable_proactive_mode + +_LOGGER = logging.getLogger(__name__) +HANDLERS = Registry() + + +@HANDLERS.register(("Alexa.Discovery", "Discover")) +async def async_api_discovery(hass, config, directive, context): + """Create a API formatted discovery response. + + Async friendly. + """ + discovery_endpoints = [ + alexa_entity.serialize_discovery() + for alexa_entity in async_get_entities(hass, config) + if config.should_expose(alexa_entity.entity_id) + ] + + 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 config.supports_auth: + await config.async_accept_grant(auth_code) + + if config.should_report_state: + 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 + elif domain == vacuum.DOMAIN: + supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if not supported & vacuum.SUPPORT_TURN_ON and supported & vacuum.SUPPORT_START: + service = vacuum.SERVICE_START + elif domain == timer.DOMAIN: + service = timer.SERVICE_START + elif domain == media_player.DOMAIN: + supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + power_features = media_player.SUPPORT_TURN_ON | media_player.SUPPORT_TURN_OFF + if not supported & power_features: + service = media_player.SERVICE_MEDIA_PLAY + + 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 + elif domain == vacuum.DOMAIN: + supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if ( + not supported & vacuum.SUPPORT_TURN_OFF + and supported & vacuum.SUPPORT_RETURN_HOME + ): + service = vacuum.SERVICE_RETURN_TO_BASE + elif domain == timer.DOMAIN: + service = timer.SERVICE_CANCEL + elif domain == media_player.DOMAIN: + supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + power_features = media_player.SUPPORT_TURN_ON | media_player.SUPPORT_TURN_OFF + if not supported & power_features: + service = media_player.SERVICE_MEDIA_STOP + + 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": f"{dt_util.utcnow().replace(tzinfo=None).isoformat()}Z", + } + + 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": f"{dt_util.utcnow().replace(tzinfo=None).isoformat()}Z", + } + + 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 + service = None + data = {ATTR_ENTITY_ID: entity.entity_id} + + if entity.domain == fan.DOMAIN: + service = fan.SERVICE_SET_SPEED + speed = "off" + + percentage = int(directive.payload["percentage"]) + if percentage <= 33: + speed = "low" + elif percentage <= 66: + speed = "medium" + elif percentage <= 100: + speed = "high" + data[fan.ATTR_SPEED] = speed + + 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) + current = PERCENTAGE_FAN_MAP.get(speed, 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 + + 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 + + +@HANDLERS.register(("Alexa.LockController", "Unlock")) +async def async_api_unlock(hass, config, directive, context): + """Process an unlock request.""" + if config.locale not in {"de-DE", "en-US", "ja-JP"}: + msg = f"The unlock directive is not supported for the following locales: {config.locale}" + raise AlexaInvalidDirectiveError(msg) + + entity = directive.entity + await hass.services.async_call( + entity.domain, + SERVICE_UNLOCK, + {ATTR_ENTITY_ID: entity.entity_id}, + blocking=False, + context=context, + ) + + response = directive.response() + response.add_context_property( + {"namespace": "Alexa.LockController", "name": "lockState", "value": "UNLOCKED"} + ) + + return 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. + # Strips trailing 1 to match single input devices. + source_list = entity.attributes.get(media_player.const.ATTR_INPUT_SOURCE_LIST, []) + for source in source_list: + formatted_source = ( + source.lower().replace("-", "").replace("_", "").replace(" ", "") + ) + media_input = media_input.lower().replace(" ", "") + if ( + formatted_source in Inputs.VALID_SOURCE_NAME_MAP.keys() + and formatted_source == media_input + ) or ( + media_input.endswith("1") and formatted_source == media_input.rstrip("1") + ): + media_input = source + break + else: + msg = ( + f"failed to map input {media_input} to a media source on {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. + # This workaround will simply call the volume up/Volume down the amount of steps asked for + # When no steps are called in the request, Alexa sends a default of 10 steps which for most + # purposes is too high. The default is set 1 in this case. + entity = directive.entity + volume_int = int(directive.payload["volumeSteps"]) + is_default = bool(directive.payload["volumeStepsDefault"]) + default_steps = 1 + + if volume_int < 0: + service_volume = SERVICE_VOLUME_DOWN + if is_default: + volume_int = -default_steps + else: + service_volume = SERVICE_VOLUME_UP + if is_default: + volume_int = default_steps + + data = {ATTR_ENTITY_ID: entity.entity_id} + + for _ in range(abs(volume_int)): + await hass.services.async_call( + entity.domain, service_volume, 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"] + + data = {ATTR_ENTITY_ID: entity.entity_id} + + ha_preset = next((k for k, v in API_THERMOSTAT_PRESETS.items() if v == mode), None) + + if ha_preset: + presets = entity.attributes.get(climate.ATTR_PRESET_MODES, []) + + if ha_preset not in presets: + msg = f"The requested thermostat mode {ha_preset} is not supported" + raise AlexaUnsupportedThermostatModeError(msg) + + service = climate.SERVICE_SET_PRESET_MODE + data[climate.ATTR_PRESET_MODE] = ha_preset + + elif mode == "CUSTOM": + operation_list = entity.attributes.get(climate.ATTR_HVAC_MODES) + custom_mode = directive.payload["thermostatMode"]["customName"] + custom_mode = next( + (k for k, v in API_THERMOSTAT_MODES_CUSTOM.items() if v == custom_mode), + None, + ) + if custom_mode not in operation_list: + msg = ( + f"The requested thermostat mode {mode}: {custom_mode} is not supported" + ) + raise AlexaUnsupportedThermostatModeError(msg) + + service = climate.SERVICE_SET_HVAC_MODE + data[climate.ATTR_HVAC_MODE] = custom_mode + + else: + operation_list = entity.attributes.get(climate.ATTR_HVAC_MODES) + ha_modes = {k: v for k, v in API_THERMOSTAT_MODES.items() if v == mode} + ha_mode = next(iter(set(ha_modes).intersection(operation_list)), None) + if ha_mode not in operation_list: + msg = f"The requested thermostat mode {mode} is not supported" + raise AlexaUnsupportedThermostatModeError(msg) + + service = climate.SERVICE_SET_HVAC_MODE + data[climate.ATTR_HVAC_MODE] = ha_mode + + response = directive.response() + await hass.services.async_call( + climate.DOMAIN, service, 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") + + +@HANDLERS.register(("Alexa.PowerLevelController", "SetPowerLevel")) +async def async_api_set_power_level(hass, config, directive, context): + """Process a SetPowerLevel request.""" + entity = directive.entity + service = None + data = {ATTR_ENTITY_ID: entity.entity_id} + + if entity.domain == fan.DOMAIN: + service = fan.SERVICE_SET_SPEED + speed = "off" + + percentage = int(directive.payload["powerLevel"]) + if percentage <= 33: + speed = "low" + elif percentage <= 66: + speed = "medium" + else: + speed = "high" + + data[fan.ATTR_SPEED] = speed + + await hass.services.async_call( + entity.domain, service, data, blocking=False, context=context + ) + + return directive.response() + + +@HANDLERS.register(("Alexa.PowerLevelController", "AdjustPowerLevel")) +async def async_api_adjust_power_level(hass, config, directive, context): + """Process an AdjustPowerLevel request.""" + entity = directive.entity + percentage_delta = int(directive.payload["powerLevelDelta"]) + 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) + current = PERCENTAGE_FAN_MAP.get(speed, 100) + + # set percentage + percentage = max(0, percentage_delta + current) + speed = "off" + + if percentage <= 33: + speed = "low" + elif percentage <= 66: + speed = "medium" + else: + speed = "high" + + data[fan.ATTR_SPEED] = speed + + await hass.services.async_call( + entity.domain, service, data, blocking=False, context=context + ) + + return directive.response() + + +@HANDLERS.register(("Alexa.SecurityPanelController", "Arm")) +async def async_api_arm(hass, config, directive, context): + """Process a Security Panel Arm request.""" + entity = directive.entity + service = None + arm_state = directive.payload["armState"] + data = {ATTR_ENTITY_ID: entity.entity_id} + + if entity.state != STATE_ALARM_DISARMED: + msg = "You must disarm the system before you can set the requested arm state." + raise AlexaSecurityPanelAuthorizationRequired(msg) + + if arm_state == "ARMED_AWAY": + service = SERVICE_ALARM_ARM_AWAY + elif arm_state == "ARMED_NIGHT": + service = SERVICE_ALARM_ARM_NIGHT + elif arm_state == "ARMED_STAY": + service = SERVICE_ALARM_ARM_HOME + + await hass.services.async_call( + entity.domain, service, data, blocking=False, context=context + ) + + # return 0 until alarm integration supports an exit delay + payload = {"exitDelayInSeconds": 0} + + response = directive.response( + name="Arm.Response", namespace="Alexa.SecurityPanelController", payload=payload + ) + + response.add_context_property( + { + "name": "armState", + "namespace": "Alexa.SecurityPanelController", + "value": arm_state, + } + ) + + return response + + +@HANDLERS.register(("Alexa.SecurityPanelController", "Disarm")) +async def async_api_disarm(hass, config, directive, context): + """Process a Security Panel Disarm request.""" + entity = directive.entity + data = {ATTR_ENTITY_ID: entity.entity_id} + response = directive.response() + + # Per Alexa Documentation: If you receive a Disarm directive, and the system is already disarmed, + # respond with a success response, not an error response. + if entity.state == STATE_ALARM_DISARMED: + return response + + payload = directive.payload + if "authorization" in payload: + value = payload["authorization"]["value"] + if payload["authorization"]["type"] == "FOUR_DIGIT_PIN": + data["code"] = value + + if not await hass.services.async_call( + entity.domain, SERVICE_ALARM_DISARM, data, blocking=True, context=context + ): + msg = "Invalid Code" + raise AlexaSecurityPanelUnauthorizedError(msg) + + response.add_context_property( + { + "name": "armState", + "namespace": "Alexa.SecurityPanelController", + "value": "DISARMED", + } + ) + + return response + + +@HANDLERS.register(("Alexa.ModeController", "SetMode")) +async def async_api_set_mode(hass, config, directive, context): + """Process a SetMode directive.""" + entity = directive.entity + instance = directive.instance + domain = entity.domain + service = None + data = {ATTR_ENTITY_ID: entity.entity_id} + mode = directive.payload["mode"] + + # Fan Direction + if instance == f"{fan.DOMAIN}.{fan.ATTR_DIRECTION}": + _, direction = mode.split(".") + if direction in (fan.DIRECTION_REVERSE, fan.DIRECTION_FORWARD): + service = fan.SERVICE_SET_DIRECTION + data[fan.ATTR_DIRECTION] = direction + + # Cover Position + elif instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": + _, position = mode.split(".") + + if position == cover.STATE_CLOSED: + service = cover.SERVICE_CLOSE_COVER + elif position == cover.STATE_OPEN: + service = cover.SERVICE_OPEN_COVER + elif position == "custom": + service = cover.SERVICE_STOP_COVER + + else: + msg = "Entity does not support directive" + raise AlexaInvalidDirectiveError(msg) + + await hass.services.async_call( + domain, service, data, blocking=False, context=context + ) + + response = directive.response() + response.add_context_property( + { + "namespace": "Alexa.ModeController", + "instance": instance, + "name": "mode", + "value": mode, + } + ) + + return response + + +@HANDLERS.register(("Alexa.ModeController", "AdjustMode")) +async def async_api_adjust_mode(hass, config, directive, context): + """Process a AdjustMode request. + + Requires capabilityResources supportedModes to be ordered. + Only supportedModes with ordered=True support the adjustMode directive. + """ + + # Currently no supportedModes are configured with ordered=True to support this request. + msg = "Entity does not support directive" + raise AlexaInvalidDirectiveError(msg) + + +@HANDLERS.register(("Alexa.ToggleController", "TurnOn")) +async def async_api_toggle_on(hass, config, directive, context): + """Process a toggle on request.""" + entity = directive.entity + instance = directive.instance + domain = entity.domain + service = None + data = {ATTR_ENTITY_ID: entity.entity_id} + + # Fan Oscillating + if instance == f"{fan.DOMAIN}.{fan.ATTR_OSCILLATING}": + service = fan.SERVICE_OSCILLATE + data[fan.ATTR_OSCILLATING] = True + else: + msg = "Entity does not support directive" + raise AlexaInvalidDirectiveError(msg) + + await hass.services.async_call( + domain, service, data, blocking=False, context=context + ) + + response = directive.response() + response.add_context_property( + { + "namespace": "Alexa.ToggleController", + "instance": instance, + "name": "toggleState", + "value": "ON", + } + ) + + return response + + +@HANDLERS.register(("Alexa.ToggleController", "TurnOff")) +async def async_api_toggle_off(hass, config, directive, context): + """Process a toggle off request.""" + entity = directive.entity + instance = directive.instance + domain = entity.domain + service = None + data = {ATTR_ENTITY_ID: entity.entity_id} + + # Fan Oscillating + if instance == f"{fan.DOMAIN}.{fan.ATTR_OSCILLATING}": + service = fan.SERVICE_OSCILLATE + data[fan.ATTR_OSCILLATING] = False + else: + msg = "Entity does not support directive" + raise AlexaInvalidDirectiveError(msg) + + await hass.services.async_call( + domain, service, data, blocking=False, context=context + ) + + response = directive.response() + response.add_context_property( + { + "namespace": "Alexa.ToggleController", + "instance": instance, + "name": "toggleState", + "value": "OFF", + } + ) + + return response + + +@HANDLERS.register(("Alexa.RangeController", "SetRangeValue")) +async def async_api_set_range(hass, config, directive, context): + """Process a next request.""" + entity = directive.entity + instance = directive.instance + domain = entity.domain + service = None + data = {ATTR_ENTITY_ID: entity.entity_id} + range_value = directive.payload["rangeValue"] + + # Fan Speed + if instance == f"{fan.DOMAIN}.{fan.ATTR_SPEED}": + range_value = int(range_value) + service = fan.SERVICE_SET_SPEED + speed_list = entity.attributes[fan.ATTR_SPEED_LIST] + speed = next((v for i, v in enumerate(speed_list) if i == range_value), None) + + if not speed: + msg = "Entity does not support value" + raise AlexaInvalidValueError(msg) + + if speed == fan.SPEED_OFF: + service = fan.SERVICE_TURN_OFF + + data[fan.ATTR_SPEED] = speed + + # Cover Position + elif instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": + range_value = int(range_value) + if range_value == 0: + service = cover.SERVICE_CLOSE_COVER + elif range_value == 100: + service = cover.SERVICE_OPEN_COVER + else: + service = cover.SERVICE_SET_COVER_POSITION + data[cover.ATTR_POSITION] = range_value + + # Cover Tilt + elif instance == f"{cover.DOMAIN}.tilt": + range_value = int(range_value) + if range_value == 0: + service = cover.SERVICE_CLOSE_COVER_TILT + elif range_value == 100: + service = cover.SERVICE_OPEN_COVER_TILT + else: + service = cover.SERVICE_SET_COVER_TILT_POSITION + data[cover.ATTR_TILT_POSITION] = range_value + + # Input Number Value + elif instance == f"{input_number.DOMAIN}.{input_number.ATTR_VALUE}": + range_value = float(range_value) + service = input_number.SERVICE_SET_VALUE + min_value = float(entity.attributes[input_number.ATTR_MIN]) + max_value = float(entity.attributes[input_number.ATTR_MAX]) + data[input_number.ATTR_VALUE] = min(max_value, max(min_value, range_value)) + + # Vacuum Fan Speed + elif instance == f"{vacuum.DOMAIN}.{vacuum.ATTR_FAN_SPEED}": + service = vacuum.SERVICE_SET_FAN_SPEED + speed_list = entity.attributes[vacuum.ATTR_FAN_SPEED_LIST] + speed = next( + (v for i, v in enumerate(speed_list) if i == int(range_value)), None + ) + + if not speed: + msg = "Entity does not support value" + raise AlexaInvalidValueError(msg) + + data[vacuum.ATTR_FAN_SPEED] = speed + + else: + msg = "Entity does not support directive" + raise AlexaInvalidDirectiveError(msg) + + await hass.services.async_call( + domain, service, data, blocking=False, context=context + ) + + response = directive.response() + response.add_context_property( + { + "namespace": "Alexa.RangeController", + "instance": instance, + "name": "rangeValue", + "value": range_value, + } + ) + + return response + + +@HANDLERS.register(("Alexa.RangeController", "AdjustRangeValue")) +async def async_api_adjust_range(hass, config, directive, context): + """Process a next request.""" + entity = directive.entity + instance = directive.instance + domain = entity.domain + service = None + data = {ATTR_ENTITY_ID: entity.entity_id} + range_delta = directive.payload["rangeValueDelta"] + range_delta_default = bool(directive.payload["rangeValueDeltaDefault"]) + response_value = 0 + + # Fan Speed + if instance == f"{fan.DOMAIN}.{fan.ATTR_SPEED}": + range_delta = int(range_delta) + service = fan.SERVICE_SET_SPEED + speed_list = entity.attributes[fan.ATTR_SPEED_LIST] + current_speed = entity.attributes[fan.ATTR_SPEED] + current_speed_index = next( + (i for i, v in enumerate(speed_list) if v == current_speed), 0 + ) + new_speed_index = min( + len(speed_list) - 1, max(0, current_speed_index + range_delta) + ) + speed = next( + (v for i, v in enumerate(speed_list) if i == new_speed_index), None + ) + + if speed == fan.SPEED_OFF: + service = fan.SERVICE_TURN_OFF + + data[fan.ATTR_SPEED] = response_value = speed + + # Cover Position + elif instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": + range_delta = int(range_delta * 20) if range_delta_default else int(range_delta) + service = SERVICE_SET_COVER_POSITION + current = entity.attributes.get(cover.ATTR_POSITION) + if not current: + msg = f"Unable to determine {entity.entity_id} current position" + raise AlexaInvalidValueError(msg) + position = response_value = min(100, max(0, range_delta + current)) + if position == 100: + service = cover.SERVICE_OPEN_COVER + elif position == 0: + service = cover.SERVICE_CLOSE_COVER + else: + data[cover.ATTR_POSITION] = position + + # Cover Tilt + elif instance == f"{cover.DOMAIN}.tilt": + range_delta = int(range_delta * 20) if range_delta_default else int(range_delta) + service = SERVICE_SET_COVER_TILT_POSITION + current = entity.attributes.get(cover.ATTR_TILT_POSITION) + if not current: + msg = f"Unable to determine {entity.entity_id} current tilt position" + raise AlexaInvalidValueError(msg) + tilt_position = response_value = min(100, max(0, range_delta + current)) + if tilt_position == 100: + service = cover.SERVICE_OPEN_COVER_TILT + elif tilt_position == 0: + service = cover.SERVICE_CLOSE_COVER_TILT + else: + data[cover.ATTR_TILT_POSITION] = tilt_position + + # Input Number Value + elif instance == f"{input_number.DOMAIN}.{input_number.ATTR_VALUE}": + range_delta = float(range_delta) + service = input_number.SERVICE_SET_VALUE + min_value = float(entity.attributes[input_number.ATTR_MIN]) + max_value = float(entity.attributes[input_number.ATTR_MAX]) + current = float(entity.state) + data[input_number.ATTR_VALUE] = response_value = min( + max_value, max(min_value, range_delta + current) + ) + + # Vacuum Fan Speed + elif instance == f"{vacuum.DOMAIN}.{vacuum.ATTR_FAN_SPEED}": + range_delta = int(range_delta) + service = vacuum.SERVICE_SET_FAN_SPEED + speed_list = entity.attributes[vacuum.ATTR_FAN_SPEED_LIST] + current_speed = entity.attributes[vacuum.ATTR_FAN_SPEED] + current_speed_index = next( + (i for i, v in enumerate(speed_list) if v == current_speed), 0 + ) + new_speed_index = min( + len(speed_list) - 1, max(0, current_speed_index + range_delta) + ) + speed = next( + (v for i, v in enumerate(speed_list) if i == new_speed_index), None + ) + + data[vacuum.ATTR_FAN_SPEED] = response_value = speed + + else: + msg = "Entity does not support directive" + raise AlexaInvalidDirectiveError(msg) + + await hass.services.async_call( + domain, service, data, blocking=False, context=context + ) + + response = directive.response() + response.add_context_property( + { + "namespace": "Alexa.RangeController", + "instance": instance, + "name": "rangeValue", + "value": response_value, + } + ) + + return response + + +@HANDLERS.register(("Alexa.ChannelController", "ChangeChannel")) +async def async_api_changechannel(hass, config, directive, context): + """Process a change channel request.""" + channel = "0" + entity = directive.entity + channel_payload = directive.payload["channel"] + metadata_payload = directive.payload["channelMetadata"] + payload_name = "number" + + if "number" in channel_payload: + channel = channel_payload["number"] + payload_name = "number" + elif "callSign" in channel_payload: + channel = channel_payload["callSign"] + payload_name = "callSign" + elif "affiliateCallSign" in channel_payload: + channel = channel_payload["affiliateCallSign"] + payload_name = "affiliateCallSign" + elif "uri" in channel_payload: + channel = channel_payload["uri"] + payload_name = "uri" + elif "name" in metadata_payload: + channel = metadata_payload["name"] + payload_name = "callSign" + + data = { + ATTR_ENTITY_ID: entity.entity_id, + media_player.const.ATTR_MEDIA_CONTENT_ID: channel, + media_player.const.ATTR_MEDIA_CONTENT_TYPE: media_player.const.MEDIA_TYPE_CHANNEL, + } + + await hass.services.async_call( + entity.domain, + media_player.const.SERVICE_PLAY_MEDIA, + data, + blocking=False, + context=context, + ) + + response = directive.response() + + response.add_context_property( + { + "namespace": "Alexa.ChannelController", + "name": "channel", + "value": {payload_name: channel}, + } + ) + + return response + + +@HANDLERS.register(("Alexa.ChannelController", "SkipChannels")) +async def async_api_skipchannel(hass, config, directive, context): + """Process a skipchannel request.""" + channel = int(directive.payload["channelCount"]) + entity = directive.entity + + data = {ATTR_ENTITY_ID: entity.entity_id} + + if channel < 0: + service_media = SERVICE_MEDIA_PREVIOUS_TRACK + else: + service_media = SERVICE_MEDIA_NEXT_TRACK + + for _ in range(abs(channel)): + await hass.services.async_call( + entity.domain, service_media, data, blocking=False, context=context + ) + + response = directive.response() + + response.add_context_property( + { + "namespace": "Alexa.ChannelController", + "name": "channel", + "value": {"number": ""}, + } + ) + + return response + + +@HANDLERS.register(("Alexa.SeekController", "AdjustSeekPosition")) +async def async_api_seek(hass, config, directive, context): + """Process a seek request.""" + entity = directive.entity + position_delta = int(directive.payload["deltaPositionMilliseconds"]) + + current_position = entity.attributes.get(media_player.ATTR_MEDIA_POSITION) + if not current_position: + msg = f"{entity} did not return the current media position." + raise AlexaVideoActionNotPermittedForContentError(msg) + + seek_position = int(current_position) + int(position_delta / 1000) + + if seek_position < 0: + seek_position = 0 + + media_duration = entity.attributes.get(media_player.ATTR_MEDIA_DURATION) + if media_duration and 0 < int(media_duration) < seek_position: + seek_position = media_duration + + data = { + ATTR_ENTITY_ID: entity.entity_id, + media_player.ATTR_MEDIA_SEEK_POSITION: seek_position, + } + + await hass.services.async_call( + media_player.DOMAIN, + media_player.SERVICE_MEDIA_SEEK, + data, + blocking=False, + context=context, + ) + + # convert seconds to milliseconds for StateReport. + seek_position = int(seek_position * 1000) + + payload = {"properties": [{"name": "positionMilliseconds", "value": seek_position}]} + return directive.response( + name="StateReport", namespace="Alexa.SeekController", payload=payload + ) + + +@HANDLERS.register(("Alexa.EqualizerController", "SetMode")) +async def async_api_set_eq_mode(hass, config, directive, context): + """Process a SetMode request for EqualizerController.""" + mode = directive.payload["mode"] + entity = directive.entity + data = {ATTR_ENTITY_ID: entity.entity_id} + + sound_mode_list = entity.attributes.get(media_player.const.ATTR_SOUND_MODE_LIST) + if sound_mode_list and mode.lower() in sound_mode_list: + data[media_player.const.ATTR_SOUND_MODE] = mode.lower() + else: + msg = f"failed to map sound mode {mode} to a mode on {entity.entity_id}" + raise AlexaInvalidValueError(msg) + + await hass.services.async_call( + entity.domain, + media_player.SERVICE_SELECT_SOUND_MODE, + data, + blocking=False, + context=context, + ) + + return directive.response() + + +@HANDLERS.register(("Alexa.EqualizerController", "AdjustBands")) +@HANDLERS.register(("Alexa.EqualizerController", "ResetBands")) +@HANDLERS.register(("Alexa.EqualizerController", "SetBands")) +async def async_api_bands_directive(hass, config, directive, context): + """Handle an AdjustBands, ResetBands, SetBands request. + + Only mode directives are currently supported for the EqualizerController. + """ + # Currently bands directives are not supported. + msg = "Entity does not support directive" + raise AlexaInvalidDirectiveError(msg) + + +@HANDLERS.register(("Alexa.TimeHoldController", "Hold")) +async def async_api_hold(hass, config, directive, context): + """Process a TimeHoldController Hold request.""" + entity = directive.entity + data = {ATTR_ENTITY_ID: entity.entity_id} + + if entity.domain == timer.DOMAIN: + service = timer.SERVICE_PAUSE + + elif entity.domain == vacuum.DOMAIN: + service = vacuum.SERVICE_START_PAUSE + + else: + msg = "Entity does not support directive" + raise AlexaInvalidDirectiveError(msg) + + await hass.services.async_call( + entity.domain, service, data, blocking=False, context=context + ) + + return directive.response() + + +@HANDLERS.register(("Alexa.TimeHoldController", "Resume")) +async def async_api_resume(hass, config, directive, context): + """Process a TimeHoldController Resume request.""" + entity = directive.entity + data = {ATTR_ENTITY_ID: entity.entity_id} + + if entity.domain == timer.DOMAIN: + service = timer.SERVICE_START + + elif entity.domain == vacuum.DOMAIN: + service = vacuum.SERVICE_START_PAUSE + + else: + msg = "Entity does not support directive" + raise AlexaInvalidDirectiveError(msg) + + await hass.services.async_call( + entity.domain, service, data, blocking=False, context=context + ) + + return directive.response() + + +@HANDLERS.register(("Alexa.CameraStreamController", "InitializeCameraStreams")) +async def async_api_initialize_camera_stream(hass, config, directive, context): + """Process a InitializeCameraStreams request.""" + entity = directive.entity + stream_source = await camera.async_request_stream(hass, entity.entity_id, fmt="hls") + camera_image = hass.states.get(entity.entity_id).attributes["entity_picture"] + external_url = network.async_get_external_url(hass) + payload = { + "cameraStreams": [ + { + "uri": f"{external_url}{stream_source}", + "protocol": "HLS", + "resolution": {"width": 1280, "height": 720}, + "authorizationType": "NONE", + "videoCodec": "H264", + "audioCodec": "AAC", + } + ], + "imageUri": f"{external_url}{camera_image}", + } + return directive.response( + name="Response", namespace="Alexa.CameraStreamController", payload=payload + ) diff --git a/homeassistant/components/alexa/intent.py b/homeassistant/components/alexa/intent.py index b30a7238b3e04..f879b66268b95 100644 --- a/homeassistant/components/alexa/intent.py +++ b/homeassistant/components/alexa/intent.py @@ -14,27 +14,24 @@ HANDLERS = Registry() -INTENTS_API_ENDPOINT = '/api/alexa' +INTENTS_API_ENDPOINT = "/api/alexa" class SpeechType(enum.Enum): """The Alexa speech types.""" - plaintext = 'PlainText' - ssml = 'SSML' + plaintext = "PlainText" + ssml = "SSML" -SPEECH_MAPPINGS = { - 'plain': SpeechType.plaintext, - 'ssml': SpeechType.ssml, -} +SPEECH_MAPPINGS = {"plain": SpeechType.plaintext, "ssml": SpeechType.ssml} class CardType(enum.Enum): """The Alexa card types.""" - simple = 'Simple' - link_account = 'LinkAccount' + simple = "Simple" + link_account = "LinkAccount" @callback @@ -51,44 +48,50 @@ class AlexaIntentsView(http.HomeAssistantView): """Handle Alexa requests.""" url = INTENTS_API_ENDPOINT - name = 'api:alexa' + name = "api:alexa" async def post(self, request): """Handle Alexa.""" - hass = request.app['hass'] + 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) + 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))) + 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.")) + 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.")) + 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.")) + 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_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() @@ -104,25 +107,25 @@ async def async_handle_message(hass, message): - intent.IntentError """ - req = message.get('request') - req_type = req['type'] + req = message.get("request") + req_type = req["type"] handler = HANDLERS.get(req_type) if not handler: - raise UnknownRequest('Received unknown request {}'.format(req_type)) + raise UnknownRequest(f"Received unknown request {req_type}") return await handler(hass, message) -@HANDLERS.register('SessionEndedRequest') +@HANDLERS.register("SessionEndedRequest") async def async_handle_session_end(hass, message): """Handle a session end request.""" return None -@HANDLERS.register('IntentRequest') -@HANDLERS.register('LaunchRequest') +@HANDLERS.register("IntentRequest") +@HANDLERS.register("LaunchRequest") async def async_handle_intent(hass, message): """Handle an intent request. @@ -132,33 +135,37 @@ async def async_handle_intent(hass, message): - intent.IntentError """ - req = message.get('request') - alexa_intent_info = req.get('intent') + 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') + if req["type"] == "LaunchRequest": + intent_name = ( + message.get("session", {}).get("application", {}).get("applicationId") + ) else: - intent_name = alexa_intent_info['name'] + 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()}) + 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']) + alexa_speech, intent_response.speech[intent_speech]["speech"] + ) break - if 'simple' in intent_response.card: + if "simple" in intent_response.card: alexa_response.add_card( - CardType.simple, intent_response.card['simple']['title'], - intent_response.card['simple']['content']) + CardType.simple, + intent_response.card["simple"]["title"], + intent_response.card["simple"]["content"], + ) return alexa_response.as_dict() @@ -168,23 +175,23 @@ def resolve_slot_synonyms(key, request): # 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'] + resolved_value = request["value"] - if ('resolutions' in request and - 'resolutionsPerAuthority' in request['resolutions'] and - len(request['resolutions']['resolutionsPerAuthority']) >= 1): + 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: + 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']]) + 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 @@ -192,9 +199,9 @@ def resolve_slot_synonyms(key, request): resolved_value = possible_values[0] else: _LOGGER.debug( - 'Found multiple synonym resolutions for slot value: {%s: %s}', + "Found multiple synonym resolutions for slot value: {%s: %s}", key, - request['value'] + resolved_value, ) return resolved_value @@ -215,12 +222,12 @@ def __init__(self, hass, intent_info): # 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(): + for key, value in intent_info.get("slots", {}).items(): # Only include slots with values - if 'value' not in value: + if "value" not in value: continue - _key = key.replace('.', '_') + _key = key.replace(".", "_") self.variables[_key] = resolve_slot_synonyms(key, value) @@ -228,9 +235,7 @@ def add_card(self, card_type, title, content): """Add a card to the response.""" assert self.card is None - card = { - "type": card_type.value - } + card = {"type": card_type.value} if card_type == CardType.link_account: self.card = card @@ -244,43 +249,36 @@ 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' + key = "ssml" if speech_type == SpeechType.ssml else "text" - self.speech = { - 'type': speech_type.value, - key: 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' + key = "ssml" if speech_type == SpeechType.ssml else "text" self.reprompt = { - 'type': speech_type.value, - key: text.async_render(self.variables) + "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 - } + response = {"shouldEndSession": self.should_end_session} if self.card is not None: - response['card'] = self.card + response["card"] = self.card if self.speech is not None: - response['outputSpeech'] = self.speech + response["outputSpeech"] = self.speech if self.reprompt is not None: - response['reprompt'] = { - 'outputSpeech': self.reprompt - } + response["reprompt"] = {"outputSpeech": self.reprompt} return { - 'version': '1.0', - 'sessionAttributes': self.session_attributes, - 'response': response, + "version": "1.0", + "sessionAttributes": self.session_attributes, + "response": response, } diff --git a/homeassistant/components/alexa/manifest.json b/homeassistant/components/alexa/manifest.json index e4fc9eb86805a..6144ccc687037 100644 --- a/homeassistant/components/alexa/manifest.json +++ b/homeassistant/components/alexa/manifest.json @@ -1,10 +1,8 @@ { "domain": "alexa", - "name": "Alexa", - "documentation": "https://www.home-assistant.io/components/alexa", - "requirements": [], - "dependencies": [ - "http" - ], - "codeowners": [] + "name": "Amazon Alexa", + "documentation": "https://www.home-assistant.io/integrations/alexa", + "dependencies": ["http"], + "after_dependencies": ["logbook", "camera"], + "codeowners": ["@home-assistant/cloud", "@ochlocracy"] } diff --git a/homeassistant/components/alexa/messages.py b/homeassistant/components/alexa/messages.py new file mode 100644 index 0000000000000..4dd154ea11f2e --- /dev/null +++ b/homeassistant/components/alexa/messages.py @@ -0,0 +1,195 @@ +"""Alexa models.""" +import logging +from uuid import uuid4 + +from .const import ( + API_CONTEXT, + API_DIRECTIVE, + API_ENDPOINT, + API_EVENT, + API_HEADER, + API_PAYLOAD, + API_SCOPE, +) +from .entities import ENTITY_ADAPTERS +from .errors import AlexaInvalidEndpointError + +_LOGGER = logging.getLogger(__name__) + + +class AlexaDirective: + """An incoming Alexa directive.""" + + def __init__(self, request): + """Initialize a directive.""" + 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 = self.instance = 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 + - instance (when header includes instance property) + + Behavior when self.has_endpoint is False is undefined. + + Will raise AlexaInvalidEndpointError if the endpoint in the request is + malformed or nonexistent. + """ + _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 or not config.should_expose(self.entity_id): + raise AlexaInvalidEndpointError(_endpoint_id) + + self.endpoint = ENTITY_ADAPTERS[self.entity.domain](hass, config, self.entity) + if "instance" in self._directive[API_HEADER]: + self.instance = self._directive[API_HEADER]["instance"] + + 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: + """Class to hold a response.""" + + def __init__(self, name, namespace, payload=None): + """Initialize the response.""" + 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 thermostat 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 diff --git a/homeassistant/components/alexa/resources.py b/homeassistant/components/alexa/resources.py new file mode 100644 index 0000000000000..5c02eca4fb2d2 --- /dev/null +++ b/homeassistant/components/alexa/resources.py @@ -0,0 +1,399 @@ +"""Alexa Resources and Assets.""" + + +class AlexaGlobalCatalog: + """The Global Alexa catalog. + + https://developer.amazon.com/docs/device-apis/resources-and-assets.html#global-alexa-catalog + + You can use the global Alexa catalog for pre-defined names of devices, settings, values, and units. + This catalog is localized into all the languages that Alexa supports. + + You can reference the following catalog of pre-defined friendly names. + Each item in the following list is an asset identifier followed by its supported friendly names. + The first friendly name for each identifier is the one displayed in the Alexa mobile app. + """ + + # Air Purifier, Air Cleaner,Clean Air Machine + DEVICE_NAME_AIR_PURIFIER = "Alexa.DeviceName.AirPurifier" + + # Fan, Blower + DEVICE_NAME_FAN = "Alexa.DeviceName.Fan" + + # Router, Internet Router, Network Router, Wifi Router, Net Router + DEVICE_NAME_ROUTER = "Alexa.DeviceName.Router" + + # Shade, Blind, Curtain, Roller, Shutter, Drape, Awning, Window shade, Interior blind + DEVICE_NAME_SHADE = "Alexa.DeviceName.Shade" + + # Shower + DEVICE_NAME_SHOWER = "Alexa.DeviceName.Shower" + + # Space Heater, Portable Heater + DEVICE_NAME_SPACE_HEATER = "Alexa.DeviceName.SpaceHeater" + + # Washer, Washing Machine + DEVICE_NAME_WASHER = "Alexa.DeviceName.Washer" + + # 2.4G Guest Wi-Fi, 2.4G Guest Network, Guest Network 2.4G, 2G Guest Wifi + SETTING_2G_GUEST_WIFI = "Alexa.Setting.2GGuestWiFi" + + # 5G Guest Wi-Fi, 5G Guest Network, Guest Network 5G, 5G Guest Wifi + SETTING_5G_GUEST_WIFI = "Alexa.Setting.5GGuestWiFi" + + # Auto, Automatic, Automatic Mode, Auto Mode + SETTING_AUTO = "Alexa.Setting.Auto" + + # Direction + SETTING_DIRECTION = "Alexa.Setting.Direction" + + # Dry Cycle, Dry Preset, Dry Setting, Dryer Cycle, Dryer Preset, Dryer Setting + SETTING_DRY_CYCLE = "Alexa.Setting.DryCycle" + + # Fan Speed, Airflow speed, Wind Speed, Air speed, Air velocity + SETTING_FAN_SPEED = "Alexa.Setting.FanSpeed" + + # Guest Wi-fi, Guest Network, Guest Net + SETTING_GUEST_WIFI = "Alexa.Setting.GuestWiFi" + + # Heat + SETTING_HEAT = "Alexa.Setting.Heat" + + # Mode + SETTING_MODE = "Alexa.Setting.Mode" + + # Night, Night Mode + SETTING_NIGHT = "Alexa.Setting.Night" + + # Opening, Height, Lift, Width + SETTING_OPENING = "Alexa.Setting.Opening" + + # Oscillate, Swivel, Oscillation, Spin, Back and forth + SETTING_OSCILLATE = "Alexa.Setting.Oscillate" + + # Preset, Setting + SETTING_PRESET = "Alexa.Setting.Preset" + + # Quiet, Quiet Mode, Noiseless, Silent + SETTING_QUIET = "Alexa.Setting.Quiet" + + # Temperature, Temp + SETTING_TEMPERATURE = "Alexa.Setting.Temperature" + + # Wash Cycle, Wash Preset, Wash setting + SETTING_WASH_CYCLE = "Alexa.Setting.WashCycle" + + # Water Temperature, Water Temp, Water Heat + SETTING_WATER_TEMPERATURE = "Alexa.Setting.WaterTemperature" + + # Handheld Shower, Shower Wand, Hand Shower + SHOWER_HAND_HELD = "Alexa.Shower.HandHeld" + + # Rain Head, Overhead shower, Rain Shower, Rain Spout, Rain Faucet + SHOWER_RAIN_HEAD = "Alexa.Shower.RainHead" + + # Degrees, Degree + UNIT_ANGLE_DEGREES = "Alexa.Unit.Angle.Degrees" + + # Radians, Radian + UNIT_ANGLE_RADIANS = "Alexa.Unit.Angle.Radians" + + # Feet, Foot + UNIT_DISTANCE_FEET = "Alexa.Unit.Distance.Feet" + + # Inches, Inch + UNIT_DISTANCE_INCHES = "Alexa.Unit.Distance.Inches" + + # Kilometers + UNIT_DISTANCE_KILOMETERS = "Alexa.Unit.Distance.Kilometers" + + # Meters, Meter, m + UNIT_DISTANCE_METERS = "Alexa.Unit.Distance.Meters" + + # Miles, Mile + UNIT_DISTANCE_MILES = "Alexa.Unit.Distance.Miles" + + # Yards, Yard + UNIT_DISTANCE_YARDS = "Alexa.Unit.Distance.Yards" + + # Grams, Gram, g + UNIT_MASS_GRAMS = "Alexa.Unit.Mass.Grams" + + # Kilograms, Kilogram, kg + UNIT_MASS_KILOGRAMS = "Alexa.Unit.Mass.Kilograms" + + # Percent + UNIT_PERCENT = "Alexa.Unit.Percent" + + # Celsius, Degrees Celsius, Degrees, C, Centigrade, Degrees Centigrade + UNIT_TEMPERATURE_CELSIUS = "Alexa.Unit.Temperature.Celsius" + + # Degrees, Degree + UNIT_TEMPERATURE_DEGREES = "Alexa.Unit.Temperature.Degrees" + + # Fahrenheit, Degrees Fahrenheit, Degrees F, Degrees, F + UNIT_TEMPERATURE_FAHRENHEIT = "Alexa.Unit.Temperature.Fahrenheit" + + # Kelvin, Degrees Kelvin, Degrees K, Degrees, K + UNIT_TEMPERATURE_KELVIN = "Alexa.Unit.Temperature.Kelvin" + + # Cubic Feet, Cubic Foot + UNIT_VOLUME_CUBIC_FEET = "Alexa.Unit.Volume.CubicFeet" + + # Cubic Meters, Cubic Meter, Meters Cubed + UNIT_VOLUME_CUBIC_METERS = "Alexa.Unit.Volume.CubicMeters" + + # Gallons, Gallon + UNIT_VOLUME_GALLONS = "Alexa.Unit.Volume.Gallons" + + # Liters, Liter, L + UNIT_VOLUME_LITERS = "Alexa.Unit.Volume.Liters" + + # Pints, Pint + UNIT_VOLUME_PINTS = "Alexa.Unit.Volume.Pints" + + # Quarts, Quart + UNIT_VOLUME_QUARTS = "Alexa.Unit.Volume.Quarts" + + # Ounces, Ounce, oz + UNIT_WEIGHT_OUNCES = "Alexa.Unit.Weight.Ounces" + + # Pounds, Pound, lbs + UNIT_WEIGHT_POUNDS = "Alexa.Unit.Weight.Pounds" + + # Close + VALUE_CLOSE = "Alexa.Value.Close" + + # Delicates, Delicate + VALUE_DELICATE = "Alexa.Value.Delicate" + + # High + VALUE_HIGH = "Alexa.Value.High" + + # Low + VALUE_LOW = "Alexa.Value.Low" + + # Maximum, Max + VALUE_MAXIMUM = "Alexa.Value.Maximum" + + # Medium, Mid + VALUE_MEDIUM = "Alexa.Value.Medium" + + # Minimum, Min + VALUE_MINIMUM = "Alexa.Value.Minimum" + + # Open + VALUE_OPEN = "Alexa.Value.Open" + + # Quick Wash, Fast Wash, Wash Quickly, Speed Wash + VALUE_QUICK_WASH = "Alexa.Value.QuickWash" + + +class AlexaCapabilityResource: + """Base class for Alexa capabilityResources, modeResources, and presetResources objects. + + Resources objects labels must be unique across all modeResources and presetResources within the same device. + To provide support for all supported locales, include one label from the AlexaGlobalCatalog in the labels array. + You cannot use any words from the following list as friendly names: + https://developer.amazon.com/docs/alexa/device-apis/resources-and-assets.html#names-you-cannot-use + + https://developer.amazon.com/docs/device-apis/resources-and-assets.html#capability-resources + """ + + def __init__(self, labels): + """Initialize an Alexa resource.""" + self._resource_labels = [] + for label in labels: + self._resource_labels.append(label) + + def serialize_capability_resources(self): + """Return capabilityResources object serialized for an API response.""" + return self.serialize_labels(self._resource_labels) + + @staticmethod + def serialize_configuration(): + """Return ModeResources, PresetResources friendlyNames serialized for an API response.""" + return [] + + @staticmethod + def serialize_labels(resources): + """Return resource label objects for friendlyNames serialized for an API response.""" + labels = [] + for label in resources: + if label in AlexaGlobalCatalog.__dict__.values(): + label = {"@type": "asset", "value": {"assetId": label}} + else: + label = {"@type": "text", "value": {"text": label, "locale": "en-US"}} + + labels.append(label) + + return {"friendlyNames": labels} + + +class AlexaModeResource(AlexaCapabilityResource): + """Implements Alexa ModeResources. + + https://developer.amazon.com/docs/device-apis/resources-and-assets.html#capability-resources + """ + + def __init__(self, labels, ordered=False): + """Initialize an Alexa modeResource.""" + super().__init__(labels) + self._supported_modes = [] + self._mode_ordered = ordered + + def add_mode(self, value, labels): + """Add mode to the supportedModes object.""" + self._supported_modes.append({"value": value, "labels": labels}) + + def serialize_configuration(self): + """Return configuration for ModeResources friendlyNames serialized for an API response.""" + mode_resources = [] + for mode in self._supported_modes: + result = { + "value": mode["value"], + "modeResources": self.serialize_labels(mode["labels"]), + } + mode_resources.append(result) + + return {"ordered": self._mode_ordered, "supportedModes": mode_resources} + + +class AlexaPresetResource(AlexaCapabilityResource): + """Implements Alexa PresetResources. + + Use presetResources with RangeController to provide a set of friendlyNames for each RangeController preset. + + https://developer.amazon.com/docs/device-apis/resources-and-assets.html#presetresources + """ + + def __init__(self, labels, min_value, max_value, precision, unit=None): + """Initialize an Alexa presetResource.""" + super().__init__(labels) + self._presets = [] + self._minimum_value = min_value + self._maximum_value = max_value + self._precision = precision + self._unit_of_measure = None + if unit in AlexaGlobalCatalog.__dict__.values(): + self._unit_of_measure = unit + + def add_preset(self, value, labels): + """Add preset to configuration presets array.""" + self._presets.append({"value": value, "labels": labels}) + + def serialize_configuration(self): + """Return configuration for PresetResources friendlyNames serialized for an API response.""" + configuration = { + "supportedRange": { + "minimumValue": self._minimum_value, + "maximumValue": self._maximum_value, + "precision": self._precision, + } + } + + if self._unit_of_measure: + configuration["unitOfMeasure"] = self._unit_of_measure + + if self._presets: + preset_resources = [ + { + "rangeValue": preset["value"], + "presetResources": self.serialize_labels(preset["labels"]), + } + for preset in self._presets + ] + configuration["presets"] = preset_resources + + return configuration + + +class AlexaSemantics: + """Class for Alexa Semantics Object. + + You can optionally enable additional utterances by using semantics. When you use semantics, + you manually map the phrases "open", "close", "raise", and "lower" to directives. + + Semantics is supported for the following interfaces only: ModeController, RangeController, and ToggleController. + + Semantics stateMappings are only supported for one interface of the same type on the same device. If a device has + multiple RangeControllers only one interface may use stateMappings otherwise discovery will fail. + + You can support semantics actionMappings on different controllers for the same device, however each controller must + support different phrases. For example, you can support "raise" on a RangeController, and "open" on a ModeController, + but you can't support "open" on both RangeController and ModeController. Semantics stateMappings are only supported + for one interface on the same device. + + https://developer.amazon.com/docs/device-apis/alexa-discovery.html#semantics-object + """ + + MAPPINGS_ACTION = "actionMappings" + MAPPINGS_STATE = "stateMappings" + + ACTIONS_TO_DIRECTIVE = "ActionsToDirective" + STATES_TO_VALUE = "StatesToValue" + STATES_TO_RANGE = "StatesToRange" + + ACTION_CLOSE = "Alexa.Actions.Close" + ACTION_LOWER = "Alexa.Actions.Lower" + ACTION_OPEN = "Alexa.Actions.Open" + ACTION_RAISE = "Alexa.Actions.Raise" + + STATES_OPEN = "Alexa.States.Open" + STATES_CLOSED = "Alexa.States.Closed" + + DIRECTIVE_RANGE_SET_VALUE = "SetRangeValue" + DIRECTIVE_RANGE_ADJUST_VALUE = "AdjustRangeValue" + DIRECTIVE_TOGGLE_TURN_ON = "TurnOn" + DIRECTIVE_TOGGLE_TURN_OFF = "TurnOff" + DIRECTIVE_MODE_SET_MODE = "SetMode" + DIRECTIVE_MODE_ADJUST_MODE = "AdjustMode" + + def __init__(self): + """Initialize an Alexa modeResource.""" + self._action_mappings = [] + self._state_mappings = [] + + def _add_action_mapping(self, semantics): + """Add action mapping between actions and interface directives.""" + self._action_mappings.append(semantics) + + def _add_state_mapping(self, semantics): + """Add state mapping between states and interface directives.""" + self._state_mappings.append(semantics) + + def add_states_to_value(self, states, value): + """Add StatesToValue stateMappings.""" + self._add_state_mapping( + {"@type": self.STATES_TO_VALUE, "states": states, "value": value} + ) + + def add_states_to_range(self, states, min_value, max_value): + """Add StatesToRange stateMappings.""" + self._add_state_mapping( + { + "@type": self.STATES_TO_RANGE, + "states": states, + "range": {"minimumValue": min_value, "maximumValue": max_value}, + } + ) + + def add_action_to_directive(self, actions, directive, payload): + """Add ActionsToDirective actionMappings.""" + self._add_action_mapping( + { + "@type": self.ACTIONS_TO_DIRECTIVE, + "actions": actions, + "directive": {"name": directive, "payload": payload}, + } + ) + + def serialize_semantics(self): + """Return semantics object serialized for an API response.""" + semantics = {} + if self._action_mappings: + semantics[self.MAPPINGS_ACTION] = self._action_mappings + if self._state_mappings: + semantics[self.MAPPINGS_STATE] = self._state_mappings + + return semantics diff --git a/homeassistant/components/alexa/smart_home.py b/homeassistant/components/alexa/smart_home.py index 184aee9a4404f..0f166ab3a27dc 100644 --- a/homeassistant/components/alexa/smart_home.py +++ b/homeassistant/components/alexa/smart_home.py @@ -1,1364 +1,35 @@ """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} +import homeassistant.core as ha - for prop in endpoint.serialize_properties(): - if (prop['namespace'], prop['name']) not in already_set: - self.add_context_property(prop) +from .const import API_DIRECTIVE, API_HEADER, EVENT_ALEXA_SMART_HOME +from .errors import AlexaBridgeUnreachableError, AlexaError +from .handlers import HANDLERS +from .messages import AlexaDirective - def serialize(self): - """Return response as a JSON-able data structure.""" - return self._response +_LOGGER = logging.getLogger(__name__) -async def async_handle_message( - hass, - config, - request, - context=None, - enabled=True, -): +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' + assert request[API_DIRECTIVE][API_HEADER]["payloadVersion"] == "3" if context is None: context = ha.Context() - directive = _AlexaDirective(request) + directive = AlexaDirective(request) try: if not enabled: - raise _AlexaBridgeUnreachableError( - 'Alexa API not enabled in Home Assistant configuration') + raise AlexaBridgeUnreachableError( + "Alexa API not enabled in Home Assistant configuration" + ) if directive.has_endpoint: directive.load_entity(hass, config) @@ -1370,785 +41,26 @@ async def async_handle_message( response.merge_context_properties(directive.endpoint) else: _LOGGER.warning( - "Unsupported API request %s/%s", - directive.namespace, - directive.name, + "Unsupported API request %s/%s", directive.namespace, directive.name ) response = directive.error() - except _AlexaError as err: + except AlexaError as err: response = directive.error( - error_type=err.error_type, - error_message=err.error_message) + error_type=err.error_type, error_message=err.error_message + ) - request_info = { - 'namespace': directive.namespace, - 'name': directive.name, - } + 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, loop=hass.loop): - 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 + 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, ) - 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') + return response.serialize() diff --git a/homeassistant/components/alexa/smart_home_http.py b/homeassistant/components/alexa/smart_home_http.py new file mode 100644 index 0000000000000..7c745f8afddaf --- /dev/null +++ b/homeassistant/components/alexa/smart_home_http.py @@ -0,0 +1,123 @@ +"""Alexa HTTP interface.""" +import logging + +from homeassistant import core +from homeassistant.components.http.view import HomeAssistantView + +from .auth import Auth +from .config import AbstractConfig +from .const import ( + CONF_CLIENT_ID, + CONF_CLIENT_SECRET, + CONF_ENDPOINT, + CONF_ENTITY_CONFIG, + CONF_FILTER, + CONF_LOCALE, +) +from .smart_home import async_handle_message +from .state_report import async_enable_proactive_mode + +_LOGGER = logging.getLogger(__name__) +SMART_HOME_HTTP_ENDPOINT = "/api/alexa/smart_home" + + +class AlexaConfig(AbstractConfig): + """Alexa config.""" + + def __init__(self, hass, config): + """Initialize Alexa config.""" + super().__init__(hass) + self._config = config + + if config.get(CONF_CLIENT_ID) and config.get(CONF_CLIENT_SECRET): + self._auth = Auth(hass, config[CONF_CLIENT_ID], config[CONF_CLIENT_SECRET]) + else: + self._auth = None + + @property + def supports_auth(self): + """Return if config supports auth.""" + return self._auth is not None + + @property + def should_report_state(self): + """Return if we should proactively report states.""" + return self._auth is not None + + @property + def endpoint(self): + """Endpoint for report state.""" + return self._config.get(CONF_ENDPOINT) + + @property + def entity_config(self): + """Return entity config.""" + return self._config.get(CONF_ENTITY_CONFIG) or {} + + @property + def locale(self): + """Return config locale.""" + return self._config.get(CONF_LOCALE) + + def should_expose(self, entity_id): + """If an entity should be exposed.""" + return self._config[CONF_FILTER](entity_id) + + @core.callback + def async_invalidate_access_token(self): + """Invalidate access token.""" + self._auth.async_invalidate_access_token() + + async def async_get_access_token(self): + """Get an access token.""" + return await self._auth.async_get_access_token() + + async def async_accept_grant(self, code): + """Accept a grant.""" + return await self._auth.async_do_auth(code) + + +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. + """ + smart_home_config = AlexaConfig(hass, config) + hass.http.register_view(SmartHomeView(smart_home_config)) + + if smart_home_config.should_report_state: + await async_enable_proactive_mode(hass, smart_home_config) + + +class SmartHomeView(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["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=core.Context(user_id=user.id) + ) + _LOGGER.debug("Sending Alexa Smart Home response: %s", response) + return b"" if response is None else self.json(response) diff --git a/homeassistant/components/alexa/state_report.py b/homeassistant/components/alexa/state_report.py new file mode 100644 index 0000000000000..b595bc98589b4 --- /dev/null +++ b/homeassistant/components/alexa/state_report.py @@ -0,0 +1,253 @@ +"""Alexa state report code.""" +import asyncio +import json +import logging + +import aiohttp +import async_timeout + +from homeassistant.const import MATCH_ALL, STATE_ON +import homeassistant.util.dt as dt_util + +from .const import API_CHANGE, Cause +from .entities import ENTITY_ADAPTERS +from .messages import AlexaResponse + +_LOGGER = logging.getLogger(__name__) +DEFAULT_TIMEOUT = 10 + + +async def async_enable_proactive_mode(hass, smart_home_config): + """Enable the proactive mode. + + Proactive mode makes this component report state changes to Alexa. + """ + # Validate we can get access token. + await smart_home_config.async_get_access_token() + + async def async_entity_state_listener(changed_entity, old_state, new_state): + if not hass.is_running: + return + + if not new_state: + return + + if new_state.domain not in ENTITY_ADAPTERS: + return + + if not smart_home_config.should_expose(changed_entity): + _LOGGER.debug("Not exposing %s because filtered by config", changed_entity) + 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 + if ( + interface.name() == "Alexa.DoorbellEventSource" + and new_state.state == STATE_ON + ): + await async_send_doorbell_event_message( + hass, smart_home_config, alexa_changed_entity + ) + return + + return hass.helpers.event.async_track_state_change( + MATCH_ALL, async_entity_state_listener + ) + + +async def async_send_changereport_message( + hass, config, alexa_entity, *, invalidate_access_token=True +): + """Send a ChangeReport message for an Alexa entity. + + https://developer.amazon.com/docs/smarthome/state-reporting-for-a-smart-home-skill.html#report-state-with-changereport-events + """ + token = await config.async_get_access_token() + + headers = {"Authorization": f"Bearer {token}"} + + endpoint = alexa_entity.alexa_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() + session = hass.helpers.aiohttp_client.async_get_clientsession() + + try: + 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 sending report to Alexa.") + return + + 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: + return + + response_json = json.loads(response_text) + + if ( + response_json["payload"]["code"] == "INVALID_ACCESS_TOKEN_EXCEPTION" + and not invalidate_access_token + ): + config.async_invalidate_access_token() + return await async_send_changereport_message( + hass, config, alexa_entity, invalidate_access_token=False + ) + + _LOGGER.error( + "Error when sending ChangeReport to Alexa: %s: %s", + response_json["payload"]["code"], + response_json["payload"]["description"], + ) + + +async def async_send_add_or_update_message(hass, config, entity_ids): + """Send an AddOrUpdateReport message for entities. + + https://developer.amazon.com/docs/device-apis/alexa-discovery.html#add-or-update-report + """ + token = await config.async_get_access_token() + + headers = {"Authorization": f"Bearer {token}"} + + endpoints = [] + + for entity_id in entity_ids: + domain = entity_id.split(".", 1)[0] + + if domain not in ENTITY_ADAPTERS: + continue + + alexa_entity = ENTITY_ADAPTERS[domain](hass, config, hass.states.get(entity_id)) + endpoints.append(alexa_entity.serialize_discovery()) + + payload = {"endpoints": endpoints, "scope": {"type": "BearerToken", "token": token}} + + message = AlexaResponse( + name="AddOrUpdateReport", namespace="Alexa.Discovery", payload=payload + ) + + message_serialized = message.serialize() + session = hass.helpers.aiohttp_client.async_get_clientsession() + + return await session.post( + config.endpoint, headers=headers, json=message_serialized, allow_redirects=True + ) + + +async def async_send_delete_message(hass, config, entity_ids): + """Send an DeleteReport message for entities. + + https://developer.amazon.com/docs/device-apis/alexa-discovery.html#deletereport-event + """ + token = await config.async_get_access_token() + + headers = {"Authorization": f"Bearer {token}"} + + endpoints = [] + + for entity_id in entity_ids: + domain = entity_id.split(".", 1)[0] + + if domain not in ENTITY_ADAPTERS: + continue + + alexa_entity = ENTITY_ADAPTERS[domain](hass, config, hass.states.get(entity_id)) + endpoints.append({"endpointId": alexa_entity.alexa_id()}) + + payload = {"endpoints": endpoints, "scope": {"type": "BearerToken", "token": token}} + + message = AlexaResponse( + name="DeleteReport", namespace="Alexa.Discovery", payload=payload + ) + + message_serialized = message.serialize() + session = hass.helpers.aiohttp_client.async_get_clientsession() + + return await session.post( + config.endpoint, headers=headers, json=message_serialized, allow_redirects=True + ) + + +async def async_send_doorbell_event_message(hass, config, alexa_entity): + """Send a DoorbellPress event message for an Alexa entity. + + https://developer.amazon.com/docs/smarthome/send-events-to-the-alexa-event-gateway.html + """ + token = await config.async_get_access_token() + + headers = {"Authorization": f"Bearer {token}"} + + endpoint = alexa_entity.alexa_id() + + message = AlexaResponse( + name="DoorbellPress", + namespace="Alexa.DoorbellEventSource", + payload={ + "cause": {"type": Cause.PHYSICAL_INTERACTION}, + "timestamp": f"{dt_util.utcnow().replace(tzinfo=None).isoformat()}Z", + }, + ) + + message.set_endpoint_full(token, endpoint) + + message_serialized = message.serialize() + session = hass.helpers.aiohttp_client.async_get_clientsession() + + try: + 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 sending report to Alexa.") + return + + 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: + return + + response_json = json.loads(response_text) + + _LOGGER.error( + "Error when sending DoorbellPress event to Alexa: %s: %s", + response_json["payload"]["code"], + response_json["payload"]["description"], + ) diff --git a/homeassistant/components/almond/__init__.py b/homeassistant/components/almond/__init__.py new file mode 100644 index 0000000000000..58fada7a196d1 --- /dev/null +++ b/homeassistant/components/almond/__init__.py @@ -0,0 +1,306 @@ +"""Support for Almond.""" +import asyncio +from datetime import timedelta +import logging +import time +from typing import Optional + +from aiohttp import ClientError, ClientSession +import async_timeout +from pyalmond import AbstractAlmondWebAuth, AlmondLocalAuth, WebAlmondAPI +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.auth.const import GROUP_ID_ADMIN +from homeassistant.components import conversation +from homeassistant.const import CONF_HOST, CONF_TYPE, EVENT_HOMEASSISTANT_START +from homeassistant.core import Context, CoreState, HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import ( + aiohttp_client, + config_entry_oauth2_flow, + config_validation as cv, + event, + intent, + network, + storage, +) + +from . import config_flow +from .const import DOMAIN, TYPE_LOCAL, TYPE_OAUTH2 + +CONF_CLIENT_ID = "client_id" +CONF_CLIENT_SECRET = "client_secret" + +STORAGE_VERSION = 1 +STORAGE_KEY = DOMAIN + +ALMOND_SETUP_DELAY = 30 + +DEFAULT_OAUTH2_HOST = "https://almond.stanford.edu" +DEFAULT_LOCAL_HOST = "http://localhost:3000" + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Any( + vol.Schema( + { + vol.Required(CONF_TYPE): TYPE_OAUTH2, + vol.Required(CONF_CLIENT_ID): cv.string, + vol.Required(CONF_CLIENT_SECRET): cv.string, + vol.Optional(CONF_HOST, default=DEFAULT_OAUTH2_HOST): cv.url, + } + ), + vol.Schema( + {vol.Required(CONF_TYPE): TYPE_LOCAL, vol.Required(CONF_HOST): cv.url} + ), + ) + }, + extra=vol.ALLOW_EXTRA, +) +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass, config): + """Set up the Almond component.""" + hass.data[DOMAIN] = {} + + if DOMAIN not in config: + return True + + conf = config[DOMAIN] + + host = conf[CONF_HOST] + + if conf[CONF_TYPE] == TYPE_OAUTH2: + config_flow.AlmondFlowHandler.async_register_implementation( + hass, + config_entry_oauth2_flow.LocalOAuth2Implementation( + hass, + DOMAIN, + conf[CONF_CLIENT_ID], + conf[CONF_CLIENT_SECRET], + f"{host}/me/api/oauth2/authorize", + f"{host}/me/api/oauth2/token", + ), + ) + return True + + if not hass.config_entries.async_entries(DOMAIN): + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={"type": TYPE_LOCAL, "host": conf[CONF_HOST]}, + ) + ) + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: config_entries.ConfigEntry): + """Set up Almond config entry.""" + websession = aiohttp_client.async_get_clientsession(hass) + + if entry.data["type"] == TYPE_LOCAL: + auth = AlmondLocalAuth(entry.data["host"], websession) + else: + # OAuth2 + implementation = await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, entry + ) + oauth_session = config_entry_oauth2_flow.OAuth2Session( + hass, entry, implementation + ) + auth = AlmondOAuth(entry.data["host"], websession, oauth_session) + + api = WebAlmondAPI(auth) + agent = AlmondAgent(hass, api, entry) + + # Hass.io does its own configuration. + if not entry.data.get("is_hassio"): + # If we're not starting or local, set up Almond right away + if hass.state != CoreState.not_running or entry.data["type"] == TYPE_LOCAL: + await _configure_almond_for_ha(hass, entry, api) + + else: + # OAuth2 implementations can potentially rely on the HA Cloud url. + # This url is not be available until 30 seconds after boot. + + async def configure_almond(_now): + try: + await _configure_almond_for_ha(hass, entry, api) + except ConfigEntryNotReady: + _LOGGER.warning( + "Unable to configure Almond to connect to Home Assistant" + ) + + async def almond_hass_start(_event): + event.async_call_later(hass, ALMOND_SETUP_DELAY, configure_almond) + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, almond_hass_start) + + conversation.async_set_agent(hass, agent) + return True + + +async def _configure_almond_for_ha( + hass: HomeAssistant, entry: config_entries.ConfigEntry, api: WebAlmondAPI +): + """Configure Almond to connect to HA.""" + + if entry.data["type"] == TYPE_OAUTH2: + # If we're connecting over OAuth2, we will only set up connection + # with Home Assistant if we're remotely accessible. + hass_url = network.async_get_external_url(hass) + else: + hass_url = hass.config.api.base_url + + # If hass_url is None, we're not going to configure Almond to connect to HA. + if hass_url is None: + return + + _LOGGER.debug("Configuring Almond to connect to Home Assistant at %s", hass_url) + store = storage.Store(hass, STORAGE_VERSION, STORAGE_KEY) + data = await store.async_load() + + if data is None: + data = {} + + user = None + if "almond_user" in data: + user = await hass.auth.async_get_user(data["almond_user"]) + + if user is None: + user = await hass.auth.async_create_system_user("Almond", [GROUP_ID_ADMIN]) + data["almond_user"] = user.id + await store.async_save(data) + + refresh_token = await hass.auth.async_create_refresh_token( + user, + # Almond will be fine as long as we restart once every 5 years + access_token_expiration=timedelta(days=365 * 5), + ) + + # Create long lived access token + access_token = hass.auth.async_create_access_token(refresh_token) + + # Store token in Almond + try: + with async_timeout.timeout(30): + await api.async_create_device( + { + "kind": "io.home-assistant", + "hassUrl": hass_url, + "accessToken": access_token, + "refreshToken": "", + # 5 years from now in ms. + "accessTokenExpires": (time.time() + 60 * 60 * 24 * 365 * 5) * 1000, + } + ) + except (asyncio.TimeoutError, ClientError) as err: + if isinstance(err, asyncio.TimeoutError): + msg = "Request timeout" + else: + msg = err + _LOGGER.warning("Unable to configure Almond: %s", msg) + await hass.auth.async_remove_refresh_token(refresh_token) + raise ConfigEntryNotReady + + # Clear all other refresh tokens + for token in list(user.refresh_tokens.values()): + if token.id != refresh_token.id: + await hass.auth.async_remove_refresh_token(token) + + +async def async_unload_entry(hass, entry): + """Unload Almond.""" + conversation.async_set_agent(hass, None) + return True + + +class AlmondOAuth(AbstractAlmondWebAuth): + """Almond Authentication using OAuth2.""" + + def __init__( + self, + host: str, + websession: ClientSession, + oauth_session: config_entry_oauth2_flow.OAuth2Session, + ): + """Initialize Almond auth.""" + super().__init__(host, websession) + self._oauth_session = oauth_session + + async def async_get_access_token(self): + """Return a valid access token.""" + if not self._oauth_session.valid_token: + await self._oauth_session.async_ensure_token_valid() + + return self._oauth_session.token["access_token"] + + +class AlmondAgent(conversation.AbstractConversationAgent): + """Almond conversation agent.""" + + def __init__( + self, hass: HomeAssistant, api: WebAlmondAPI, entry: config_entries.ConfigEntry + ): + """Initialize the agent.""" + self.hass = hass + self.api = api + self.entry = entry + + @property + def attribution(self): + """Return the attribution.""" + return {"name": "Powered by Almond", "url": "https://almond.stanford.edu/"} + + async def async_get_onboarding(self): + """Get onboard url if not onboarded.""" + if self.entry.data.get("onboarded"): + return None + + host = self.entry.data["host"] + if self.entry.data.get("is_hassio"): + host = "/core_almond" + return { + "text": "Would you like to opt-in to share your anonymized commands with Stanford to improve Almond's responses?", + "url": f"{host}/conversation", + } + + async def async_set_onboarding(self, shown): + """Set onboarding status.""" + self.hass.config_entries.async_update_entry( + self.entry, data={**self.entry.data, "onboarded": shown} + ) + + return True + + async def async_process( + self, text: str, context: Context, conversation_id: Optional[str] = None + ) -> intent.IntentResponse: + """Process a sentence.""" + response = await self.api.async_converse_text(text, conversation_id) + + first_choice = True + buffer = "" + for message in response["messages"]: + if message["type"] == "text": + buffer += f"\n{message['text']}" + elif message["type"] == "picture": + buffer += f"\n Picture: {message['url']}" + elif message["type"] == "rdl": + buffer += ( + f"\n Link: {message['rdl']['displayTitle']} " + f"{message['rdl']['webCallback']}" + ) + elif message["type"] == "choice": + if first_choice: + first_choice = False + else: + buffer += "," + buffer += f" {message['title']}" + + intent_result = intent.IntentResponse() + intent_result.async_set_speech(buffer.strip()) + return intent_result diff --git a/homeassistant/components/almond/config_flow.py b/homeassistant/components/almond/config_flow.py new file mode 100644 index 0000000000000..b1eb506270b86 --- /dev/null +++ b/homeassistant/components/almond/config_flow.py @@ -0,0 +1,124 @@ +"""Config flow to connect with Home Assistant.""" +import asyncio +import logging + +from aiohttp import ClientError +import async_timeout +from pyalmond import AlmondLocalAuth, WebAlmondAPI +import voluptuous as vol +from yarl import URL + +from homeassistant import config_entries, core, data_entry_flow +from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow + +from .const import DOMAIN, TYPE_LOCAL, TYPE_OAUTH2 + + +async def async_verify_local_connection(hass: core.HomeAssistant, host: str): + """Verify that a local connection works.""" + websession = aiohttp_client.async_get_clientsession(hass) + api = WebAlmondAPI(AlmondLocalAuth(host, websession)) + + try: + with async_timeout.timeout(10): + await api.async_list_apps() + + return True + except (asyncio.TimeoutError, ClientError): + return False + + +@config_entries.HANDLERS.register(DOMAIN) +class AlmondFlowHandler(config_entry_oauth2_flow.AbstractOAuth2FlowHandler): + """Implementation of the Almond OAuth2 config flow.""" + + DOMAIN = DOMAIN + + host = None + hassio_discovery = None + + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return logging.getLogger(__name__) + + @property + def extra_authorize_data(self) -> dict: + """Extra data that needs to be appended to the authorize url.""" + return {"scope": "profile user-read user-read-results user-exec-command"} + + async def async_step_user(self, user_input=None): + """Handle a flow start.""" + # Only allow 1 instance. + if self._async_current_entries(): + return self.async_abort(reason="already_setup") + + return await super().async_step_user(user_input) + + async def async_step_auth(self, user_input=None): + """Handle authorize step.""" + result = await super().async_step_auth(user_input) + + if result["type"] == data_entry_flow.RESULT_TYPE_EXTERNAL_STEP: + self.host = str(URL(result["url"]).with_path("me")) + + return result + + async def async_oauth_create_entry(self, data: dict) -> dict: + """Create an entry for the flow. + + Ok to override if you want to fetch extra info or even add another step. + """ + # pylint: disable=invalid-name + self.CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + data["type"] = TYPE_OAUTH2 + data["host"] = self.host + return self.async_create_entry(title=self.flow_impl.name, data=data) + + async def async_step_import(self, user_input: dict = None) -> dict: + """Import data.""" + # Only allow 1 instance. + if self._async_current_entries(): + return self.async_abort(reason="already_setup") + + if not await async_verify_local_connection(self.hass, user_input["host"]): + self.logger.warning( + "Aborting import of Almond because we're unable to connect" + ) + return self.async_abort(reason="cannot_connect") + + self.CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + return self.async_create_entry( + title="Configuration.yaml", + data={"type": TYPE_LOCAL, "host": user_input["host"]}, + ) + + async def async_step_hassio(self, user_input=None): + """Receive a Hass.io discovery.""" + if self._async_current_entries(): + return self.async_abort(reason="already_setup") + + 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.""" + data = self.hassio_discovery + + if user_input is not None: + return self.async_create_entry( + title=data["addon"], + data={ + "is_hassio": True, + "type": TYPE_LOCAL, + "host": f"http://{data['host']}:{data['port']}", + }, + ) + + return self.async_show_form( + step_id="hassio_confirm", + description_placeholders={"addon": data["addon"]}, + data_schema=vol.Schema({}), + ) diff --git a/homeassistant/components/almond/const.py b/homeassistant/components/almond/const.py new file mode 100644 index 0000000000000..34dca28e9571e --- /dev/null +++ b/homeassistant/components/almond/const.py @@ -0,0 +1,4 @@ +"""Constants for the Almond integration.""" +DOMAIN = "almond" +TYPE_OAUTH2 = "oauth2" +TYPE_LOCAL = "local" diff --git a/homeassistant/components/almond/manifest.json b/homeassistant/components/almond/manifest.json new file mode 100644 index 0000000000000..44404b504f6a0 --- /dev/null +++ b/homeassistant/components/almond/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "almond", + "name": "Almond", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/almond", + "dependencies": ["http", "conversation"], + "codeowners": ["@gcampax", "@balloob"], + "requirements": ["pyalmond==0.0.2"] +} diff --git a/homeassistant/components/almond/strings.json b/homeassistant/components/almond/strings.json new file mode 100644 index 0000000000000..008d21c463b0e --- /dev/null +++ b/homeassistant/components/almond/strings.json @@ -0,0 +1,16 @@ +{ + "config": { + "step": { + "pick_implementation": { "title": "Pick Authentication Method" }, + "hassio_confirm": { + "title": "Almond via Hass.io add-on", + "description": "Do you want to configure Home Assistant to connect to Almond provided by the Hass.io add-on: {addon}?" + } + }, + "abort": { + "already_setup": "You can only configure one Almond account.", + "cannot_connect": "Unable to connect to the Almond server.", + "missing_configuration": "Please check the documentation on how to set up Almond." + } + } +} diff --git a/homeassistant/components/almond/translations/bg.json b/homeassistant/components/almond/translations/bg.json new file mode 100644 index 0000000000000..c2bcab535f363 --- /dev/null +++ b/homeassistant/components/almond/translations/bg.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "already_setup": "\u041c\u043e\u0436\u0435\u0442\u0435 \u0434\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u0442\u0435 \u0441\u0430\u043c\u043e \u0435\u0434\u0438\u043d Almond \u0430\u043a\u0430\u0443\u043d\u0442.", + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435 \u0441 Almond \u0441\u044a\u0440\u0432\u044a\u0440\u0430.", + "missing_configuration": "\u041c\u043e\u043b\u044f, \u043f\u0440\u043e\u0432\u0435\u0440\u0435\u0442\u0435 \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u0446\u0438\u044f\u0442\u0430 \u043a\u0430\u043a \u0434\u0430 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u0435 Almond." + }, + "step": { + "pick_implementation": { + "title": "\u0418\u0437\u0431\u043e\u0440 \u043d\u0430 \u043c\u0435\u0442\u043e\u0434 \u0437\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u043a\u0430\u0446\u0438\u044f" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/almond/translations/ca.json b/homeassistant/components/almond/translations/ca.json new file mode 100644 index 0000000000000..8747f1ed7dffa --- /dev/null +++ b/homeassistant/components/almond/translations/ca.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_setup": "Nom\u00e9s pots configurar un \u00fanic compte amb Almond.", + "cannot_connect": "No es pot connectar amb el servidor d'Almond.", + "missing_configuration": "Consulta la documentaci\u00f3 sobre com configurar Almond." + }, + "step": { + "hassio_confirm": { + "description": "Vols configurar Home Assistant perqu\u00e8 es connecti amb Almond proporcionat pel complement de Hass.io: {addon}?", + "title": "Almond (complement de Hass.io)" + }, + "pick_implementation": { + "title": "Selecci\u00f3 del m\u00e8tode d'autenticaci\u00f3" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/almond/translations/cs.json b/homeassistant/components/almond/translations/cs.json new file mode 100644 index 0000000000000..f103fcc272774 --- /dev/null +++ b/homeassistant/components/almond/translations/cs.json @@ -0,0 +1,10 @@ +{ + "config": { + "step": { + "hassio_confirm": { + "description": "Chcete nakonfigurovat slu\u017ebu Home Assistant pro p\u0159ipojen\u00ed k Almond pomoc\u00ed hass.io {addon}?", + "title": "Almond prost\u0159ednictv\u00edm dopl\u0148ku Hass.io" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/almond/translations/da.json b/homeassistant/components/almond/translations/da.json new file mode 100644 index 0000000000000..9ce415cf8f979 --- /dev/null +++ b/homeassistant/components/almond/translations/da.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_setup": "Du kan kun konfigurere en Almond-konto.", + "cannot_connect": "Kan ikke oprette forbindelse til Almond-serveren.", + "missing_configuration": "Tjek venligst dokumentationen om, hvordan man indstiller Almond." + }, + "step": { + "hassio_confirm": { + "description": "Vil du konfigurere Home Assistant til at oprette forbindelse til Almond leveret af Hass.io-tilf\u00f8jelsen: {addon}?", + "title": "Almond via Hass.io-tilf\u00f8jelse" + }, + "pick_implementation": { + "title": "V\u00e6lg godkendelsesmetode" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/almond/translations/de.json b/homeassistant/components/almond/translations/de.json new file mode 100644 index 0000000000000..d90bbc9154f6c --- /dev/null +++ b/homeassistant/components/almond/translations/de.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_setup": "Du kannst nur ein Almond-Konto konfigurieren.", + "cannot_connect": "Verbindung zum Almond-Server nicht m\u00f6glich.", + "missing_configuration": "Bitte \u00fcberpr\u00fcfe die Dokumentation zur Einrichtung von Almond." + }, + "step": { + "hassio_confirm": { + "description": "M\u00f6chtest du Home Assistant so konfigurieren, dass eine Verbindung mit Almond als Hass.io-Add-On hergestellt wird: {addon}?", + "title": "Almond \u00fcber das Hass.io Add-on" + }, + "pick_implementation": { + "title": "W\u00e4hle die Authentifizierungsmethode" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/almond/translations/en.json b/homeassistant/components/almond/translations/en.json new file mode 100644 index 0000000000000..2a587d4640337 --- /dev/null +++ b/homeassistant/components/almond/translations/en.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_setup": "You can only configure one Almond account.", + "cannot_connect": "Unable to connect to the Almond server.", + "missing_configuration": "Please check the documentation on how to set up Almond." + }, + "step": { + "hassio_confirm": { + "description": "Do you want to configure Home Assistant to connect to Almond provided by the Hass.io add-on: {addon}?", + "title": "Almond via Hass.io add-on" + }, + "pick_implementation": { + "title": "Pick Authentication Method" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/almond/translations/es-419.json b/homeassistant/components/almond/translations/es-419.json new file mode 100644 index 0000000000000..fbcf901c2e5db --- /dev/null +++ b/homeassistant/components/almond/translations/es-419.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_setup": "Solo puede configurar una cuenta Almond.", + "cannot_connect": "No se puede conectar con el servidor Almond.", + "missing_configuration": "Por favor, consulte la documentaci\u00f3n sobre c\u00f3mo configurar Almond." + }, + "step": { + "hassio_confirm": { + "description": "\u00bfDesea configurar Home Assistant para conectarse a Almond proporcionado por el complemento Hass.io: {addon}?", + "title": "Almond a trav\u00e9s del complemento Hass.io" + }, + "pick_implementation": { + "title": "Seleccione el m\u00e9todo de autenticaci\u00f3n" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/almond/translations/es.json b/homeassistant/components/almond/translations/es.json new file mode 100644 index 0000000000000..de9fb58eabdd0 --- /dev/null +++ b/homeassistant/components/almond/translations/es.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_setup": "S\u00f3lo puede configurar una cuenta de Almond.", + "cannot_connect": "No se puede conectar al servidor Almond.", + "missing_configuration": "Consulte la documentaci\u00f3n sobre c\u00f3mo configurar Almond." + }, + "step": { + "hassio_confirm": { + "description": "\u00bfDesea configurar Home Assistant para conectarse a Almond proporcionado por el complemento Hass.io: {addon} ?", + "title": "Almond a trav\u00e9s del complemento Hass.io" + }, + "pick_implementation": { + "title": "Seleccione el m\u00e9todo de autenticaci\u00f3n" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/almond/translations/fr.json b/homeassistant/components/almond/translations/fr.json new file mode 100644 index 0000000000000..f39a1660bb900 --- /dev/null +++ b/homeassistant/components/almond/translations/fr.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_setup": "Vous ne pouvez configurer qu'un seul compte Almond", + "cannot_connect": "Impossible de se connecter au serveur Almond", + "missing_configuration": "Veuillez consulter la documentation pour savoir comment configurer Almond." + }, + "step": { + "hassio_confirm": { + "description": "Voulez-vous configurer Home Assistant pour se connecter \u00e0 Almond fourni par le module compl\u00e9mentaire Hass.io: {addon} ?", + "title": "Almonf via le module compl\u00e9mentaire Hass.io" + }, + "pick_implementation": { + "title": "S\u00e9lectionner une m\u00e9thode d'authentification" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/almond/translations/hu.json b/homeassistant/components/almond/translations/hu.json new file mode 100644 index 0000000000000..2f9be096d79ca --- /dev/null +++ b/homeassistant/components/almond/translations/hu.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_setup": "Csak egy Almond fi\u00f3kot konfigur\u00e1lhat.", + "cannot_connect": "Nem lehet csatlakozni az Almond szerverhez.", + "missing_configuration": "K\u00e9rj\u00fck, ellen\u0151rizze az Almond be\u00e1ll\u00edt\u00e1s\u00e1nak dokument\u00e1ci\u00f3j\u00e1t." + }, + "step": { + "hassio_confirm": { + "description": "Be szeretn\u00e9 \u00e1ll\u00edtani a Home Assistant alkalmaz\u00e1st az Almondhoz val\u00f3 csatlakoz\u00e1shoz, amelyet a Hass.io kieg\u00e9sz\u00edt\u0151 biztos\u00edt: {addon} ?", + "title": "Almond a Hass.io kieg\u00e9sz\u00edt\u0151n kereszt\u00fcl" + }, + "pick_implementation": { + "title": "V\u00e1lassza ki a hiteles\u00edt\u00e9si m\u00f3dszert" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/almond/translations/it.json b/homeassistant/components/almond/translations/it.json new file mode 100644 index 0000000000000..3e68336bf3e78 --- /dev/null +++ b/homeassistant/components/almond/translations/it.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_setup": "\u00c8 possibile configurare un solo account Almond.", + "cannot_connect": "Impossibile connettersi al server Almond.", + "missing_configuration": "Si prega di controllare la documentazione su come impostare Almond." + }, + "step": { + "hassio_confirm": { + "description": "Vuoi configurare Home Assistant a connettersi ad Almond tramite il componente aggiuntivo Hass.io: {addon} ?", + "title": "Almond tramite il componente aggiuntivo di Hass.io" + }, + "pick_implementation": { + "title": "Seleziona metodo di autenticazione" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/almond/translations/ko.json b/homeassistant/components/almond/translations/ko.json new file mode 100644 index 0000000000000..645eaafab08e2 --- /dev/null +++ b/homeassistant/components/almond/translations/ko.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_setup": "\ud558\ub098\uc758 Almond \uacc4\uc815\ub9cc \uad6c\uc131\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.", + "cannot_connect": "Almond \uc11c\ubc84\uc5d0 \uc5f0\uacb0\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4.", + "missing_configuration": "Almond \uc124\uc815 \ubc29\ubc95\uc5d0 \ub300\ud55c \uc124\uba85\uc11c\ub97c \ud655\uc778\ud574\uc8fc\uc138\uc694." + }, + "step": { + "hassio_confirm": { + "description": "Hass.io {addon} \uc560\ub4dc\uc628\uc73c\ub85c Almond \uc5d0 \uc5f0\uacb0\ud558\ub3c4\ub85d Home Assistant \ub97c \uad6c\uc131\ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?", + "title": "Hass.io \uc560\ub4dc\uc628\uc758 Almond" + }, + "pick_implementation": { + "title": "\uc778\uc99d \ubc29\ubc95 \uc120\ud0dd\ud558\uae30" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/almond/translations/lb.json b/homeassistant/components/almond/translations/lb.json new file mode 100644 index 0000000000000..3b866a326bea7 --- /dev/null +++ b/homeassistant/components/almond/translations/lb.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_setup": "Dir k\u00ebnnt n\u00ebmmen een eenzegen Almond Kont konfigur\u00e9ieren.", + "cannot_connect": "Kann sech net mam Almond Server verbannen.", + "missing_configuration": "Kuckt w.e.g. Dokumentatioun iwwert d'ariichten vun Almond." + }, + "step": { + "hassio_confirm": { + "description": "W\u00ebllt dir Home Assistant konfigur\u00e9iere fir sech mam Almond ze verbannen dee vun der hass.io Erweiderung {addon} bereet gestallt g\u00ebtt?", + "title": "Almond via Hass.io Erweiderung" + }, + "pick_implementation": { + "title": "Wielt Authentifikatiouns Method aus" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/almond/translations/nl.json b/homeassistant/components/almond/translations/nl.json new file mode 100644 index 0000000000000..7a2a60b1a695e --- /dev/null +++ b/homeassistant/components/almond/translations/nl.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_setup": "U kunt slechts \u00e9\u00e9n Almond-account configureren.", + "cannot_connect": "Kan geen verbinding maken met de Almond-server.", + "missing_configuration": "Raadpleeg de documentatie over het instellen van Almond." + }, + "step": { + "hassio_confirm": { + "description": "Wilt u Home Assistant configureren om verbinding te maken met Almond die wordt aangeboden door de hass.io add-on {addon} ?", + "title": "Almond via Hass.io add-on" + }, + "pick_implementation": { + "title": "Kies de authenticatie methode" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/almond/translations/nn.json b/homeassistant/components/almond/translations/nn.json new file mode 100644 index 0000000000000..adee951492873 --- /dev/null +++ b/homeassistant/components/almond/translations/nn.json @@ -0,0 +1,3 @@ +{ + "title": "Almond" +} \ No newline at end of file diff --git a/homeassistant/components/almond/translations/no.json b/homeassistant/components/almond/translations/no.json new file mode 100644 index 0000000000000..9ec4e8853b933 --- /dev/null +++ b/homeassistant/components/almond/translations/no.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_setup": "Du kan bare konfigurere en Almond konto.", + "cannot_connect": "Kan ikke koble til Almond-serveren.", + "missing_configuration": "Vennligst sjekk dokumentasjonen om hvordan du setter opp Almond." + }, + "step": { + "hassio_confirm": { + "description": "Vil du konfigurere Home Assistant til \u00e5 koble til Almond levert av Hass.io add-on: {addon}?", + "title": "" + }, + "pick_implementation": { + "title": "Velg autentiseringsmetode" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/almond/translations/pl.json b/homeassistant/components/almond/translations/pl.json new file mode 100644 index 0000000000000..6b3feb4bd0bae --- /dev/null +++ b/homeassistant/components/almond/translations/pl.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_setup": "Mo\u017cesz skonfigurowa\u0107 tylko jedno konto Almond.", + "cannot_connect": "Nie mo\u017cna po\u0142\u0105czy\u0107 si\u0119 z serwerem Almond.", + "missing_configuration": "Prosz\u0119 zapozna\u0107 si\u0119 z dokumentacj\u0105 konfiguracji Almond." + }, + "step": { + "hassio_confirm": { + "description": "Czy chcesz skonfigurowa\u0107 Home Assistant'a, aby \u0142\u0105czy\u0142 si\u0119 z Almond dostarczonym przez dodatek Hass.io: {addon}?", + "title": "Almond poprzez dodatek Hass.io" + }, + "pick_implementation": { + "title": "Wybierz metod\u0119 uwierzytelniania" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/almond/translations/pt-BR.json b/homeassistant/components/almond/translations/pt-BR.json new file mode 100644 index 0000000000000..94dfbefb86a22 --- /dev/null +++ b/homeassistant/components/almond/translations/pt-BR.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "pick_implementation": { + "title": "Escolha o m\u00e9todo de autentica\u00e7\u00e3o" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/almond/translations/pt.json b/homeassistant/components/almond/translations/pt.json new file mode 100644 index 0000000000000..94dfbefb86a22 --- /dev/null +++ b/homeassistant/components/almond/translations/pt.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "pick_implementation": { + "title": "Escolha o m\u00e9todo de autentica\u00e7\u00e3o" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/almond/translations/ru.json b/homeassistant/components/almond/translations/ru.json new file mode 100644 index 0000000000000..3821a65e08b8a --- /dev/null +++ b/homeassistant/components/almond/translations/ru.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_setup": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430.", + "cannot_connect": "\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 Almond.", + "missing_configuration": "\u041e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 \u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u044f\u043c\u0438 \u043f\u043e \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0435 Almond." + }, + "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 Almond (\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u0435 \u0434\u043b\u044f Hass.io \"{addon}\")?", + "title": "Almond (\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u0435 \u0434\u043b\u044f Hass.io)" + }, + "pick_implementation": { + "title": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u043c\u0435\u0442\u043e\u0434 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/almond/translations/sl.json b/homeassistant/components/almond/translations/sl.json new file mode 100644 index 0000000000000..cc2197ffaba4d --- /dev/null +++ b/homeassistant/components/almond/translations/sl.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_setup": "Konfigurirate lahko samo en ra\u010dun Almond.", + "cannot_connect": "Ni mogo\u010de vzpostaviti povezave s stre\u017enikom Almond.", + "missing_configuration": "Prosimo, preverite dokumentacijo o tem, kako nastaviti Almond." + }, + "step": { + "hassio_confirm": { + "description": "Ali \u017eelite konfigurirati Home Assistant za povezavo z Almondom, ki ga ponuja dodatek Hass.io: {addon} ?", + "title": "Almond prek dodatka Hass.io" + }, + "pick_implementation": { + "title": "Izberite na\u010din preverjanja pristnosti" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/almond/translations/sv.json b/homeassistant/components/almond/translations/sv.json new file mode 100644 index 0000000000000..70743f68e4d65 --- /dev/null +++ b/homeassistant/components/almond/translations/sv.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_setup": "Du kan bara konfigurera ett Almond-konto.", + "cannot_connect": "Det g\u00e5r inte att ansluta till Almond-servern.", + "missing_configuration": "Kontrollera dokumentationen f\u00f6r hur du st\u00e4ller in Almond." + }, + "step": { + "hassio_confirm": { + "description": "Vill du konfigurera Home Assistant f\u00f6r att ansluta till Almond som tillhandah\u00e5lls av Hass.io-till\u00e4gget: {addon} ?", + "title": "Almond via Hass.io-till\u00e4gget" + }, + "pick_implementation": { + "title": "V\u00e4lj autentiseringsmetod" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/almond/translations/zh-Hant.json b/homeassistant/components/almond/translations/zh-Hant.json new file mode 100644 index 0000000000000..96e3d92e06072 --- /dev/null +++ b/homeassistant/components/almond/translations/zh-Hant.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_setup": "\u50c5\u80fd\u8a2d\u5b9a\u4e00\u7d44 Almond \u5e33\u865f\u3002", + "cannot_connect": "\u7121\u6cd5\u9023\u7dda\u81f3 Almond \u4f3a\u670d\u5668\u3002", + "missing_configuration": "\u8acb\u53c3\u8003\u76f8\u95dc\u6587\u4ef6\u4ee5\u4e86\u89e3\u5982\u4f55\u8a2d\u5b9a Almond\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 Almond\uff1f", + "title": "\u4f7f\u7528 Hass.io \u9644\u52a0\u7d44\u4ef6 Almond" + }, + "pick_implementation": { + "title": "\u9078\u64c7\u9a57\u8b49\u6a21\u5f0f" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/alpha_vantage/__init__.py b/homeassistant/components/alpha_vantage/__init__.py index f8220c2cb811e..b8da9c190245b 100644 --- a/homeassistant/components/alpha_vantage/__init__.py +++ b/homeassistant/components/alpha_vantage/__init__.py @@ -1 +1 @@ -"""The alpha_vantage component.""" +"""The Alpha Vantage component.""" diff --git a/homeassistant/components/alpha_vantage/manifest.json b/homeassistant/components/alpha_vantage/manifest.json index dacc428ea2ee3..dad5fc88e804c 100644 --- a/homeassistant/components/alpha_vantage/manifest.json +++ b/homeassistant/components/alpha_vantage/manifest.json @@ -1,12 +1,7 @@ { "domain": "alpha_vantage", - "name": "Alpha vantage", - "documentation": "https://www.home-assistant.io/components/alpha_vantage", - "requirements": [ - "alpha_vantage==2.1.0" - ], - "dependencies": [], - "codeowners": [ - "@fabaff" - ] + "name": "Alpha Vantage", + "documentation": "https://www.home-assistant.io/integrations/alpha_vantage", + "requirements": ["alpha_vantage==2.1.3"], + "codeowners": ["@fabaff"] } diff --git a/homeassistant/components/alpha_vantage/sensor.py b/homeassistant/components/alpha_vantage/sensor.py index 9ea6797a56efc..0d0aec47915e9 100644 --- a/homeassistant/components/alpha_vantage/sensor.py +++ b/homeassistant/components/alpha_vantage/sensor.py @@ -2,74 +2,75 @@ from datetime import timedelta import logging +from alpha_vantage.foreignexchange import ForeignExchange +from alpha_vantage.timeseries import TimeSeries import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import ( - ATTR_ATTRIBUTION, CONF_API_KEY, CONF_CURRENCY, CONF_NAME) +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' +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' +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', + "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]), -}) +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) + api_key = config[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') + msg = "No symbols or currencies configured." + hass.components.persistent_notification.create(msg, "Sensor alpha_vantage") _LOGGER.warning(msg) return @@ -78,12 +79,10 @@ def setup_platform(hass, config, add_entities, discovery_info=None): dev = [] for symbol in symbols: try: - _LOGGER.debug("Configuring timeseries for symbols: %s", - symbol[CONF_SYMBOL]) + _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) + _LOGGER.error("API Key is not valid or symbol '%s' not known", symbol) dev.append(AlphaVantageSensor(timeseries, symbol)) forex = ForeignExchange(key=api_key) @@ -92,12 +91,13 @@ def setup_platform(hass, config, add_entities, discovery_info=None): 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) + 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) + from_cur, + to_cur, + ) _LOGGER.debug(str(error)) dev.append(AlphaVantageForeignExchange(forex, conversion)) @@ -115,7 +115,7 @@ def __init__(self, timeseries, 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')) + self._icon = ICONS.get(symbol.get(CONF_CURRENCY, "USD")) @property def name(self): @@ -130,7 +130,7 @@ def unit_of_measurement(self): @property def state(self): """Return the state of the sensor.""" - return self.values['1. open'] + return self.values["1. open"] @property def device_state_attributes(self): @@ -138,9 +138,9 @@ def device_state_attributes(self): 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'], + ATTR_CLOSE: self.values["4. close"], + ATTR_HIGH: self.values["2. high"], + ATTR_LOW: self.values["3. low"], } @property @@ -162,14 +162,14 @@ class AlphaVantageForeignExchange(Entity): 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) + self._from_currency = config[CONF_FROM] + self._to_currency = config[CONF_TO] if CONF_NAME in config: self._name = config.get(CONF_NAME) else: - self._name = '{}/{}'.format(self._to_currency, self._from_currency) + self._name = f"{self._to_currency}/{self._from_currency}" self._unit_of_measurement = self._to_currency - self._icon = ICONS.get(self._from_currency, 'USD') + self._icon = ICONS.get(self._from_currency, "USD") self.values = None @property @@ -185,7 +185,7 @@ def unit_of_measurement(self): @property def state(self): """Return the state of the sensor.""" - return round(float(self.values['5. Exchange Rate']), 4) + return round(float(self.values["5. Exchange Rate"]), 4) @property def icon(self): @@ -204,9 +204,16 @@ def device_state_attributes(self): 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) + _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) + 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/manifest.json b/homeassistant/components/amazon_polly/manifest.json index 19140aac93968..abcc46cadad54 100644 --- a/homeassistant/components/amazon_polly/manifest.json +++ b/homeassistant/components/amazon_polly/manifest.json @@ -1,12 +1,7 @@ { "domain": "amazon_polly", - "name": "Amazon polly", - "documentation": "https://www.home-assistant.io/components/amazon_polly", - "requirements": [ - "boto3==1.9.16" - ], - "dependencies": [], - "codeowners": [ - "@robbiet480" - ] + "name": "Amazon Polly", + "documentation": "https://www.home-assistant.io/integrations/amazon_polly", + "requirements": ["boto3==1.9.252"], + "codeowners": ["@robbiet480"] } diff --git a/homeassistant/components/amazon_polly/tts.py b/homeassistant/components/amazon_polly/tts.py index 4511a587a60c5..ef3fe4e3ccbfc 100644 --- a/homeassistant/components/amazon_polly/tts.py +++ b/homeassistant/components/amazon_polly/tts.py @@ -1,6 +1,7 @@ """Support for the Amazon Polly text to speech service.""" import logging +import boto3 import voluptuous as vol from homeassistant.components.tts import PLATFORM_SCHEMA, Provider @@ -8,121 +9,161 @@ _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_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' +CONF_ENGINE = "engine" +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 + "Zhiyu", # Chinese + "Mads", + "Naja", # Danish + "Ruben", + "Lotte", # Dutch + "Russell", + "Nicole", # English Australian + "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_OUTPUT_FORMATS = ["mp3", "ogg_vorbis", "pcm"] -SUPPORTED_SAMPLE_RATES_MAP = { - 'mp3': ['8000', '16000', '22050'], - 'ogg_vorbis': ['8000', '16000', '22050'], - 'pcm': ['8000', '16000'], -} +SUPPORTED_ENGINES = ["neural", "standard"] -SUPPORTED_TEXT_TYPES = ['text', 'ssml'] +SUPPORTED_SAMPLE_RATES = ["8000", "16000", "22050", "24000"] -CONTENT_TYPE_EXTENSIONS = { - 'audio/mpeg': 'mp3', - 'audio/ogg': 'ogg', - 'audio/pcm': 'pcm', +SUPPORTED_SAMPLE_RATES_MAP = { + "mp3": ["8000", "16000", "22050", "24000"], + "ogg_vorbis": ["8000", "16000", "22050"], + "pcm": ["8000", "16000"], } -DEFAULT_VOICE = 'Joanna' -DEFAULT_OUTPUT_FORMAT = 'mp3' -DEFAULT_TEXT_TYPE = 'text' +SUPPORTED_TEXT_TYPES = ["text", "ssml"] + +CONTENT_TYPE_EXTENSIONS = {"audio/mpeg": "mp3", "audio/ogg": "ogg", "audio/pcm": "pcm"} + +DEFAULT_ENGINE = "standard" +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_ENGINE, default=DEFAULT_ENGINE): vol.In(SUPPORTED_ENGINES), + 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 + ), + } +) -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): +def get_engine(hass, config, discovery_info=None): """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]) + output_format = config[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) + _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_REGION: config[CONF_REGION], CONF_ACCESS_KEY_ID: config.get(CONF_ACCESS_KEY_ID), CONF_SECRET_ACCESS_KEY: config.get(CONF_SECRET_ACCESS_KEY), } @@ -131,7 +172,7 @@ def get_engine(hass, config): del config[CONF_ACCESS_KEY_ID] del config[CONF_SECRET_ACCESS_KEY] - polly_client = boto3.client('polly', **aws_config) + polly_client = boto3.client("polly", **aws_config) supported_languages = [] @@ -139,27 +180,25 @@ def get_engine(hass, config): 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')) + 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) + 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): + 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' + self.default_voice = self.config[CONF_VOICE] + self.name = "Amazon Polly" @property def supported_languages(self): @@ -169,7 +208,7 @@ def supported_languages(self): @property def default_language(self): """Return the default language.""" - return self.all_voices.get(self.default_voice).get('LanguageCode') + return self.all_voices.get(self.default_voice).get("LanguageCode") @property def default_options(self): @@ -185,18 +224,20 @@ 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) + 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( + Engine=self.config[CONF_ENGINE], OutputFormat=self.config[CONF_OUTPUT_FORMAT], SampleRate=self.config[CONF_SAMPLE_RATE], Text=message, TextType=self.config[CONF_TEXT_TYPE], - VoiceId=voice_id + VoiceId=voice_id, ) - return (CONTENT_TYPE_EXTENSIONS[resp.get('ContentType')], - resp.get('AudioStream').read()) + 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 deleted file mode 100644 index 054b1a89ae8af..0000000000000 --- a/homeassistant/components/ambiclimate/.translations/ca.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "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 deleted file mode 100644 index d34169edfc734..0000000000000 --- a/homeassistant/components/ambiclimate/.translations/cs.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "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 deleted file mode 100644 index 68d714cfc1bea..0000000000000 --- a/homeassistant/components/ambiclimate/.translations/de.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "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 deleted file mode 100644 index da1e173b4a816..0000000000000 --- a/homeassistant/components/ambiclimate/.translations/en.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "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/ko.json b/homeassistant/components/ambiclimate/.translations/ko.json deleted file mode 100644 index be337bd3f0edf..0000000000000 --- a/homeassistant/components/ambiclimate/.translations/ko.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "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 deleted file mode 100644 index a6ce441749d6d..0000000000000 --- a/homeassistant/components/ambiclimate/.translations/lb.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "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/ru.json b/homeassistant/components/ambiclimate/.translations/ru.json deleted file mode 100644 index 129579315a29d..0000000000000 --- a/homeassistant/components/ambiclimate/.translations/ru.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "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/zh-Hant.json b/homeassistant/components/ambiclimate/.translations/zh-Hant.json deleted file mode 100644 index 28859cbf5912f..0000000000000 --- a/homeassistant/components/ambiclimate/.translations/zh-Hant.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "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 index 07494ce6cf773..e15f6dea2ec44 100644 --- a/homeassistant/components/ambiclimate/__init__.py +++ b/homeassistant/components/ambiclimate/__init__.py @@ -4,19 +4,20 @@ 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({ + DOMAIN: vol.Schema( + { vol.Required(CONF_CLIENT_ID): cv.string, vol.Required(CONF_CLIENT_SECRET): cv.string, - }) + } + ) }, extra=vol.ALLOW_EXTRA, ) @@ -30,15 +31,16 @@ async def async_setup(hass, config): conf = config[DOMAIN] config_flow.register_flow_implementation( - hass, conf[CONF_CLIENT_ID], - conf[CONF_CLIENT_SECRET]) + 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')) + 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 index d326a94376129..cb19d1329caaf 100644 --- a/homeassistant/components/ambiclimate/climate.py +++ b/homeassistant/components/ambiclimate/climate.py @@ -5,41 +5,44 @@ import ambiclimate import voluptuous as vol -from homeassistant.components.climate import ClimateDevice +from homeassistant.components.climate import ClimateEntity from homeassistant.components.climate.const import ( + HVAC_MODE_HEAT, + HVAC_MODE_OFF, 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.const import ATTR_NAME, ATTR_TEMPERATURE, 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) + +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) +SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE -SEND_COMFORT_FEEDBACK_SCHEMA = vol.Schema({ - vol.Required(ATTR_NAME): cv.string, - vol.Required(ATTR_VALUE): cv.string, -}) +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_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, -}) +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): +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the Ambicliamte device.""" @@ -50,24 +53,27 @@ async def async_setup_entry(hass, entry, async_add_entities): 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) + 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) + token_info = await oauth.refresh_access_token(token_info) except ambiclimate.AmbiclimateOauthError: + token_info = None + + if not token_info: _LOGGER.error("Failed to refresh access token") return - if _token_info: - await store.async_save(token_info) - token_info = _token_info + await store.async_save(token_info) - data_connection = ambiclimate.AmbiclimateConnection(oauth, - token_info=token_info, - websession=websession) + data_connection = ambiclimate.AmbiclimateConnection( + oauth, token_info=token_info, websession=websession + ) if not await data_connection.find_devices(): _LOGGER.error("No devices found") @@ -91,10 +97,12 @@ async def send_comfort_feedback(service): 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) + 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.""" @@ -103,10 +111,9 @@ async def set_comfort_mode(service): if device: await device.set_comfort_mode() - hass.services.async_register(DOMAIN, - SERVICE_COMFORT_MODE, - set_comfort_mode, - schema=SET_COMFORT_MODE_SCHEMA) + 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.""" @@ -115,13 +122,15 @@ async def set_temperature_mode(service): 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) + hass.services.async_register( + DOMAIN, + SERVICE_TEMPERATURE_MODE, + set_temperature_mode, + schema=SET_TEMPERATURE_MODE_SCHEMA, + ) -class AmbiclimateEntity(ClimateDevice): +class AmbiclimateEntity(ClimateEntity): """Representation of a Ambiclimate Thermostat device.""" def __init__(self, heater, store): @@ -144,11 +153,9 @@ def name(self): def device_info(self): """Return the device info.""" return { - 'identifiers': { - (DOMAIN, self.unique_id) - }, - 'name': self.name, - 'manufacturer': 'Ambiclimate', + "identifiers": {(DOMAIN, self.unique_id)}, + "name": self.name, + "manufacturer": "Ambiclimate", } @property @@ -159,7 +166,7 @@ def temperature_unit(self): @property def target_temperature(self): """Return the target temperature.""" - return self._data.get('target_temperature') + return self._data.get("target_temperature") @property def target_temperature_step(self): @@ -169,17 +176,12 @@ def target_temperature_step(self): @property def current_temperature(self): """Return the current temperature.""" - return self._data.get('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' + return self._data.get("humidity") @property def min_temp(self): @@ -197,9 +199,17 @@ def supported_features(self): return SUPPORT_FLAGS @property - def current_operation(self): + def hvac_modes(self): + """Return the list of available hvac operation modes.""" + return [HVAC_MODE_HEAT, HVAC_MODE_OFF] + + @property + def hvac_mode(self): """Return current operation.""" - return STATE_HEAT if self.is_on else STATE_OFF + if self._data.get("power", "").lower() == "on": + return HVAC_MODE_HEAT + + return HVAC_MODE_OFF async def async_set_temperature(self, **kwargs): """Set new target temperature.""" @@ -208,13 +218,13 @@ async def async_set_temperature(self, **kwargs): 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_set_hvac_mode(self, hvac_mode): + """Set new target hvac mode.""" + if hvac_mode == HVAC_MODE_HEAT: + await self._heater.turn_on() + return + if hvac_mode == HVAC_MODE_OFF: + await self._heater.turn_off() async def async_update(self): """Retrieve latest state.""" diff --git a/homeassistant/components/ambiclimate/config_flow.py b/homeassistant/components/ambiclimate/config_flow.py index 9bbdfceb7b035..4996a458a1f34 100644 --- a/homeassistant/components/ambiclimate/config_flow.py +++ b/homeassistant/components/ambiclimate/config_flow.py @@ -7,10 +7,18 @@ 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' +from .const import ( + AUTH_CALLBACK_NAME, + AUTH_CALLBACK_PATH, + CONF_CLIENT_ID, + CONF_CLIENT_SECRET, + DOMAIN, + STORAGE_KEY, + STORAGE_VERSION, +) + +DATA_AMBICLIMATE_IMPL = "ambiclimate_flow_implementation" _LOGGER = logging.getLogger(__name__) @@ -30,7 +38,7 @@ def register_flow_implementation(hass, client_id, client_secret): } -@config_entries.HANDLERS.register('ambiclimate') +@config_entries.HANDLERS.register("ambiclimate") class AmbiclimateFlowHandler(config_entries.ConfigFlow): """Handle a config flow.""" @@ -45,54 +53,52 @@ def __init__(self): 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') + 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 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') + return self.async_abort(reason="already_setup") errors = {} if user_input is not None: - errors['base'] = 'follow_link' + 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()}, + 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') + 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') + return self.async_abort(reason="access_token") config = self.hass.data[DATA_AMBICLIMATE_IMPL].copy() - config['callback_url'] = self._cb_url() + config["callback_url"] = self._cb_url() - return self.async_create_entry( - title="Ambiclimate", - data=config, - ) + return self.async_create_entry(title="Ambiclimate", data=config) async def _get_token_info(self, code): oauth = self._generate_oauth() @@ -116,15 +122,16 @@ def _generate_oauth(self): 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) + 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) + return f"{self.hass.config.api.base_url}{AUTH_CALLBACK_PATH}" async def _get_authorize_url(self): oauth = self._generate_oauth() @@ -140,14 +147,13 @@ class AmbiclimateAuthCallbackView(HomeAssistantView): async def get(self, request): """Receive authorization token.""" - code = request.query.get('code') + code = request.query.get("code") if code is None: return "No code" - hass = request.app['hass'] + hass = request.app["hass"] hass.async_create_task( hass.config_entries.flow.async_init( - DOMAIN, - context={'source': 'code'}, - data=code, - )) + DOMAIN, context={"source": "code"}, data=code + ) + ) return "OK!" diff --git a/homeassistant/components/ambiclimate/const.py b/homeassistant/components/ambiclimate/const.py index b1b9f4c27674f..833fef303f54e 100644 --- a/homeassistant/components/ambiclimate/const.py +++ b/homeassistant/components/ambiclimate/const.py @@ -1,14 +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' +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' +AUTH_CALLBACK_NAME = "api:ambiclimate" +AUTH_CALLBACK_PATH = "/api/ambiclimate" diff --git a/homeassistant/components/ambiclimate/manifest.json b/homeassistant/components/ambiclimate/manifest.json index f3b3450f1639e..151b761dff865 100644 --- a/homeassistant/components/ambiclimate/manifest.json +++ b/homeassistant/components/ambiclimate/manifest.json @@ -1,12 +1,9 @@ { "domain": "ambiclimate", "name": "Ambiclimate", - "documentation": "https://www.home-assistant.io/components/ambiclimate", - "requirements": [ - "ambiclimate==0.1.1" - ], - "dependencies": [], - "codeowners": [ - "@danielhiversen" - ] + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/ambiclimate", + "requirements": ["ambiclimate==0.2.1"], + "dependencies": ["http"], + "codeowners": ["@danielhiversen"] } diff --git a/homeassistant/components/ambiclimate/strings.json b/homeassistant/components/ambiclimate/strings.json index 78386077af28e..50bc8284b7174 100644 --- a/homeassistant/components/ambiclimate/strings.json +++ b/homeassistant/components/ambiclimate/strings.json @@ -1,6 +1,5 @@ { "config": { - "title": "Ambiclimate", "step": { "auth": { "title": "Authenticate Ambiclimate", @@ -20,4 +19,4 @@ "access_token": "Unknown error generating an access token." } } -} \ No newline at end of file +} diff --git a/homeassistant/components/ambiclimate/translations/bg.json b/homeassistant/components/ambiclimate/translations/bg.json new file mode 100644 index 0000000000000..e76a714d5b012 --- /dev/null +++ b/homeassistant/components/ambiclimate/translations/bg.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "access_token": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430 \u043f\u0440\u0438 \u0433\u0435\u043d\u0435\u0440\u0438\u0440\u0430\u043d\u0435 \u043d\u0430 \u043a\u043e\u0434 \u0437\u0430 \u0434\u043e\u0441\u0442\u044a\u043f.", + "already_setup": "\u041f\u0440\u043e\u0444\u0438\u043b\u044a\u0442 \u043d\u0430 Ambiclimate \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d.", + "no_config": "\u0422\u0440\u044f\u0431\u0432\u0430 \u0434\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u0442\u0435 Ambiclimate, \u043f\u0440\u0435\u0434\u0438 \u0434\u0430 \u043c\u043e\u0436\u0435\u0442\u0435 \u0434\u0430 \u0433\u043e \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u043a\u0438\u0440\u0430\u0442\u0435. [\u041c\u043e\u043b\u044f, \u043f\u0440\u043e\u0447\u0435\u0442\u0435\u0442\u0435 \u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u0438\u0442\u0435](https://www.home-assistant.io/components/ambiclimate/)." + }, + "create_entry": { + "default": "\u0423\u0441\u043f\u0435\u0448\u043d\u043e \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u043a\u0438\u0440\u0430\u043d\u0435 \u0441 Ambiclimate." + }, + "error": { + "follow_link": "\u041c\u043e\u043b\u044f, \u043f\u043e\u0441\u043b\u0435\u0434\u0432\u0430\u0439\u0442\u0435 \u0432\u0440\u044a\u0437\u043a\u0430\u0442\u0430 \u0438 \u0441\u0435 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u043a\u0438\u0440\u0430\u0439\u0442\u0435, \u043f\u0440\u0435\u0434\u0438 \u0434\u0430 \u043d\u0430\u0442\u0438\u0441\u043d\u0435\u0442\u0435 \u0418\u0437\u043f\u0440\u0430\u0449\u0430\u043d\u0435", + "no_token": "\u041b\u0438\u043f\u0441\u0432\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u043a\u0438\u0440\u0430\u043d\u0435 \u0441 Ambiclimate" + }, + "step": { + "auth": { + "description": "\u041c\u043e\u043b\u044f, \u043f\u043e\u0441\u043b\u0435\u0434\u0432\u0430\u0439\u0442\u0435 \u0442\u043e\u0437\u0438 [link]({authorization_url}) \u0438 \u0420\u0430\u0437\u0440\u0435\u0448\u0435\u0442\u0435 \u0434\u043e\u0441\u0442\u044a\u043f\u0430 \u0434\u043e \u043f\u0440\u043e\u0444\u0438\u043b\u0430 \u0441\u0438 \u0432 Ambiclimate, \u0441\u043b\u0435\u0434 \u0442\u043e\u0432\u0430 \u0441\u0435 \u0432\u044a\u0440\u043d\u0435\u0442\u0435 \u0438 \u043d\u0430\u0442\u0438\u0441\u043d\u0435\u0442\u0435 \u0418\u0437\u043f\u0440\u0430\u0449\u0430\u043d\u0435 \u043f\u043e-\u0434\u043e\u043b\u0443. \n (\u0423\u0432\u0435\u0440\u0435\u0442\u0435 \u0441\u0435, \u0447\u0435 \u043f\u043e\u0441\u043e\u0447\u0435\u043d\u0438\u044f\u0442 url \u0437\u0430 \u043e\u0431\u0440\u0430\u0442\u043d\u0430 \u043f\u043e\u0432\u0438\u043a\u0432\u0430\u043d\u0435 \u0435 {cb_url})", + "title": "\u0410\u0443\u0442\u0435\u043d\u0442\u0438\u043a\u0438\u0440\u0430\u043d\u0435 \u0441 Ambiclimate" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambiclimate/translations/ca.json b/homeassistant/components/ambiclimate/translations/ca.json new file mode 100644 index 0000000000000..0b8ca963813e3 --- /dev/null +++ b/homeassistant/components/ambiclimate/translations/ca.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "access_token": "S'ha produ\u00eft un error desconegut al generat un token d'acc\u00e9s.", + "already_setup": "El compte d'Ambi Climate est\u00e0 configurat.", + "no_config": "Necessites configurar Ambiclimate 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 Ambiclimate, 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" + } + } + } +} \ 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..da0430346a738 --- /dev/null +++ b/homeassistant/components/ambiclimate/translations/cs.json @@ -0,0 +1,14 @@ +{ + "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" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambiclimate/translations/da.json b/homeassistant/components/ambiclimate/translations/da.json new file mode 100644 index 0000000000000..3229e9f4127a0 --- /dev/null +++ b/homeassistant/components/ambiclimate/translations/da.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "access_token": "Ukendt fejl ved generering af et adgangstoken.", + "already_setup": "Ambiclimate kontoen er konfigureret.", + "no_config": "Du skal konfigurere Ambiclimate f\u00f8r du kan godkende med det. [L\u00e6s venligst vejledningen](https://www.home-assistant.io/components/ambiclimate/)." + }, + "create_entry": { + "default": "Godkendt med Ambiclimate" + }, + "error": { + "follow_link": "F\u00f8lg linket og godkend f\u00f8r du trykker p\u00e5 send", + "no_token": "Ikke godkendt med Ambiclimate" + }, + "step": { + "auth": { + "description": "F\u00f8lg dette [link]({authorization_url}) og Tillad adgang til din Ambiclimate-konto, vend s\u00e5 tilbage og tryk p\u00e5 Indsend nedenfor.\n(Kontroll\u00e9r den angivne callback url er {cb_url})", + "title": "Godkend 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..6fba5772a1008 --- /dev/null +++ b/homeassistant/components/ambiclimate/translations/de.json @@ -0,0 +1,22 @@ +{ + "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" + } + } + } +} \ 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..509b801fa62f6 --- /dev/null +++ b/homeassistant/components/ambiclimate/translations/en.json @@ -0,0 +1,22 @@ +{ + "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" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambiclimate/translations/es-419.json b/homeassistant/components/ambiclimate/translations/es-419.json new file mode 100644 index 0000000000000..55fb20ef45c9a --- /dev/null +++ b/homeassistant/components/ambiclimate/translations/es-419.json @@ -0,0 +1,22 @@ +{ + "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, lea las instrucciones](https://www.home-assistant.io/components/ambiclimate/)." + }, + "create_entry": { + "default": "Autenticaci\u00f3n exitosa con Ambiclimate" + }, + "error": { + "follow_link": "Por favor, siga el enlace y autent\u00edquese antes de presionar Enviar", + "no_token": "No autenticado con Ambiclimate" + }, + "step": { + "auth": { + "description": "Por favor, siga este [link]('authorization_url') y Permitir acceso a su cuenta de Ambiclimate, luego vuelva y presione Enviar a continuaci\u00f3n.\n(Aseg\u00farese de que la url de devoluci\u00f3n de llamada especificada es {cb_url})", + "title": "Autenticaci\u00f3n de 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..01d6643b6349f --- /dev/null +++ b/homeassistant/components/ambiclimate/translations/es.json @@ -0,0 +1,22 @@ +{ + "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" + } + } + } +} \ 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..c16b0c1026617 --- /dev/null +++ b/homeassistant/components/ambiclimate/translations/fr.json @@ -0,0 +1,22 @@ +{ + "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" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambiclimate/translations/it.json b/homeassistant/components/ambiclimate/translations/it.json new file mode 100644 index 0000000000000..2da7a0ee4c8a2 --- /dev/null +++ b/homeassistant/components/ambiclimate/translations/it.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "access_token": "Errore sconosciuto durante la generazione di un token di accesso.", + "already_setup": "L'account Ambiclimate \u00e8 configurato.", + "no_config": "\u00c8 necessario configurare Ambiclimate prima di poter eseguire l'autenticazione con esso. [Leggere le istruzioni] (https://www.home-assistant.io/components/ambiclimate/)." + }, + "create_entry": { + "default": "Autenticato con successo con Ambiclimate" + }, + "error": { + "follow_link": "Si prega di seguire il link e di autenticarsi prima di premere Invia", + "no_token": "Non autenticato con Ambiclimate" + }, + "step": { + "auth": { + "description": "Segui questo [link]({authorization_url}) e Consenti accesso al tuo account Ambiclimate, quindi torna indietro e premi Invia qui sotto. \n (Assicurati che l'URL di richiamata specificato sia {cb_url})", + "title": "Autenticare 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..2717d4c4b798b --- /dev/null +++ b/homeassistant/components/ambiclimate/translations/ko.json @@ -0,0 +1,22 @@ +{ + "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": "Ambiclimate \ub97c \uc778\uc99d\ud558\ub824\uba74 \uba3c\uc800 Ambiclimate \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\ud558\uae30" + } + } + } +} \ 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..3d2b56ba466a0 --- /dev/null +++ b/homeassistant/components/ambiclimate/translations/lb.json @@ -0,0 +1,22 @@ +{ + "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/ambiclimate/)." + }, + "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" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambiclimate/translations/nl.json b/homeassistant/components/ambiclimate/translations/nl.json new file mode 100644 index 0000000000000..17e6dfa9c82af --- /dev/null +++ b/homeassistant/components/ambiclimate/translations/nl.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "access_token": "Onbekende fout bij het genereren van een toegangstoken.", + "already_setup": "Het Ambiclimate-account is geconfigureerd.", + "no_config": "U moet Ambiclimate configureren voordat u zich ermee kunt authenticeren. (Lees de instructies) (https://www.home-assistant.io/components/ambiclimate/)." + }, + "create_entry": { + "default": "Succesvol geverifieerd met Ambiclimate" + }, + "error": { + "follow_link": "Gelieve de link te volgen en te verifi\u00ebren voordat u op Verzenden drukt.", + "no_token": "Niet geverifieerd met Ambiclimate" + }, + "step": { + "auth": { + "description": "Volg deze [link] ( {authorization_url} ) en Toestaan toegang tot uw Ambiclimate-account, kom dan terug en druk hieronder op Verzenden . \n (Zorg ervoor dat de opgegeven callback-URL {cb_url} )", + "title": "Authenticatie Ambiclimate" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambiclimate/translations/nn.json b/homeassistant/components/ambiclimate/translations/nn.json new file mode 100644 index 0000000000000..31e478697d702 --- /dev/null +++ b/homeassistant/components/ambiclimate/translations/nn.json @@ -0,0 +1,3 @@ +{ + "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..e0df96e036167 --- /dev/null +++ b/homeassistant/components/ambiclimate/translations/no.json @@ -0,0 +1,22 @@ +{ + "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 godkjenn 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, kom deretter tilbake og trykk Send nedenfor.\n(Kontroller at den angitte URL-adressen for tilbakeringing er {cb_url})", + "title": "Autensiere 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..69cb3f0e81891 --- /dev/null +++ b/homeassistant/components/ambiclimate/translations/pl.json @@ -0,0 +1,22 @@ +{ + "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, aby m\u00f3c si\u0119 z nim uwierzytelni\u0107. Zapoznaj si\u0119 z [instrukcj\u0105](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 \"Zatwierd\u017a\"", + "no_token": "Nieuwierzytelniony z Ambiclimate" + }, + "step": { + "auth": { + "description": "Kliknij poni\u017cszy [link]({authorization_url}) i Zezw\u00f3l na dost\u0119p do konta Ambiclimate, a nast\u0119pnie wr\u00f3\u0107 i naci\u015bnij Zatwierd\u017a poni\u017cej. \n(Upewnij si\u0119, \u017ce podany adres URL to {cb_url})", + "title": "Uwierzytelnienie 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..1b0ae2a74df90 --- /dev/null +++ b/homeassistant/components/ambiclimate/translations/pt-BR.json @@ -0,0 +1,22 @@ +{ + "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" + } + } + } +} \ 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..a1eefc78575ab --- /dev/null +++ b/homeassistant/components/ambiclimate/translations/ru.json @@ -0,0 +1,22 @@ +{ + "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\u0451\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0430.", + "no_config": "\u041d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e \u0432\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u044c \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0443 Ambiclimate \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\u0451\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" + } + } + } +} \ 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..e293b411226a9 --- /dev/null +++ b/homeassistant/components/ambiclimate/translations/sl.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "access_token": "Neznana napaka pri ustvarjanju \u017eetona za dostop.", + "already_setup": "Ra\u010dun Ambiclimate je konfiguriran.", + "no_config": "Ambiclimate 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" + } + } + } +} \ 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..3ff8ed3da9769 --- /dev/null +++ b/homeassistant/components/ambiclimate/translations/sv.json @@ -0,0 +1,22 @@ +{ + "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" + } + } + } +} \ 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..2efd9f1354970 --- /dev/null +++ b/homeassistant/components/ambiclimate/translations/zh-Hant.json @@ -0,0 +1,22 @@ +{ + "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 \u8a2d\u5099\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" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambient_station/.translations/bg.json b/homeassistant/components/ambient_station/.translations/bg.json deleted file mode 100644 index 2099038f00430..0000000000000 --- a/homeassistant/components/ambient_station/.translations/bg.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "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 deleted file mode 100644 index d3c451f3e3ff8..0000000000000 --- a/homeassistant/components/ambient_station/.translations/ca.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "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 deleted file mode 100644 index ac3d86a995bd0..0000000000000 --- a/homeassistant/components/ambient_station/.translations/da.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "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 deleted file mode 100644 index 1431efbf167b2..0000000000000 --- a/homeassistant/components/ambient_station/.translations/de.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "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 deleted file mode 100644 index 5bd643da55cfa..0000000000000 --- a/homeassistant/components/ambient_station/.translations/en.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "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 deleted file mode 100644 index 268a6ba001e45..0000000000000 --- a/homeassistant/components/ambient_station/.translations/es-419.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "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 deleted file mode 100644 index d4b0075aa6576..0000000000000 --- a/homeassistant/components/ambient_station/.translations/es.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "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 deleted file mode 100644 index b28cb374eacdf..0000000000000 --- a/homeassistant/components/ambient_station/.translations/fr.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "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/hu.json b/homeassistant/components/ambient_station/.translations/hu.json deleted file mode 100644 index 222b512c39f82..0000000000000 --- a/homeassistant/components/ambient_station/.translations/hu.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "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 deleted file mode 100644 index f87c987a79fba..0000000000000 --- a/homeassistant/components/ambient_station/.translations/it.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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 deleted file mode 100644 index 541b8699dc815..0000000000000 --- a/homeassistant/components/ambient_station/.translations/ko.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "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 deleted file mode 100644 index 0f0d60d445863..0000000000000 --- a/homeassistant/components/ambient_station/.translations/lb.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "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 deleted file mode 100644 index a070128eefe41..0000000000000 --- a/homeassistant/components/ambient_station/.translations/nl.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "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 deleted file mode 100644 index 0b9d377718ba2..0000000000000 --- a/homeassistant/components/ambient_station/.translations/no.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "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 deleted file mode 100644 index 2140b4e29fe27..0000000000000 --- a/homeassistant/components/ambient_station/.translations/pl.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "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 deleted file mode 100644 index 92746b29f3d6a..0000000000000 --- a/homeassistant/components/ambient_station/.translations/pt.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "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 deleted file mode 100644 index d1264010b75c1..0000000000000 --- a/homeassistant/components/ambient_station/.translations/ru.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "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 deleted file mode 100644 index 906a6b404c463..0000000000000 --- a/homeassistant/components/ambient_station/.translations/sl.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "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 deleted file mode 100644 index c429d4395030f..0000000000000 --- a/homeassistant/components/ambient_station/.translations/sv.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "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/zh-Hans.json b/homeassistant/components/ambient_station/.translations/zh-Hans.json deleted file mode 100644 index 866c06316f1ca..0000000000000 --- a/homeassistant/components/ambient_station/.translations/zh-Hans.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "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 deleted file mode 100644 index 7e3ed3ef88850..0000000000000 --- a/homeassistant/components/ambient_station/.translations/zh-Hant.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "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 index 2c185c3bc71de..8ee37f4503eb6 100644 --- a/homeassistant/components/ambient_station/__init__.py +++ b/homeassistant/components/ambient_station/__init__.py @@ -1,224 +1,246 @@ """Support for Ambient Weather Station Service.""" +import asyncio import logging +from aioambient import Client +from aioambient.errors import WebsocketError 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) + ATTR_LOCATION, + ATTR_NAME, + CONCENTRATION_PARTS_PER_MILLION, + CONF_API_KEY, + DEGREE, + EVENT_HOMEASSISTANT_STOP, + POWER_WATT, + SPEED_MILES_PER_HOUR, + TEMP_FAHRENHEIT, + UNIT_PERCENTAGE, +) 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) + 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) + ATTR_LAST_DATA, + ATTR_MONITORED_CONDITIONS, + CONF_APP_KEY, + DATA_CLIENT, + DOMAIN, + TYPE_BINARY_SENSOR, + TYPE_SENSOR, +) _LOGGER = logging.getLogger(__name__) -DATA_CONFIG = 'config' +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' + +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_SOLARRADIATION_LX = "solarradiation_lx" +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), + TYPE_24HOURRAININ: ("24 Hr Rain", "in", TYPE_SENSOR, None), + TYPE_BAROMABSIN: ("Abs Pressure", "inHg", TYPE_SENSOR, "pressure"), + TYPE_BAROMRELIN: ("Rel Pressure", "inHg", TYPE_SENSOR, "pressure"), + 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", CONCENTRATION_PARTS_PER_MILLION, TYPE_SENSOR, None), + TYPE_DAILYRAININ: ("Daily Rain", "in", TYPE_SENSOR, None), + TYPE_DEWPOINT: ("Dew Point", TEMP_FAHRENHEIT, TYPE_SENSOR, "temperature"), + TYPE_EVENTRAININ: ("Event Rain", "in", TYPE_SENSOR, None), + TYPE_FEELSLIKE: ("Feels Like", TEMP_FAHRENHEIT, TYPE_SENSOR, "temperature"), + TYPE_HOURLYRAININ: ("Hourly Rain Rate", "in/hr", TYPE_SENSOR, None), + TYPE_HUMIDITY10: ("Humidity 10", UNIT_PERCENTAGE, TYPE_SENSOR, "humidity"), + TYPE_HUMIDITY1: ("Humidity 1", UNIT_PERCENTAGE, TYPE_SENSOR, "humidity"), + TYPE_HUMIDITY2: ("Humidity 2", UNIT_PERCENTAGE, TYPE_SENSOR, "humidity"), + TYPE_HUMIDITY3: ("Humidity 3", UNIT_PERCENTAGE, TYPE_SENSOR, "humidity"), + TYPE_HUMIDITY4: ("Humidity 4", UNIT_PERCENTAGE, TYPE_SENSOR, "humidity"), + TYPE_HUMIDITY5: ("Humidity 5", UNIT_PERCENTAGE, TYPE_SENSOR, "humidity"), + TYPE_HUMIDITY6: ("Humidity 6", UNIT_PERCENTAGE, TYPE_SENSOR, "humidity"), + TYPE_HUMIDITY7: ("Humidity 7", UNIT_PERCENTAGE, TYPE_SENSOR, "humidity"), + TYPE_HUMIDITY8: ("Humidity 8", UNIT_PERCENTAGE, TYPE_SENSOR, "humidity"), + TYPE_HUMIDITY9: ("Humidity 9", UNIT_PERCENTAGE, TYPE_SENSOR, "humidity"), + TYPE_HUMIDITY: ("Humidity", UNIT_PERCENTAGE, TYPE_SENSOR, "humidity"), + TYPE_HUMIDITYIN: ("Humidity In", UNIT_PERCENTAGE, TYPE_SENSOR, "humidity"), + TYPE_LASTRAIN: ("Last Rain", None, TYPE_SENSOR, "timestamp"), + TYPE_MAXDAILYGUST: ("Max Gust", SPEED_MILES_PER_HOUR, 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", UNIT_PERCENTAGE, TYPE_SENSOR, "humidity"), + TYPE_SOILHUM1: ("Soil Humidity 1", UNIT_PERCENTAGE, TYPE_SENSOR, "humidity"), + TYPE_SOILHUM2: ("Soil Humidity 2", UNIT_PERCENTAGE, TYPE_SENSOR, "humidity"), + TYPE_SOILHUM3: ("Soil Humidity 3", UNIT_PERCENTAGE, TYPE_SENSOR, "humidity"), + TYPE_SOILHUM4: ("Soil Humidity 4", UNIT_PERCENTAGE, TYPE_SENSOR, "humidity"), + TYPE_SOILHUM5: ("Soil Humidity 5", UNIT_PERCENTAGE, TYPE_SENSOR, "humidity"), + TYPE_SOILHUM6: ("Soil Humidity 6", UNIT_PERCENTAGE, TYPE_SENSOR, "humidity"), + TYPE_SOILHUM7: ("Soil Humidity 7", UNIT_PERCENTAGE, TYPE_SENSOR, "humidity"), + TYPE_SOILHUM8: ("Soil Humidity 8", UNIT_PERCENTAGE, TYPE_SENSOR, "humidity"), + TYPE_SOILHUM9: ("Soil Humidity 9", UNIT_PERCENTAGE, TYPE_SENSOR, "humidity"), + TYPE_SOILTEMP10F: ("Soil Temp 10", TEMP_FAHRENHEIT, TYPE_SENSOR, "temperature"), + TYPE_SOILTEMP1F: ("Soil Temp 1", TEMP_FAHRENHEIT, TYPE_SENSOR, "temperature"), + TYPE_SOILTEMP2F: ("Soil Temp 2", TEMP_FAHRENHEIT, TYPE_SENSOR, "temperature"), + TYPE_SOILTEMP3F: ("Soil Temp 3", TEMP_FAHRENHEIT, TYPE_SENSOR, "temperature"), + TYPE_SOILTEMP4F: ("Soil Temp 4", TEMP_FAHRENHEIT, TYPE_SENSOR, "temperature"), + TYPE_SOILTEMP5F: ("Soil Temp 5", TEMP_FAHRENHEIT, TYPE_SENSOR, "temperature"), + TYPE_SOILTEMP6F: ("Soil Temp 6", TEMP_FAHRENHEIT, TYPE_SENSOR, "temperature"), + TYPE_SOILTEMP7F: ("Soil Temp 7", TEMP_FAHRENHEIT, TYPE_SENSOR, "temperature"), + TYPE_SOILTEMP8F: ("Soil Temp 8", TEMP_FAHRENHEIT, TYPE_SENSOR, "temperature"), + TYPE_SOILTEMP9F: ("Soil Temp 9", TEMP_FAHRENHEIT, TYPE_SENSOR, "temperature"), + TYPE_SOLARRADIATION: ("Solar Rad", f"{POWER_WATT}/m^2", TYPE_SENSOR, None), + TYPE_SOLARRADIATION_LX: ("Solar Rad (lx)", "lx", TYPE_SENSOR, "illuminance"), + TYPE_TEMP10F: ("Temp 10", TEMP_FAHRENHEIT, TYPE_SENSOR, "temperature"), + TYPE_TEMP1F: ("Temp 1", TEMP_FAHRENHEIT, TYPE_SENSOR, "temperature"), + TYPE_TEMP2F: ("Temp 2", TEMP_FAHRENHEIT, TYPE_SENSOR, "temperature"), + TYPE_TEMP3F: ("Temp 3", TEMP_FAHRENHEIT, TYPE_SENSOR, "temperature"), + TYPE_TEMP4F: ("Temp 4", TEMP_FAHRENHEIT, TYPE_SENSOR, "temperature"), + TYPE_TEMP5F: ("Temp 5", TEMP_FAHRENHEIT, TYPE_SENSOR, "temperature"), + TYPE_TEMP6F: ("Temp 6", TEMP_FAHRENHEIT, TYPE_SENSOR, "temperature"), + TYPE_TEMP7F: ("Temp 7", TEMP_FAHRENHEIT, TYPE_SENSOR, "temperature"), + TYPE_TEMP8F: ("Temp 8", TEMP_FAHRENHEIT, TYPE_SENSOR, "temperature"), + TYPE_TEMP9F: ("Temp 9", TEMP_FAHRENHEIT, TYPE_SENSOR, "temperature"), + TYPE_TEMPF: ("Temp", TEMP_FAHRENHEIT, TYPE_SENSOR, "temperature"), + TYPE_TEMPINF: ("Inside Temp", TEMP_FAHRENHEIT, TYPE_SENSOR, "temperature"), + 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", DEGREE, TYPE_SENSOR, None), + TYPE_WINDDIR_AVG10M: ("Wind Dir Avg 10m", DEGREE, TYPE_SENSOR, None), + TYPE_WINDDIR_AVG2M: ("Wind Dir Avg 2m", SPEED_MILES_PER_HOUR, TYPE_SENSOR, None), + TYPE_WINDGUSTDIR: ("Gust Dir", DEGREE, TYPE_SENSOR, None), + TYPE_WINDGUSTMPH: ("Wind Gust", SPEED_MILES_PER_HOUR, TYPE_SENSOR, None), + TYPE_WINDSPDMPH_AVG10M: ("Wind Avg 10m", SPEED_MILES_PER_HOUR, TYPE_SENSOR, None), + TYPE_WINDSPDMPH_AVG2M: ("Wind Avg 2m", SPEED_MILES_PER_HOUR, TYPE_SENSOR, None), + TYPE_WINDSPEEDMPH: ("Wind Speed", SPEED_MILES_PER_HOUR, 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) +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_APP_KEY): cv.string, + vol.Required(CONF_API_KEY): cv.string, + } + ) + }, + extra=vol.ALLOW_EXTRA, +) async def async_setup(hass, config): @@ -234,44 +256,45 @@ async def async_setup(hass, config): # 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] - })) + 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 + if not config_entry.unique_id: + hass.config_entries.async_update_entry( + config_entry, unique_id=config_entry.data[CONF_APP_KEY] + ) session = aiohttp_client.async_get_clientsession(hass) try: ambient = AmbientStation( - hass, config_entry, + 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, [])) + config_entry.data[CONF_APP_KEY], + session, + ), + ) 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) + _LOGGER.error("Config entry failed: %s", err) raise ConfigEntryNotReady hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_STOP, ambient.client.websocket.disconnect()) + EVENT_HOMEASSISTANT_STOP, ambient.client.websocket.disconnect() + ) return True @@ -281,9 +304,34 @@ async def async_unload_entry(hass, 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) + tasks = [ + hass.config_entries.async_forward_entry_unload(config_entry, component) + for component in ("binary_sensor", "sensor") + ] + + await asyncio.gather(*tasks) + + return True + + +async def async_migrate_entry(hass, config_entry): + """Migrate old entry.""" + version = config_entry.version + + _LOGGER.debug("Migrating from version %s", version) + + # 1 -> 2: Unique ID format changed, so delete and re-import: + if version == 1: + dev_reg = await hass.helpers.device_registry.async_get_registry() + dev_reg.async_clear_config_entry(config_entry) + + en_reg = await hass.helpers.entity_registry.async_get_registry() + en_reg.async_clear_config_entry(config_entry) + + version = config_entry.version = 2 + hass.config_entries.async_update_entry(config_entry) + + _LOGGER.info("Migration to version %s successful", version) return True @@ -291,87 +339,74 @@ async def async_unload_entry(hass, config_entry): class AmbientStation: """Define a class to handle the Ambient websocket.""" - def __init__(self, hass, config_entry, client, monitored_conditions): + def __init__(self, hass, config_entry, client): """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: + async def connect(timestamp=None): + """Connect.""" await self.client.websocket.connect() + + try: + await 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) + self._ws_reconnect_delay = min(2 * self._ws_reconnect_delay, 480) + async_call_later(self._hass, self._ws_reconnect_delay, 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) + _LOGGER.info("Connected to websocket") def on_data(data): """Define a handler to fire when the data is received.""" - mac_address = data['macAddress'] + mac_address = data["macAddress"] if data != self.stations[mac_address][ATTR_LAST_DATA]: - _LOGGER.debug('New data received: %s', 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) + async_dispatcher_send( + self._hass, f"ambient_station_data_update_{mac_address}" + ) def on_disconnect(): """Define a handler to fire when the websocket is disconnected.""" - _LOGGER.info('Disconnected from websocket') + _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: + 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']), + _LOGGER.debug("New station subscription: %s", data) + + # Only create entities based on the data coming through the socket. + # If the user is monitoring brightness (in W/m^2), make sure we also + # add a calculated sensor for the same data measured in lx: + monitored_conditions = [ + k for k in station["lastData"] if k in SENSOR_TYPES + ] + if TYPE_SOLARRADIATION in monitored_conditions: + monitored_conditions.append(TYPE_SOLARRADIATION_LX) + + self.stations[station["macAddress"]] = { + ATTR_LAST_DATA: station["lastData"], + ATTR_LOCATION: station.get("info", {}).get("location"), + ATTR_MONITORED_CONDITIONS: monitored_conditions, + ATTR_NAME: station.get("info", {}).get( + "name", station["macAddress"] + ), } # If the websocket disconnects and reconnects, the on_subscribed @@ -379,10 +414,12 @@ def on_subscribed(data): # 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'): + for component in ("binary_sensor", "sensor"): self._hass.async_create_task( self._hass.config_entries.async_forward_entry_setup( - self._config_entry, component)) + self._config_entry, component + ) + ) self._entry_setup_complete = True self._ws_reconnect_delay = DEFAULT_SOCKET_MIN_RETRY @@ -403,11 +440,11 @@ class AmbientWeatherEntity(Entity): """Define a base Ambient PWS entity.""" def __init__( - self, ambient, mac_address, station_name, sensor_type, - sensor_name): + self, ambient, mac_address, station_name, sensor_type, sensor_name, device_class + ): """Initialize the sensor.""" self._ambient = ambient - self._async_unsub_dispatcher_connect = None + self._device_class = device_class self._mac_address = mac_address self._sensor_name = sensor_name self._sensor_type = sensor_type @@ -417,24 +454,42 @@ def __init__( @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 + # Since the solarradiation_lx sensor is created only if the + # user shows a solarradiation sensor, ensure that the + # solarradiation_lx sensor shows as available if the solarradiation + # sensor is available: + if self._sensor_type == TYPE_SOLARRADIATION_LX: + return ( + self._ambient.stations[self._mac_address][ATTR_LAST_DATA].get( + TYPE_SOLARRADIATION + ) + is not None + ) + return ( + self._ambient.stations[self._mac_address][ATTR_LAST_DATA].get( + self._sensor_type + ) + is not None + ) + + @property + def device_class(self): + """Return the device class.""" + return self._device_class @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', + "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) + return f"{self._station_name}_{self._sensor_name}" @property def should_poll(self): @@ -444,19 +499,26 @@ def should_poll(self): @property def unique_id(self): """Return a unique, unchanging string that represents this sensor.""" - return '{0}_{1}'.format(self._mac_address, self._sensor_name) + return f"{self._mac_address}_{self._sensor_type}" async def async_added_to_hass(self): """Register callbacks.""" + @callback def update(): """Update the state.""" - self.async_schedule_update_ha_state(True) + self.update_from_latest_data() + self.async_write_ha_state() + + self.async_on_remove( + async_dispatcher_connect( + self.hass, f"ambient_station_data_update_{self._mac_address}", update + ) + ) - self._async_unsub_dispatcher_connect = async_dispatcher_connect( - self.hass, TOPIC_UPDATE, update) + self.update_from_latest_data() - async def async_will_remove_from_hass(self): - """Disconnect dispatcher listener when removed.""" - if self._async_unsub_dispatcher_connect: - self._async_unsub_dispatcher_connect() + @callback + def update_from_latest_data(self): + """Update the entity from the latest data.""" + raise NotImplementedError diff --git a/homeassistant/components/ambient_station/binary_sensor.py b/homeassistant/components/ambient_station/binary_sensor.py index 02f7590c307ee..5aba9d637d42c 100644 --- a/homeassistant/components/ambient_station/binary_sensor.py +++ b/homeassistant/components/ambient_station/binary_sensor.py @@ -1,70 +1,85 @@ """Support for Ambient Weather Station binary sensors.""" import logging -from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.const import ATTR_NAME +from homeassistant.core import callback 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 + 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, + ATTR_MONITORED_CONDITIONS, + 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: + for condition in station[ATTR_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)) + ambient, + mac_address, + station[ATTR_NAME], + condition, + name, + device_class, + ) + ) async_add_entities(binary_sensor_list, True) -class AmbientWeatherBinarySensor(AmbientWeatherEntity, BinarySensorDevice): +class AmbientWeatherBinarySensor(AmbientWeatherEntity, BinarySensorEntity): """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): + 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): + @callback + def update_from_latest_data(self): """Fetch new state data for the entity.""" - self._state = self._ambient.stations[ - self._mac_address][ATTR_LAST_DATA].get(self._sensor_type) + 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 index f01bfd8f791d7..c363a2839fbf9 100644 --- a/homeassistant/components/ambient_station/config_flow.py +++ b/homeassistant/components/ambient_station/config_flow.py @@ -1,39 +1,32 @@ """Config flow to configure the Ambient PWS component.""" +from aioambient import Client +from aioambient.errors import AmbientError 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 +from .const import CONF_APP_KEY, DOMAIN # pylint: disable=unused-import -@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): +class AmbientStationFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle an Ambient PWS config flow.""" - VERSION = 1 + VERSION = 2 CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_PUSH + def __init__(self): + """Initialize the config flow.""" + self.data_schema = vol.Schema( + {vol.Required(CONF_API_KEY): str, vol.Required(CONF_APP_KEY): str} + ) + 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, + step_id="user", + data_schema=self.data_schema, errors=errors if errors else {}, ) @@ -43,29 +36,26 @@ async def async_step_import(self, 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'}) + await self.async_set_unique_id(user_input[CONF_APP_KEY]) + self._abort_if_unique_id_configured() session = aiohttp_client.async_get_clientsession(self.hass) - client = Client( - user_input[CONF_API_KEY], user_input[CONF_APP_KEY], session) + 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'}) + return await self._show_form({"base": "invalid_key"}) if not devices: - return await self._show_form({'base': 'no_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) + 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 index 27ec7afefaa4a..3b1990ae83749 100644 --- a/homeassistant/components/ambient_station/const.py +++ b/homeassistant/components/ambient_station/const.py @@ -1,13 +1,12 @@ """Define constants for the Ambient PWS component.""" -DOMAIN = 'ambient_station' +DOMAIN = "ambient_station" -ATTR_LAST_DATA = 'last_data' +ATTR_LAST_DATA = "last_data" +ATTR_MONITORED_CONDITIONS = "monitored_conditions" -CONF_APP_KEY = 'app_key' +CONF_APP_KEY = "app_key" -DATA_CLIENT = 'data_client' +DATA_CLIENT = "data_client" -TOPIC_UPDATE = 'update' - -TYPE_BINARY_SENSOR = 'binary_sensor' -TYPE_SENSOR = 'sensor' +TYPE_BINARY_SENSOR = "binary_sensor" +TYPE_SENSOR = "sensor" diff --git a/homeassistant/components/ambient_station/manifest.json b/homeassistant/components/ambient_station/manifest.json index 11d2ad3668e33..e73190bb5803a 100644 --- a/homeassistant/components/ambient_station/manifest.json +++ b/homeassistant/components/ambient_station/manifest.json @@ -1,12 +1,8 @@ { "domain": "ambient_station", - "name": "Ambient station", - "documentation": "https://www.home-assistant.io/components/ambient_station", - "requirements": [ - "aioambient==0.3.0" - ], - "dependencies": [], - "codeowners": [ - "@bachya" - ] + "name": "Ambient Weather Station", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/ambient_station", + "requirements": ["aioambient==1.1.1"], + "codeowners": ["@bachya"] } diff --git a/homeassistant/components/ambient_station/sensor.py b/homeassistant/components/ambient_station/sensor.py index 9c50d97fb0361..b3b7671536870 100644 --- a/homeassistant/components/ambient_station/sensor.py +++ b/homeassistant/components/ambient_station/sensor.py @@ -2,32 +2,45 @@ import logging from homeassistant.const import ATTR_NAME - -from . import SENSOR_TYPES, AmbientWeatherEntity -from .const import ATTR_LAST_DATA, DATA_CLIENT, DOMAIN, TYPE_SENSOR +from homeassistant.core import callback + +from . import ( + SENSOR_TYPES, + TYPE_SOLARRADIATION, + TYPE_SOLARRADIATION_LX, + AmbientWeatherEntity, +) +from .const import ( + ATTR_LAST_DATA, + ATTR_MONITORED_CONDITIONS, + 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] + for condition in station[ATTR_MONITORED_CONDITIONS]: + name, unit, kind, device_class = SENSOR_TYPES[condition] if kind == TYPE_SENSOR: sensor_list.append( AmbientWeatherSensor( - ambient, mac_address, station[ATTR_NAME], condition, - name, unit)) + ambient, + mac_address, + station[ATTR_NAME], + condition, + name, + device_class, + unit, + ) + ) async_add_entities(sensor_list, True) @@ -36,11 +49,19 @@ class AmbientWeatherSensor(AmbientWeatherEntity): """Define an Ambient sensor.""" def __init__( - self, ambient, mac_address, station_name, sensor_type, sensor_name, - unit): + self, + ambient, + mac_address, + station_name, + sensor_type, + sensor_name, + device_class, + unit, + ): """Initialize the sensor.""" super().__init__( - ambient, mac_address, station_name, sensor_type, sensor_name) + ambient, mac_address, station_name, sensor_type, sensor_name, device_class + ) self._unit = unit @@ -54,7 +75,22 @@ def unit_of_measurement(self): """Return the unit of measurement.""" return self._unit - async def async_update(self): + @callback + def update_from_latest_data(self): """Fetch new state data for the sensor.""" - self._state = self._ambient.stations[ - self._mac_address][ATTR_LAST_DATA].get(self._sensor_type) + if self._sensor_type == TYPE_SOLARRADIATION_LX: + # If the user requests the solarradiation_lx sensor, use the + # value of the solarradiation sensor and apply a very accurate + # approximation of converting sunlight W/m^2 to lx: + w_m2_brightness_val = self._ambient.stations[self._mac_address][ + ATTR_LAST_DATA + ].get(TYPE_SOLARRADIATION) + + if w_m2_brightness_val is None: + self._state = None + else: + self._state = round(float(w_m2_brightness_val) / 0.0079) + else: + 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 index 657b3477bb225..0e49301198c2e 100644 --- a/homeassistant/components/ambient_station/strings.json +++ b/homeassistant/components/ambient_station/strings.json @@ -1,19 +1,15 @@ { "config": { - "title": "Ambient PWS", "step": { "user": { "title": "Fill in your information", - "data": { - "api_key": "API Key", - "app_key": "Application Key" - } + "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" - } + }, + "abort": { "already_configured": "This app key is already in use." } } } diff --git a/homeassistant/components/ambient_station/translations/bg.json b/homeassistant/components/ambient_station/translations/bg.json new file mode 100644 index 0000000000000..173b1c39c5f6d --- /dev/null +++ b/homeassistant/components/ambient_station/translations/bg.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "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" + } + } + } +} \ 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..87934f8e90c54 --- /dev/null +++ b/homeassistant/components/ambient_station/translations/ca.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Aquesta clau d'aplicaci\u00f3 ja est\u00e0 en \u00fas." + }, + "error": { + "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" + } + } + } +} \ 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..b8a4f1ab29ede --- /dev/null +++ b/homeassistant/components/ambient_station/translations/da.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Denne appn\u00f8gle er allerede i brug." + }, + "error": { + "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" + } + } + } +} \ 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..ae4fbe3650579 --- /dev/null +++ b/homeassistant/components/ambient_station/translations/de.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Dieser App-Schl\u00fcssel wird bereits verwendet." + }, + "error": { + "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" + } + } + } +} \ 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..10b7eebc38abd --- /dev/null +++ b/homeassistant/components/ambient_station/translations/en.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "This app key is already in use." + }, + "error": { + "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" + } + } + } +} \ 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..b16c5af9c6207 --- /dev/null +++ b/homeassistant/components/ambient_station/translations/es-419.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Esta clave de aplicaci\u00f3n ya est\u00e1 en uso." + }, + "error": { + "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" + } + } + } +} \ 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..12272affdf1ec --- /dev/null +++ b/homeassistant/components/ambient_station/translations/es.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Esta clave API ya est\u00e1 en uso." + }, + "error": { + "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" + } + } + } +} \ 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..d88e9f9c9f67a --- /dev/null +++ b/homeassistant/components/ambient_station/translations/fr.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Cette cl\u00e9 d'application est d\u00e9j\u00e0 utilis\u00e9e." + }, + "error": { + "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" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambient_station/.translations/he.json b/homeassistant/components/ambient_station/translations/he.json similarity index 100% rename from homeassistant/components/ambient_station/.translations/he.json rename to homeassistant/components/ambient_station/translations/he.json diff --git a/homeassistant/components/ambient_station/translations/hu.json b/homeassistant/components/ambient_station/translations/hu.json new file mode 100644 index 0000000000000..e6b95634827da --- /dev/null +++ b/homeassistant/components/ambient_station/translations/hu.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "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" + } + } + } +} \ 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..1991d053f6cae --- /dev/null +++ b/homeassistant/components/ambient_station/translations/it.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Questa chiave dell'app \u00e8 gi\u00e0 in uso." + }, + "error": { + "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..d4e227656c296 --- /dev/null +++ b/homeassistant/components/ambient_station/translations/ko.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "\uc774 \uc571 \ud0a4\ub294 \uc774\ubbf8 \uc0ac\uc6a9 \uc911\uc785\ub2c8\ub2e4." + }, + "error": { + "invalid_key": "\uc560\ud50c\ub9ac\ucf00\uc774\uc158 \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": "\uc560\ud50c\ub9ac\ucf00\uc774\uc158 \ud0a4" + }, + "title": "\uc0ac\uc6a9\uc790 \uc815\ubcf4 \uc785\ub825\ud558\uae30" + } + } + } +} \ 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..c679b270e80e7 --- /dev/null +++ b/homeassistant/components/ambient_station/translations/lb.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "D\u00ebsen App Schl\u00ebssel g\u00ebtt scho benotzt" + }, + "error": { + "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" + } + } + } +} \ 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..53ad8c9094bff --- /dev/null +++ b/homeassistant/components/ambient_station/translations/nl.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "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" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambient_station/translations/nn.json b/homeassistant/components/ambient_station/translations/nn.json new file mode 100644 index 0000000000000..1774198088a12 --- /dev/null +++ b/homeassistant/components/ambient_station/translations/nn.json @@ -0,0 +1,3 @@ +{ + "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..972d1210f002c --- /dev/null +++ b/homeassistant/components/ambient_station/translations/no.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Denne app n\u00f8kkelen er allerede i bruk." + }, + "error": { + "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" + } + } + } +} \ 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..bb597971b0cda --- /dev/null +++ b/homeassistant/components/ambient_station/translations/pl.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Ten klucz aplikacji jest ju\u017c w u\u017cyciu." + }, + "error": { + "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 dane" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambient_station/translations/pt-BR.json b/homeassistant/components/ambient_station/translations/pt-BR.json new file mode 100644 index 0000000000000..d3ac36bf0e2f8 --- /dev/null +++ b/homeassistant/components/ambient_station/translations/pt-BR.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "invalid_key": "Chave de API e / ou chave de aplicativo inv\u00e1lidas", + "no_devices": "Nenhum dispositivo encontrado na conta" + }, + "step": { + "user": { + "data": { + "api_key": "Chave API", + "app_key": "Chave de aplicativo" + }, + "title": "Preencha suas informa\u00e7\u00f5es" + } + } + } +} \ 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..56c8b5f718a9f --- /dev/null +++ b/homeassistant/components/ambient_station/translations/pt.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "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" + } + } + } +} \ 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..a78bfbe304996 --- /dev/null +++ b/homeassistant/components/ambient_station/translations/ru.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "\u042d\u0442\u043e\u0442 \u043a\u043b\u044e\u0447 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0443\u0436\u0435 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u0442\u0441\u044f." + }, + "error": { + "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\u0451\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" + } + } + } +} \ 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..0fbacf5ccc11e --- /dev/null +++ b/homeassistant/components/ambient_station/translations/sl.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Ta klju\u010d za aplikacijo je \u017ee v uporabi." + }, + "error": { + "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" + } + } + } +} \ 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..7c6be84d59473 --- /dev/null +++ b/homeassistant/components/ambient_station/translations/sv.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "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" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambient_station/.translations/th.json b/homeassistant/components/ambient_station/translations/th.json similarity index 100% rename from homeassistant/components/ambient_station/.translations/th.json rename to homeassistant/components/ambient_station/translations/th.json 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..fc092c7c2475b --- /dev/null +++ b/homeassistant/components/ambient_station/translations/zh-Hans.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "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" + } + } + } +} \ 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..f14d177e899cb --- /dev/null +++ b/homeassistant/components/ambient_station/translations/zh-Hant.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "\u6b64\u61c9\u7528\u7a0b\u5f0f\u5bc6\u9470\u5df2\u88ab\u4f7f\u7528\u3002" + }, + "error": { + "invalid_key": "API \u5bc6\u9470\u53ca/\u6216\u61c9\u7528\u5bc6\u9470\u7121\u6548", + "no_devices": "\u5e33\u865f\u4e2d\u627e\u4e0d\u5230\u4efb\u4f55\u8a2d\u5099" + }, + "step": { + "user": { + "data": { + "api_key": "API \u5bc6\u9470", + "app_key": "\u61c9\u7528\u5bc6\u9470" + }, + "title": "\u586b\u5beb\u8cc7\u8a0a" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/amcrest/__init__.py b/homeassistant/components/amcrest/__init__.py index 6de31caa90e32..be2a6b78f30db 100644 --- a/homeassistant/components/amcrest/__init__.py +++ b/homeassistant/components/amcrest/__init__.py @@ -1,76 +1,76 @@ """Support for Amcrest IP cameras.""" -import logging from datetime import timedelta +import logging +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) + ATTR_ENTITY_ID, + CONF_AUTHENTICATION, + CONF_BINARY_SENSORS, + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_SCAN_INTERVAL, + CONF_SENSORS, + CONF_USERNAME, + ENTITY_MATCH_ALL, + ENTITY_MATCH_NONE, + 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 +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_SENSORS from .camera import CAMERA_SERVICES, STREAM_SOURCE_LIST -from .const import DOMAIN, DATA_AMCREST +from .const import ( + CAMERAS, + COMM_RETRIES, + COMM_TIMEOUT, + DATA_AMCREST, + DEVICES, + DOMAIN, + SENSOR_EVENT_CODE, + SERVICE_EVENT, + SERVICE_UPDATE, +) from .helpers import service_signal -from .sensor import SENSOR_MOTION_DETECTOR, SENSORS -from .switch import SWITCHES +from .sensor import SENSORS _LOGGER = logging.getLogger(__name__) -CONF_RESOLUTION = 'resolution' -CONF_STREAM_SOURCE = 'stream_source' -CONF_FFMPEG_ARGUMENTS = 'ffmpeg_arguments' +CONF_RESOLUTION = "resolution" +CONF_STREAM_SOURCE = "stream_source" +CONF_FFMPEG_ARGUMENTS = "ffmpeg_arguments" +CONF_CONTROL_LIGHT = "control_light" -DEFAULT_NAME = 'Amcrest Camera' +DEFAULT_NAME = "Amcrest Camera" DEFAULT_PORT = 80 -DEFAULT_RESOLUTION = 'high' -DEFAULT_ARGUMENTS = '-pred 1' +DEFAULT_RESOLUTION = "high" +DEFAULT_ARGUMENTS = "-pred 1" +MAX_ERRORS = 5 +RECHECK_INTERVAL = timedelta(minutes=1) -NOTIFICATION_ID = 'amcrest_notification' -NOTIFICATION_TITLE = 'Amcrest Camera Setup' +NOTIFICATION_ID = "amcrest_notification" +NOTIFICATION_TITLE = "Amcrest Camera Setup" -RESOLUTION_LIST = { - 'high': 0, - 'low': 1, -} +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 'sensors' option value '%s' is deprecated, " - "please remove it from your configuration and use " - "the 'binary_sensors' option with value 'motion_detected' " - "instead.", SENSOR_MOTION_DETECTOR) - return sensors - - -def _deprecated_switches(config): - if CONF_SWITCHES in config: - _LOGGER.warning( - "The 'switches' option (with value %s) is deprecated, " - "please remove it from your configuration and use " - "camera services and attributes instead.", - config[CONF_SWITCHES]) - return config +AUTHENTICATION_LIST = {"basic": "basic"} def _has_unique_names(devices): @@ -79,76 +79,168 @@ def _has_unique_names(devices): return devices -AMCREST_SCHEMA = vol.All( - vol.Schema({ +AMCREST_SCHEMA = 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)]), - }), - _deprecated_switches + 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.Unique() + ), + vol.Optional(CONF_SENSORS): vol.All( + cv.ensure_list, [vol.In(SENSORS)], vol.Unique() + ), + vol.Optional(CONF_CONTROL_LIGHT, default=True): cv.boolean, + } ) -CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.All(cv.ensure_list, [AMCREST_SCHEMA], _has_unique_names) -}, extra=vol.ALLOW_EXTRA) +CONFIG_SCHEMA = vol.Schema( + {DOMAIN: vol.All(cv.ensure_list, [AMCREST_SCHEMA], _has_unique_names)}, + extra=vol.ALLOW_EXTRA, +) + + +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._wrap_login_err = False + self._wrap_event_flag = threading.Event() + self._wrap_event_flag.set() + self._unsub_recheck = None + super().__init__( + host, + port, + user, + password, + retries_connection=COMM_RETRIES, + timeout_protocol=COMM_TIMEOUT, + ) + + @property + def available(self): + """Return if camera's API is responding.""" + return self._wrap_errors <= MAX_ERRORS and not self._wrap_login_err + + @property + def available_flag(self): + """Return threading event flag that indicates if camera's API is responding.""" + return self._wrap_event_flag + + def _start_recovery(self): + self._wrap_event_flag.clear() + 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 + ) + + def command(self, *args, **kwargs): + """amcrest.Http.command wrapper to catch errors.""" + try: + ret = super().command(*args, **kwargs) + except LoginError as ex: + with self._wrap_lock: + was_online = self.available + was_login_err = self._wrap_login_err + self._wrap_login_err = True + if not was_login_err: + _LOGGER.error("%s camera offline: Login error: %s", self._wrap_name, ex) + if was_online: + self._start_recovery() + raise + except AmcrestError: + with self._wrap_lock: + was_online = self.available + errs = self._wrap_errors = self._wrap_errors + 1 + offline = not self.available + _LOGGER.debug("%s camera errs: %i", self._wrap_name, errs) + if was_online and offline: + _LOGGER.error("%s camera offline: Too many errors", self._wrap_name) + self._start_recovery() + raise + with self._wrap_lock: + was_offline = not self.available + self._wrap_errors = 0 + self._wrap_login_err = False + if was_offline: + self._unsub_recheck() + self._unsub_recheck = None + _LOGGER.error("%s camera back online", self._wrap_name) + self._wrap_event_flag.set() + 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.""" + _LOGGER.debug("Testing if %s back online", self._wrap_name) + try: + self.current_time + except AmcrestError: + pass + + +def _monitor_events(hass, name, api, event_codes): + event_codes = ",".join(event_codes) + while True: + api.available_flag.wait() + try: + for code, start in api.event_actions(event_codes, retries=5): + signal = service_signal(SERVICE_EVENT, name, code) + _LOGGER.debug("Sending signal: '%s': %s", signal, start) + dispatcher_send(hass, signal, start) + except AmcrestError as error: + _LOGGER.warning( + "Error while processing events from %s camera: %r", name, error + ) + + +def _start_event_monitor(hass, name, api, event_codes): + thread = threading.Thread( + target=_monitor_events, + name=f"Amcrest {name}", + args=(hass, name, api, event_codes), + daemon=True, + ) + thread.start() def setup(hass, config): """Set up the Amcrest IP Camera component.""" - from amcrest import AmcrestCamera, AmcrestError + hass.data.setdefault(DATA_AMCREST, {DEVICES: {}, CAMERAS: []}) - hass.data.setdefault(DATA_AMCREST, {'devices': {}, 'cameras': []}) - devices = config[DOMAIN] - - for device in devices: + for device in config[DOMAIN]: name = device[CONF_NAME] username = device[CONF_USERNAME] password = device[CONF_PASSWORD] - try: - api = AmcrestCamera(device[CONF_HOST], - device[CONF_PORT], - username, - password).camera - # pylint: disable=pointless-statement - # Test camera communications. - api.current_time - - except AmcrestError as ex: - _LOGGER.error("Unable to connect to %s camera: %s", name, 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) - continue + api = AmcrestChecker( + hass, name, device[CONF_HOST], device[CONF_PORT], username, password + ) 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 @@ -157,42 +249,43 @@ def setup(hass, config): else: authentication = None - hass.data[DATA_AMCREST]['devices'][name] = AmcrestDevice( - api, authentication, ffmpeg_arguments, stream_source, - resolution) + 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) + 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) + hass, + BINARY_SENSOR, + DOMAIN, + {CONF_NAME: name, CONF_BINARY_SENSORS: binary_sensors}, + config, + ) + event_codes = [ + BINARY_SENSORS[sensor_type][SENSOR_EVENT_CODE] + for sensor_type in binary_sensors + if BINARY_SENSORS[sensor_type][SENSOR_EVENT_CODE] is not None + ] + if event_codes: + _start_event_monitor(hass, name, api, event_codes) 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) + hass, SENSOR, DOMAIN, {CONF_NAME: name, CONF_SENSORS: sensors}, config + ) - if not hass.data[DATA_AMCREST]['devices']: + 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) + return not user or user.permissions.check_entity(entity_id, POLICY_CONTROL) async def async_extract_from_service(call): if call.context.user_id: @@ -205,20 +298,22 @@ async def async_extract_from_service(call): 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'] + entity_id + for entity_id in hass.data[DATA_AMCREST][CAMERAS] if have_permission(user, entity_id) ] + if call.data.get(ATTR_ENTITY_ID) == ENTITY_MATCH_NONE: + return [] + call_ids = await async_extract_entity_ids(hass, call) entity_ids = [] - for entity_id in hass.data[DATA_AMCREST]['cameras']: + 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 + context=call.context, entity_id=entity_id, permission=POLICY_CONTROL ) entity_ids.append(entity_id) return entity_ids @@ -228,15 +323,10 @@ async def async_service_handler(call): 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 - ) + 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]) + hass.services.register(DOMAIN, service, async_service_handler, params[0]) return True @@ -244,11 +334,19 @@ async def async_service_handler(call): class AmcrestDevice: """Representation of a base Amcrest discovery device.""" - def __init__(self, api, authentication, ffmpeg_arguments, - stream_source, resolution): + 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 index 0eb9e42e707dd..a3057211f2a47 100644 --- a/homeassistant/components/amcrest/binary_sensor.py +++ b/homeassistant/components/amcrest/binary_sensor.py @@ -1,45 +1,86 @@ -"""Suppoort for Amcrest IP camera binary sensors.""" +"""Support for Amcrest IP camera binary sensors.""" from datetime import timedelta import logging -from homeassistant.components.binary_sensor import ( - BinarySensorDevice, DEVICE_CLASS_MOTION) -from homeassistant.const import CONF_NAME, CONF_BINARY_SENSORS +from amcrest import AmcrestError -from .const import BINARY_SENSOR_SCAN_INTERVAL_SECS, DATA_AMCREST +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_CONNECTIVITY, + DEVICE_CLASS_MOTION, + BinarySensorEntity, +) +from homeassistant.const import CONF_BINARY_SENSORS, CONF_NAME +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from .const import ( + BINARY_SENSOR_SCAN_INTERVAL_SECS, + DATA_AMCREST, + DEVICES, + SENSOR_DEVICE_CLASS, + SENSOR_EVENT_CODE, + SENSOR_NAME, + SERVICE_EVENT, + 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_SENSORS = { - 'motion_detected': 'Motion Detected' + BINARY_SENSOR_MOTION_DETECTED: ( + "Motion Detected", + DEVICE_CLASS_MOTION, + "VideoMotion", + ), + BINARY_SENSOR_ONLINE: ("Online", DEVICE_CLASS_CONNECTIVITY, None), } +BINARY_SENSORS = { + k: dict(zip((SENSOR_NAME, SENSOR_DEVICE_CLASS, SENSOR_EVENT_CODE), v)) + for k, v in BINARY_SENSORS.items() +} + +_UPDATE_MSG = "Updating %s binary sensor" -async def async_setup_platform(hass, config, async_add_entities, - discovery_info=None): +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] + 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) + [ + AmcrestBinarySensor(name, device, sensor_type) + for sensor_type in discovery_info[CONF_BINARY_SENSORS] + ], + True, + ) -class AmcrestBinarySensor(BinarySensorDevice): +class AmcrestBinarySensor(BinarySensorEntity): """Binary sensor for Amcrest camera.""" def __init__(self, name, device, sensor_type): """Initialize entity.""" - self._name = '{} {}'.format(name, BINARY_SENSORS[sensor_type]) + self._name = f"{name} {BINARY_SENSORS[sensor_type][SENSOR_NAME]}" + self._signal_name = name self._api = device.api self._sensor_type = sensor_type self._state = None + self._device_class = BINARY_SENSORS[sensor_type][SENSOR_DEVICE_CLASS] + self._event_code = BINARY_SENSORS[sensor_type][SENSOR_EVENT_CODE] + self._unsub_dispatcher = [] + + @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): @@ -54,17 +95,76 @@ def is_on(self): @property def device_class(self): """Return device class.""" - return DEVICE_CLASS_MOTION + 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.""" - from amcrest import AmcrestError - - _LOGGER.debug('Pulling data from %s binary sensor', self._name) + if self._sensor_type == BINARY_SENSOR_ONLINE: + self._update_online() + else: + self._update_others() + + def _update_online(self): + if not (self._api.available or self.is_on): + return + _LOGGER.debug(_UPDATE_MSG, self._name) + if self._api.available: + # Send a command to the camera to test if we can still communicate with it. + # Override of Http.command() in __init__.py will set self._api.available + # accordingly. + try: + self._api.current_time + except AmcrestError: + pass + self._state = self._api.available + + def _update_others(self): + if not self.available: + return + _LOGGER.debug(_UPDATE_MSG, self._name) try: - self._state = self._api.is_motion_detected + self._state = "channels" in self._api.event_channels_happened( + self._event_code + ) except AmcrestError as error: - _LOGGER.error( - 'Could not update %s binary sensor due to error: %s', - self.name, 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) + + @callback + def async_event_received(self, start): + """Update state from received event.""" + _LOGGER.debug(_UPDATE_MSG, self._name) + self._state = start + self.async_write_ha_state() + + async def async_added_to_hass(self): + """Subscribe to signals.""" + self._unsub_dispatcher.append( + async_dispatcher_connect( + self.hass, + service_signal(SERVICE_UPDATE, self._signal_name), + self.async_on_demand_update, + ) + ) + if self._event_code: + self._unsub_dispatcher.append( + async_dispatcher_connect( + self.hass, + service_signal(SERVICE_EVENT, self._signal_name, self._event_code), + self.async_event_received, + ) + ) + + async def async_will_remove_from_hass(self): + """Disconnect from update signal.""" + for unsub_dispatcher in self._unsub_dispatcher: + unsub_dispatcher() diff --git a/homeassistant/components/amcrest/camera.py b/homeassistant/components/amcrest/camera.py index e646c11f2e9d2..4b3640c15433b 100644 --- a/homeassistant/components/amcrest/camera.py +++ b/homeassistant/components/amcrest/camera.py @@ -1,84 +1,133 @@ """Support for Amcrest IP cameras.""" import asyncio +from datetime import timedelta +from functools import partial import logging +from amcrest import AmcrestError +from haffmpeg.camera import CameraMjpeg import voluptuous as vol from homeassistant.components.camera import ( - Camera, CAMERA_SERVICE_SCHEMA, SUPPORT_ON_OFF, SUPPORT_STREAM) + CAMERA_SERVICE_SCHEMA, + SUPPORT_ON_OFF, + SUPPORT_STREAM, + Camera, +) from homeassistant.components.ffmpeg import DATA_FFMPEG -from homeassistant.const import ( - CONF_NAME, STATE_ON, STATE_OFF) +from homeassistant.const import CONF_NAME, STATE_OFF, STATE_ON from homeassistant.helpers.aiohttp_client import ( - async_aiohttp_proxy_stream, async_aiohttp_proxy_web, - async_get_clientsession) + async_aiohttp_proxy_stream, + async_aiohttp_proxy_web, + async_get_clientsession, +) +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect -from .const import CAMERA_WEB_SESSION_TIMEOUT, DATA_AMCREST -from .helpers import service_signal +from .const import ( + CAMERA_WEB_SESSION_TIMEOUT, + CAMERAS, + COMM_TIMEOUT, + DATA_AMCREST, + DEVICES, + SERVICE_UPDATE, + SNAPSHOT_TIMEOUT, +) +from .helpers import log_update_error, service_signal _LOGGER = logging.getLogger(__name__) -STREAM_SOURCE_LIST = [ - 'snapshot', - 'mjpeg', - 'rtsp', +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" + +_SRV_PTZ_CTRL = "ptz_control" +_ATTR_PTZ_TT = "travel_time" +_ATTR_PTZ_MOV = "movement" +_MOV = [ + "zoom_out", + "zoom_in", + "right", + "left", + "up", + "down", + "right_down", + "right_up", + "left_down", + "left_up", ] +_ZOOM_ACTIONS = ["ZoomWide", "ZoomTele"] +_MOVE_1_ACTIONS = ["Right", "Left", "Up", "Down"] +_MOVE_2_ACTIONS = ["RightDown", "RightUp", "LeftDown", "LeftUp"] +_ACTION = _ZOOM_ACTIONS + _MOVE_1_ACTIONS + _MOVE_2_ACTIONS -_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' +_DEFAULT_TT = 0.2 + +_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), -}) +_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)} +) +_SRV_PTZ_SCHEMA = CAMERA_SERVICE_SCHEMA.extend( + { + vol.Required(_ATTR_PTZ_MOV): vol.In(_MOV), + vol.Optional(_ATTR_PTZ_TT, default=_DEFAULT_TT): cv.small_float, + } +) 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', ()), + _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", ()), + _SRV_PTZ_CTRL: ( + _SRV_PTZ_SCHEMA, + "async_ptz_control", + (_ATTR_PTZ_MOV, _ATTR_PTZ_TT), + ), } _BOOL_TO_STATE = {True: STATE_ON, False: STATE_OFF} -async def async_setup_platform(hass, config, async_add_entities, - discovery_info=None): +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) + device = hass.data[DATA_AMCREST][DEVICES][name] + async_add_entities([AmcrestCam(name, device, hass.data[DATA_FFMPEG])], True) + + +class CannotSnapshot(Exception): + """Conditions are not valid for taking a snapshot.""" class AmcrestCam(Camera): @@ -94,70 +143,119 @@ def __init__(self, name, device, ffmpeg): 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._snapshot_lock = asyncio.Lock() + self._rtsp_url = None + self._snapshot_task = None self._unsub_dispatcher = [] + self._update_succeeded = False + + def _check_snapshot_ok(self): + available = self.available + if not available or not self.is_on: + _LOGGER.warning( + "Attempt to take snapshot when %s camera is %s", + self.name, + "offline" if not available else "off", + ) + raise CannotSnapshot + + async def _async_get_image(self): + try: + # Send the request to snap a picture and return raw jpg data + # Snapshot command needs a much longer read timeout than other commands. + return await self.hass.async_add_executor_job( + partial( + self._api.snapshot, + timeout=(COMM_TIMEOUT, SNAPSHOT_TIMEOUT), + stream=False, + ) + ) + except AmcrestError as error: + log_update_error(_LOGGER, "get image from", self.name, "camera", error) + return None + finally: + self._snapshot_task = None async def async_camera_image(self): """Return a still image response from the camera.""" - from amcrest import AmcrestError - - if not self.is_on: - _LOGGER.error( - 'Attempt to take snaphot when %s camera is off', self.name) + _LOGGER.debug("Take snapshot from %s", self._name) + try: + # Amcrest cameras only support one snapshot command at a time. + # Hence need to wait if a previous snapshot has not yet finished. + # Also need to check that camera is online and turned on before each wait + # and before initiating shapshot. + while self._snapshot_task: + self._check_snapshot_ok() + _LOGGER.debug("Waiting for previous snapshot from %s ...", self._name) + await self._snapshot_task + self._check_snapshot_ok() + # Run snapshot command in separate Task that can't be cancelled so + # 1) it's not possible to send another snapshot command while camera is + # still working on a previous one, and + # 2) someone will be around to catch any exceptions. + self._snapshot_task = self.hass.async_create_task(self._async_get_image()) + return await asyncio.shield(self._snapshot_task) + except CannotSnapshot: 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, self._resolution) - return response.data - except AmcrestError as error: - _LOGGER.error( - 'Could not get image from %s camera due to error: %s', - self.name, 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': + if self._stream_source == "snapshot": return await super().handle_async_mjpeg_stream(request) - if self._stream_source == 'mjpeg': + 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) + streaming_url, auth=self._token, timeout=CAMERA_WEB_SESSION_TIMEOUT + ) - return await async_aiohttp_proxy_web( - self.hass, request, stream_coro) + return await async_aiohttp_proxy_web(self.hass, request, stream_coro) # streaming via ffmpeg - from haffmpeg.camera import CameraMjpeg - streaming_url = self._api.rtsp_url(typeno=self._resolution) + 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) + 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) + 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.""" @@ -168,14 +266,20 @@ 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) + 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) + 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.""" @@ -191,7 +295,7 @@ def is_recording(self): @property def brand(self): """Return the camera brand.""" - return 'Amcrest' + return self._brand @property def motion_detection_enabled(self): @@ -203,10 +307,9 @@ def model(self): """Return the camera model.""" return self._model - @property - def stream_source(self): + async def stream_source(self): """Return the source of the stream.""" - return self._api.rtsp_url(typeno=self._resolution) + return self._rtsp_url @property def is_on(self): @@ -215,47 +318,67 @@ def is_on(self): # 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._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, self.entity_id), - getattr(self, params[1]))) - self.hass.data[DATA_AMCREST]['cameras'].append(self.entity_id) + 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) + 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.""" - from amcrest import AmcrestError - - _LOGGER.debug('Pulling data from %s camera', self.name) - if self._model is None: - try: - self._model = self._api.device_type.split('=')[-1].strip() - except AmcrestError as error: - _LOGGER.error( - 'Could not get %s camera model due to error: %s', - self.name, error) - self._model = '' + 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._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._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: - _LOGGER.error( - 'Could not get %s camera attributes due to error: %s', - self.name, error) + log_update_error(_LOGGER, "get", self.name, "camera attributes", error) + self._update_succeeded = False + else: + self._update_succeeded = True # Other Camera method overrides @@ -295,13 +418,11 @@ async def async_disable_audio(self): 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) + 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) + 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.""" @@ -319,12 +440,33 @@ async def async_stop_tour(self): """Call the job and stop camera tour.""" await self.hass.async_add_executor_job(self._start_tour, False) + async def async_ptz_control(self, movement, travel_time): + """Move or zoom camera in specified direction.""" + code = _ACTION[_MOV.index(movement)] + + kwargs = {"code": code, "arg1": 0, "arg2": 0, "arg3": 0} + if code in _MOVE_1_ACTIONS: + kwargs["arg2"] = 1 + elif code in _MOVE_2_ACTIONS: + kwargs["arg1"] = kwargs["arg2"] = 1 + + try: + await self.hass.async_add_executor_job( + partial(self._api.ptz_control_command, action="start", **kwargs) + ) + await asyncio.sleep(travel_time) + await self.hass.async_add_executor_job( + partial(self._api.ptz_control_command, action="stop", **kwargs) + ) + except AmcrestError as error: + log_update_error( + _LOGGER, "move", self.name, f"camera PTZ {movement}", error + ) + # Methods to send commands to Amcrest camera and handle errors def _enable_video_stream(self, enable): """Enable or disable camera video stream.""" - from amcrest import AmcrestError - # 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. @@ -333,109 +475,132 @@ def _enable_video_stream(self, enable): try: self._api.video_enabled = enable except AmcrestError as error: - _LOGGER.error( - 'Could not %s %s camera video stream due to error: %s', - 'enable' if enable else 'disable', self.name, 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.""" - from amcrest import AmcrestError - # 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} + rec_mode = {"Automatic": 0, "Manual": 1} try: - self._api.record_mode = rec_mode[ - 'Manual' if enable else 'Automatic'] + self._api.record_mode = rec_mode["Manual" if enable else "Automatic"] except AmcrestError as error: - _LOGGER.error( - 'Could not %s %s camera recording due to error: %s', - 'enable' if enable else 'disable', self.name, 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.""" - from amcrest import AmcrestError - try: self._api.motion_detection = str(enable).lower() except AmcrestError as error: - _LOGGER.error( - 'Could not %s %s camera motion detection due to error: %s', - 'enable' if enable else 'disable', self.name, 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.""" - from amcrest import AmcrestError - try: self._api.audio_enabled = enable except AmcrestError as error: - _LOGGER.error( - 'Could not %s %s camera audio stream due to error: %s', - 'enable' if enable else 'disable', self.name, 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( + f"configManager.cgi?action=setConfig&LightGlobal[0].Enable={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.""" - from amcrest import AmcrestError - try: self._api.motion_recording = str(enable).lower() except AmcrestError as error: - _LOGGER.error( - 'Could not %s %s camera motion recording due to error: %s', - 'enable' if enable else 'disable', self.name, 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.""" - from amcrest import AmcrestError - try: - self._api.go_to_preset( - action='start', preset_point_number=preset) + self._api.go_to_preset(action="start", preset_point_number=preset) except AmcrestError as error: - _LOGGER.error( - 'Could not move %s camera to preset %i due to error: %s', - self.name, preset, error) + log_update_error( + _LOGGER, "move", self.name, f"camera to preset {preset}", error + ) def _set_color_bw(self, cbw): """Set camera color mode.""" - from amcrest import AmcrestError - try: self._api.day_night_color = _CBW.index(cbw) except AmcrestError as error: - _LOGGER.error( - 'Could not set %s camera color mode to %s due to error: %s', - self.name, cbw, error) + log_update_error( + _LOGGER, "set", self.name, f"camera color mode to {cbw}", error + ) else: self._color_bw = cbw self.schedule_update_ha_state() def _start_tour(self, start): """Start camera tour.""" - from amcrest import AmcrestError - try: self._api.tour(start=start) except AmcrestError as error: - _LOGGER.error( - 'Could not %s %s camera tour due to error: %s', - 'start' if start else 'stop', self.name, 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 index a0230937e95b8..da7e5456786fa 100644 --- a/homeassistant/components/amcrest/const.py +++ b/homeassistant/components/amcrest/const.py @@ -1,7 +1,19 @@ """Constants for amcrest component.""" -DOMAIN = 'amcrest' +DOMAIN = "amcrest" DATA_AMCREST = DOMAIN +CAMERAS = "cameras" +DEVICES = "devices" -BINARY_SENSOR_SCAN_INTERVAL_SECS = 5 +BINARY_SENSOR_SCAN_INTERVAL_SECS = 60 CAMERA_WEB_SESSION_TIMEOUT = 10 +COMM_RETRIES = 1 +COMM_TIMEOUT = 6.05 SENSOR_SCAN_INTERVAL_SECS = 10 +SNAPSHOT_TIMEOUT = 20 + +SERVICE_EVENT = "event" +SERVICE_UPDATE = "update" + +SENSOR_DEVICE_CLASS = "class" +SENSOR_EVENT_CODE = "code" +SENSOR_NAME = "name" diff --git a/homeassistant/components/amcrest/helpers.py b/homeassistant/components/amcrest/helpers.py index 270c969a6cc9f..884d39abd7025 100644 --- a/homeassistant/components/amcrest/helpers.py +++ b/homeassistant/components/amcrest/helpers.py @@ -2,9 +2,17 @@ from .const import DOMAIN -def service_signal(service, entity_id=None): - """Encode service and entity_id into signal.""" - signal = '{}_{}'.format(DOMAIN, service) - if entity_id: - signal += '_{}'.format(entity_id.replace('.', '_')) - return signal +def service_signal(service, *args): + """Encode signal.""" + return "_".join([DOMAIN, service, *args]) + + +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 index a2eb8c24e212f..0b6fbbdc09a02 100644 --- a/homeassistant/components/amcrest/manifest.json +++ b/homeassistant/components/amcrest/manifest.json @@ -1,12 +1,8 @@ { "domain": "amcrest", "name": "Amcrest", - "documentation": "https://www.home-assistant.io/components/amcrest", - "requirements": [ - "amcrest==1.4.0" - ], - "dependencies": [ - "ffmpeg" - ], - "codeowners": [] + "documentation": "https://www.home-assistant.io/integrations/amcrest", + "requirements": ["amcrest==1.7.0"], + "dependencies": ["ffmpeg"], + "codeowners": ["@pnbruckner"] } diff --git a/homeassistant/components/amcrest/sensor.py b/homeassistant/components/amcrest/sensor.py index 718d08358c421..18abf28bdd5cd 100644 --- a/homeassistant/components/amcrest/sensor.py +++ b/homeassistant/components/amcrest/sensor.py @@ -1,37 +1,43 @@ -"""Suppoort for Amcrest IP camera sensors.""" +"""Support for Amcrest IP camera sensors.""" from datetime import timedelta import logging -from homeassistant.const import CONF_NAME, CONF_SENSORS +from amcrest import AmcrestError + +from homeassistant.const import CONF_NAME, CONF_SENSORS, UNIT_PERCENTAGE +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity -from .const import DATA_AMCREST, SENSOR_SCAN_INTERVAL_SECS +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_PTZ_PRESET = "ptz_preset" +SENSOR_SDCARD = "sdcard" # Sensor types are defined like: Name, units, icon -SENSOR_MOTION_DETECTOR = 'motion_detector' SENSORS = { - SENSOR_MOTION_DETECTOR: ['Motion Detected', None, 'mdi:run'], - 'sdcard': ['SD Used', '%', 'mdi:sd'], - 'ptz_preset': ['PTZ Preset', None, 'mdi:camera-iris'], + SENSOR_PTZ_PRESET: ["PTZ Preset", None, "mdi:camera-iris"], + SENSOR_SDCARD: ["SD Used", UNIT_PERCENTAGE, "mdi:sd"], } -async def async_setup_platform( - hass, config, async_add_entities, discovery_info=None): +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] + device = hass.data[DATA_AMCREST][DEVICES][name] async_add_entities( - [AmcrestSensor(name, device, sensor_type) - for sensor_type in discovery_info[CONF_SENSORS]], - True) + [ + AmcrestSensor(name, device, sensor_type) + for sensor_type in discovery_info[CONF_SENSORS] + ], + True, + ) class AmcrestSensor(Entity): @@ -39,13 +45,15 @@ class AmcrestSensor(Entity): def __init__(self, name, device, sensor_type): """Initialize a sensor for Amcrest camera.""" - self._name = '{} {}'.format(name, SENSORS[sensor_type][0]) + self._name = f"{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): @@ -72,28 +80,56 @@ 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.""" - _LOGGER.debug("Pulling data from %s sensor.", self._name) - - if self._sensor_type == 'motion_detector': - self._state = self._api.is_motion_detected - self._attrs['Record Mode'] = self._api.record_mode - - elif self._sensor_type == 'ptz_preset': - self._state = self._api.ptz_presets_count - - elif self._sensor_type == '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'] + if not self.available: + return + _LOGGER.debug("Updating %s sensor", self._name) + + try: + if 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" + ] = f"{storage['total'][0]:.2f} {storage['total'][1]}" + except ValueError: + self._attrs[ + "Total" + ] = f"{storage['total'][0]} {storage['total'][1]}" + try: + self._attrs[ + "Used" + ] = f"{storage['used'][0]:.2f} {storage['used'][1]}" + except ValueError: + self._attrs["Used"] = f"{storage['used'][0]} {storage['used'][1]}" + try: + self._state = f"{storage['used_percent']:.2f}" + 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 index d6e7a02a4f96b..10865586b6dbf 100644 --- a/homeassistant/components/amcrest/services.yaml +++ b/homeassistant/components/amcrest/services.yaml @@ -3,49 +3,49 @@ enable_recording: fields: entity_id: description: "Name(s) of the cameras, or 'all' for all cameras." - example: 'camera.house_front' + 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' + 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' + 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' + 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' + 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' + 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' + example: "camera.house_front" preset: description: Preset number, starting from 1. example: 1 @@ -55,7 +55,7 @@ set_color_bw: fields: entity_id: description: "Name(s) of the cameras, or 'all' for all cameras." - example: 'camera.house_front' + example: "camera.house_front" color_bw: description: Color mode, one of 'auto', 'color' or 'bw'. example: auto @@ -65,11 +65,24 @@ start_tour: fields: entity_id: description: "Name(s) of the cameras, or 'all' for all cameras." - example: 'camera.house_front' + 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' + example: "camera.house_front" + +ptz_control: + description: Move (Pan/Tilt) and/or Zoom a PTZ camera + fields: + entity_id: + description: "Name of the camera, or 'all' for all cameras." + example: "camera.house_front" + movement: + description: "up, down, right, left, right_up, right_down, left_up, left_down, zoom_in, zoom_out" + example: "right" + travel_time: + description: "(optional) Travel time in fractional seconds: from 0 to 1. Default: .2" + example: ".5" diff --git a/homeassistant/components/amcrest/switch.py b/homeassistant/components/amcrest/switch.py deleted file mode 100644 index 5989d4daf1e38..0000000000000 --- a/homeassistant/components/amcrest/switch.py +++ /dev/null @@ -1,81 +0,0 @@ -"""Support for toggling Amcrest IP camera settings.""" -import logging - -from homeassistant.const import CONF_NAME, CONF_SWITCHES -from homeassistant.helpers.entity import ToggleEntity - -from .const import DATA_AMCREST - -_LOGGER = logging.getLogger(__name__) - -# 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._api = device.api - self._setting = setting - self._state = False - self._icon = SWITCHES[setting][1] - - @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 self._setting == 'motion_detection': - self._api.motion_detection = 'true' - elif self._setting == 'motion_recording': - self._api.motion_recording = 'true' - - def turn_off(self, **kwargs): - """Turn setting off.""" - if self._setting == 'motion_detection': - self._api.motion_detection = 'false' - elif self._setting == 'motion_recording': - self._api.motion_recording = 'false' - - def update(self): - """Update setting state.""" - _LOGGER.debug("Polling state for setting: %s ", self._name) - - 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 - - @property - def icon(self): - """Return the icon for the switch.""" - return self._icon diff --git a/homeassistant/components/ampio/air_quality.py b/homeassistant/components/ampio/air_quality.py index 339f490bae54f..c925909a9a81b 100644 --- a/homeassistant/components/ampio/air_quality.py +++ b/homeassistant/components/ampio/air_quality.py @@ -2,10 +2,10 @@ from datetime import timedelta import logging +from asmog import AmpioSmog import voluptuous as vol -from homeassistant.components.air_quality import ( - PLATFORM_SCHEMA, AirQualityEntity) +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 @@ -13,20 +13,17 @@ _LOGGER = logging.getLogger(__name__) -ATTRIBUTION = 'Data provided by Ampio' -CONF_STATION_ID = 'station_id' +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, -}) +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): +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] @@ -60,7 +57,7 @@ def name(self): @property def unique_id(self): """Return unique_name.""" - return "ampio_smog_{}".format(self._station_id) + return f"ampio_smog_{self._station_id}" @property def particulate_matter_2_5(self): diff --git a/homeassistant/components/ampio/manifest.json b/homeassistant/components/ampio/manifest.json index d20b10b2d1509..c92837d2417a5 100644 --- a/homeassistant/components/ampio/manifest.json +++ b/homeassistant/components/ampio/manifest.json @@ -1,10 +1,7 @@ { "domain": "ampio", - "name": "Ampio", - "documentation": "https://www.home-assistant.io/components/ampio", - "requirements": [ - "asmog==0.0.6" - ], - "dependencies": [], + "name": "Ampio Smart Smog System", + "documentation": "https://www.home-assistant.io/integrations/ampio", + "requirements": ["asmog==0.0.6"], "codeowners": [] } diff --git a/homeassistant/components/android_ip_webcam/__init__.py b/homeassistant/components/android_ip_webcam/__init__.py index dfb6d143e9ae9..333da7dceea8b 100644 --- a/homeassistant/components/android_ip_webcam/__init__.py +++ b/homeassistant/components/android_ip_webcam/__init__.py @@ -1,151 +1,193 @@ """Support for Android IP Webcam.""" import asyncio -import logging from datetime import timedelta +import logging +from pydroid_ipcam import PyDroidIPCam import voluptuous as vol -from homeassistant.core import callback +from homeassistant.components.mjpeg.camera import CONF_MJPEG_URL, CONF_STILL_IMAGE_URL 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 + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_PLATFORM, + CONF_PORT, + CONF_SCAN_INTERVAL, + CONF_SENSORS, + CONF_SWITCHES, + CONF_TIMEOUT, + CONF_USERNAME, +) +from homeassistant.core import callback from homeassistant.helpers import discovery +from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import ( - async_dispatcher_send, async_dispatcher_connect) + async_dispatcher_connect, + async_dispatcher_send, +) from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.util.dt import utcnow -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' +ATTR_AUD_CONNS = "Audio Connections" +ATTR_HOST = "host" +ATTR_VID_CONNS = "Video Connections" -CONF_MOTION_SENSOR = 'motion_sensor' +CONF_MOTION_SENSOR = "motion_sensor" -DATA_IP_WEBCAM = 'android_ip_webcam' -DEFAULT_NAME = 'IP Webcam' +DATA_IP_WEBCAM = "android_ip_webcam" +DEFAULT_NAME = "IP Webcam" DEFAULT_PORT = 8080 DEFAULT_TIMEOUT = 10 -DOMAIN = 'android_ip_webcam' +DOMAIN = "android_ip_webcam" SCAN_INTERVAL = timedelta(seconds=10) -SIGNAL_UPDATE_DATA = 'android_ip_webcam_update' +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' + "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' + "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) +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) @@ -163,30 +205,33 @@ async def async_setup_ipcamera(cam_config): # Init ip webcam cam = PyDroidIPCam( - hass.loop, websession, host, cam_config[CONF_PORT], - username=username, password=password, - timeout=cam_config[CONF_TIMEOUT] + 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] + 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']) + 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 + 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) + async_track_point_in_utc_time(hass, async_update_data, utcnow() + interval) await async_update_data(None) @@ -194,46 +239,54 @@ async def async_update_data(now): webcams[host] = cam mjpeg_camera = { - CONF_PLATFORM: 'mjpeg', + 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 - }) + mjpeg_camera.update({CONF_USERNAME: username, CONF_PASSWORD: password}) - hass.async_create_task(discovery.async_load_platform( - hass, 'camera', 'mjpeg', mjpeg_camera, config)) + 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)) + 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)) + 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)) + 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, loop=hass.loop) + await asyncio.wait(tasks) return True @@ -248,6 +301,7 @@ def __init__(self, host, ipcam): async def async_added_to_hass(self): """Register update dispatcher.""" + @callback def async_ipcam_update(host): """Update callback.""" @@ -255,8 +309,9 @@ def async_ipcam_update(host): return self.async_schedule_update_ha_state(True) - async_dispatcher_connect( - self.hass, SIGNAL_UPDATE_DATA, async_ipcam_update) + self.async_on_remove( + async_dispatcher_connect(self.hass, SIGNAL_UPDATE_DATA, async_ipcam_update) + ) @property def should_poll(self): @@ -275,9 +330,7 @@ def device_state_attributes(self): 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') + 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 index dbe50d8186245..1438456571848 100644 --- a/homeassistant/components/android_ip_webcam/binary_sensor.py +++ b/homeassistant/components/android_ip_webcam/binary_sensor.py @@ -1,11 +1,10 @@ """Support for Android IP Webcam binary sensors.""" -from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.components.binary_sensor import BinarySensorEntity 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): +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 @@ -14,11 +13,10 @@ async def async_setup_platform( name = discovery_info[CONF_NAME] ipcam = hass.data[DATA_IP_WEBCAM][host] - async_add_entities( - [IPWebcamBinarySensor(name, host, ipcam, 'motion_active')], True) + async_add_entities([IPWebcamBinarySensor(name, host, ipcam, "motion_active")], True) -class IPWebcamBinarySensor(AndroidIPCamEntity, BinarySensorDevice): +class IPWebcamBinarySensor(AndroidIPCamEntity, BinarySensorEntity): """Representation of an IP Webcam binary sensor.""" def __init__(self, name, host, ipcam, sensor): @@ -27,7 +25,7 @@ def __init__(self, name, host, ipcam, sensor): self._sensor = sensor self._mapped_name = KEY_MAP.get(self._sensor, self._sensor) - self._name = '{} {}'.format(name, self._mapped_name) + self._name = f"{name} {self._mapped_name}" self._state = None self._unit = None @@ -49,4 +47,4 @@ async def async_update(self): @property def device_class(self): """Return the class of this device, from component DEVICE_CLASSES.""" - return 'motion' + return "motion" diff --git a/homeassistant/components/android_ip_webcam/manifest.json b/homeassistant/components/android_ip_webcam/manifest.json index 28909f7e05337..60fe72040341f 100644 --- a/homeassistant/components/android_ip_webcam/manifest.json +++ b/homeassistant/components/android_ip_webcam/manifest.json @@ -1,10 +1,7 @@ { "domain": "android_ip_webcam", - "name": "Android ip webcam", - "documentation": "https://www.home-assistant.io/components/android_ip_webcam", - "requirements": [ - "pydroid-ipcam==0.8" - ], - "dependencies": [], + "name": "Android IP Webcam", + "documentation": "https://www.home-assistant.io/integrations/android_ip_webcam", + "requirements": ["pydroid-ipcam==0.8"], "codeowners": [] } diff --git a/homeassistant/components/android_ip_webcam/sensor.py b/homeassistant/components/android_ip_webcam/sensor.py index 9748b6ba548b2..05c1fe16c61c2 100644 --- a/homeassistant/components/android_ip_webcam/sensor.py +++ b/homeassistant/components/android_ip_webcam/sensor.py @@ -2,12 +2,17 @@ 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) + 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): +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 @@ -34,7 +39,7 @@ def __init__(self, name, host, ipcam, sensor): self._sensor = sensor self._mapped_name = KEY_MAP.get(self._sensor, self._sensor) - self._name = '{} {}'.format(name, self._mapped_name) + self._name = f"{name} {self._mapped_name}" self._state = None self._unit = None @@ -55,17 +60,17 @@ def state(self): async def async_update(self): """Retrieve latest state.""" - if self._sensor in ('audio_connections', 'video_connections'): + 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' + 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: + 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') + 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 index e894913f5a468..bdbb37e76613e 100644 --- a/homeassistant/components/android_ip_webcam/switch.py +++ b/homeassistant/components/android_ip_webcam/switch.py @@ -1,13 +1,18 @@ """Support for Android IP Webcam settings.""" -from homeassistant.components.switch import SwitchDevice +from homeassistant.components.switch import SwitchEntity from . import ( - CONF_HOST, CONF_NAME, CONF_SWITCHES, DATA_IP_WEBCAM, ICON_MAP, KEY_MAP, - AndroidIPCamEntity) + 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): +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 @@ -25,7 +30,7 @@ async def async_setup_platform( async_add_entities(all_switches, True) -class IPWebcamSettingsSwitch(AndroidIPCamEntity, SwitchDevice): +class IPWebcamSettingsSwitch(AndroidIPCamEntity, SwitchEntity): """An abstract class for an IP Webcam setting.""" def __init__(self, name, host, ipcam, setting): @@ -34,7 +39,7 @@ def __init__(self, name, host, ipcam, setting): self._setting = setting self._mapped_name = KEY_MAP.get(self._setting, self._setting) - self._name = '{} {}'.format(name, self._mapped_name) + self._name = f"{name} {self._mapped_name}" self._state = False @property @@ -53,31 +58,31 @@ def is_on(self): async def async_turn_on(self, **kwargs): """Turn device on.""" - if self._setting == 'torch': + if self._setting == "torch": await self._ipcam.torch(activate=True) - elif self._setting == 'focus': + elif self._setting == "focus": await self._ipcam.focus(activate=True) - elif self._setting == 'video_recording': + 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() + self.async_write_ha_state() async def async_turn_off(self, **kwargs): """Turn device off.""" - if self._setting == 'torch': + if self._setting == "torch": await self._ipcam.torch(activate=False) - elif self._setting == 'focus': + elif self._setting == "focus": await self._ipcam.focus(activate=False) - elif self._setting == 'video_recording': + 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() + self.async_write_ha_state() @property def icon(self): """Return the icon for the switch.""" - return ICON_MAP.get(self._setting, 'mdi:flash') + return ICON_MAP.get(self._setting, "mdi:flash") diff --git a/homeassistant/components/androidtv/manifest.json b/homeassistant/components/androidtv/manifest.json index 841ad29978582..fb74ab9ab2efb 100644 --- a/homeassistant/components/androidtv/manifest.json +++ b/homeassistant/components/androidtv/manifest.json @@ -1,10 +1,11 @@ { "domain": "androidtv", - "name": "Androidtv", - "documentation": "https://www.home-assistant.io/components/androidtv", + "name": "Android TV", + "documentation": "https://www.home-assistant.io/integrations/androidtv", "requirements": [ - "androidtv==0.0.15" + "adb-shell==0.1.3", + "androidtv==0.0.41", + "pure-python-adb==0.2.2.dev0" ], - "dependencies": [], - "codeowners": [] + "codeowners": ["@JeffLIrion"] } diff --git a/homeassistant/components/androidtv/media_player.py b/homeassistant/components/androidtv/media_player.py index 030f0425df0d1..4408527394030 100644 --- a/homeassistant/components/androidtv/media_player.py +++ b/homeassistant/components/androidtv/media_player.py @@ -1,187 +1,370 @@ """Support for functionality to interact with Android TV / Fire TV devices.""" +from datetime import datetime import functools import logging +import os + +from adb_shell.auth.keygen import keygen +from adb_shell.exceptions import ( + InvalidChecksumError, + InvalidCommandError, + InvalidResponseError, + TcpTimeoutException, +) +from androidtv import ha_state_detection_rules_validator, setup +from androidtv.constants import APPS, KEYS +from androidtv.exceptions import LockNotAcquiredException import voluptuous as vol -from homeassistant.components.media_player import ( - MediaPlayerDevice, PLATFORM_SCHEMA) +from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerEntity 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) + SUPPORT_NEXT_TRACK, + SUPPORT_PAUSE, + SUPPORT_PLAY, + SUPPORT_PREVIOUS_TRACK, + SUPPORT_SELECT_SOURCE, + SUPPORT_STOP, + SUPPORT_TURN_OFF, + SUPPORT_TURN_ON, + SUPPORT_VOLUME_MUTE, + SUPPORT_VOLUME_SET, + SUPPORT_VOLUME_STEP, +) from homeassistant.const import ( - ATTR_COMMAND, ATTR_ENTITY_ID, CONF_DEVICE_CLASS, CONF_HOST, CONF_NAME, - CONF_PORT, STATE_IDLE, STATE_OFF, STATE_PAUSED, STATE_PLAYING, - STATE_STANDBY) + 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 +from homeassistant.helpers.storage import STORAGE_DIR -ANDROIDTV_DOMAIN = 'androidtv' +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' +SUPPORT_ANDROIDTV = ( + SUPPORT_PAUSE + | SUPPORT_PLAY + | SUPPORT_TURN_ON + | SUPPORT_TURN_OFF + | SUPPORT_PREVIOUS_TRACK + | SUPPORT_NEXT_TRACK + | SUPPORT_SELECT_SOURCE + | SUPPORT_STOP + | SUPPORT_VOLUME_MUTE + | SUPPORT_VOLUME_SET + | 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 +) + +ATTR_DEVICE_PATH = "device_path" +ATTR_LOCAL_PATH = "local_path" + +CONF_ADBKEY = "adbkey" +CONF_ADB_SERVER_IP = "adb_server_ip" +CONF_ADB_SERVER_PORT = "adb_server_port" +CONF_APPS = "apps" +CONF_EXCLUDE_UNNAMED_APPS = "exclude_unnamed_apps" +CONF_GET_SOURCES = "get_sources" +CONF_STATE_DETECTION_RULES = "state_detection_rules" +CONF_TURN_ON_COMMAND = "turn_on_command" +CONF_TURN_OFF_COMMAND = "turn_off_command" +CONF_SCREENCAP = "screencap" + +DEFAULT_NAME = "Android TV" DEFAULT_PORT = 5555 DEFAULT_ADB_SERVER_PORT = 5037 DEFAULT_GET_SOURCES = True -DEFAULT_DEVICE_CLASS = 'auto' +DEFAULT_DEVICE_CLASS = "auto" +DEFAULT_SCREENCAP = True -DEVICE_ANDROIDTV = 'androidtv' -DEVICE_FIRETV = 'firetv' +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 -}) +SERVICE_ADB_COMMAND = "adb_command" +SERVICE_DOWNLOAD = "download" +SERVICE_UPLOAD = "upload" + +SERVICE_ADB_COMMAND_SCHEMA = vol.Schema( + {vol.Required(ATTR_ENTITY_ID): cv.entity_ids, vol.Required(ATTR_COMMAND): cv.string} +) + +SERVICE_DOWNLOAD_SCHEMA = vol.Schema( + { + vol.Required(ATTR_ENTITY_ID): cv.entity_id, + vol.Required(ATTR_DEVICE_PATH): cv.string, + vol.Required(ATTR_LOCAL_PATH): cv.string, + } +) + +SERVICE_UPLOAD_SCHEMA = vol.Schema( + { + vol.Required(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_DEVICE_PATH): cv.string, + vol.Required(ATTR_LOCAL_PATH): 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={}): vol.Schema( + {cv.string: vol.Any(cv.string, None)} + ), + vol.Optional(CONF_TURN_ON_COMMAND): cv.string, + vol.Optional(CONF_TURN_OFF_COMMAND): cv.string, + vol.Optional(CONF_STATE_DETECTION_RULES, default={}): vol.Schema( + {cv.string: ha_state_detection_rules_validator(vol.Invalid)} + ), + vol.Optional(CONF_EXCLUDE_UNNAMED_APPS, default=False): cv.boolean, + vol.Optional(CONF_SCREENCAP, default=DEFAULT_SCREENCAP): cv.boolean, + } +) # 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} +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]) + address = f"{config[CONF_HOST]}:{config[CONF_PORT]}" + + if address in hass.data[ANDROIDTV_DOMAIN]: + _LOGGER.warning("Platform already setup on %s, skipping", address) + return if CONF_ADB_SERVER_IP not in config: - # Use "python-adb" (Python ADB implementation) - if CONF_ADBKEY in config: - aftv = setup(host, config[CONF_ADBKEY], - device_class=config[CONF_DEVICE_CLASS]) - adb_log = " using adbkey='{0}'".format(config[CONF_ADBKEY]) + # Use "adb_shell" (Python ADB implementation) + if CONF_ADBKEY not in config: + # Generate ADB key files (if they don't exist) + adbkey = hass.config.path(STORAGE_DIR, "androidtv_adbkey") + if not os.path.isfile(adbkey): + keygen(adbkey) + + adb_log = f"using Python ADB implementation with adbkey='{adbkey}'" + + aftv = setup( + config[CONF_HOST], + config[CONF_PORT], + adbkey, + device_class=config[CONF_DEVICE_CLASS], + state_detection_rules=config[CONF_STATE_DETECTION_RULES], + auth_timeout_s=10.0, + ) else: - aftv = setup(host, device_class=config[CONF_DEVICE_CLASS]) - adb_log = "" + adb_log = ( + f"using Python ADB implementation with adbkey='{config[CONF_ADBKEY]}'" + ) + + aftv = setup( + config[CONF_HOST], + config[CONF_PORT], + config[CONF_ADBKEY], + device_class=config[CONF_DEVICE_CLASS], + state_detection_rules=config[CONF_STATE_DETECTION_RULES], + auth_timeout_s=10.0, + ) + 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]) + adb_log = f"using ADB server at {config[CONF_ADB_SERVER_IP]}:{config[CONF_ADB_SERVER_PORT]}" + + aftv = setup( + config[CONF_HOST], + config[CONF_PORT], + adb_server_ip=config[CONF_ADB_SERVER_IP], + adb_server_port=config[CONF_ADB_SERVER_PORT], + device_class=config[CONF_DEVICE_CLASS], + state_detection_rules=config[CONF_STATE_DETECTION_RULES], + ) 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' + device_name = "Android TV device" elif config[CONF_DEVICE_CLASS] == DEVICE_FIRETV: - device_name = 'Fire TV device' + device_name = "Fire TV device" else: - device_name = 'Android TV / Fire TV device' + device_name = "Android TV / Fire TV device" - _LOGGER.warning("Could not connect to %s at %s%s", - device_name, host, adb_log) + _LOGGER.warning( + "Could not connect to %s at %s %s", device_name, address, adb_log + ) raise PlatformNotReady - if host in hass.data[ANDROIDTV_DOMAIN]: - _LOGGER.warning("Platform already setup on %s, skipping", host) + device_args = [ + aftv, + config[CONF_NAME], + config[CONF_APPS], + config[CONF_GET_SOURCES], + config.get(CONF_TURN_ON_COMMAND), + config.get(CONF_TURN_OFF_COMMAND), + config[CONF_EXCLUDE_UNNAMED_APPS], + config[CONF_SCREENCAP], + ] + + if aftv.DEVICE_CLASS == DEVICE_ANDROIDTV: + device = AndroidTVDevice(*device_args) + device_name = config.get(CONF_NAME, "Android TV") 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' + device = FireTVDevice(*device_args) + device_name = config.get(CONF_NAME, "Fire TV") - add_entities([device]) - _LOGGER.debug("Setup %s at %s%s", device_name, host, adb_log) - hass.data[ANDROIDTV_DOMAIN][host] = device + add_entities([device]) + _LOGGER.debug("Setup %s at %s %s", device_name, address, adb_log) + hass.data[ANDROIDTV_DOMAIN][address] = 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] + cmd = service.data[ATTR_COMMAND] + entity_id = service.data[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 and (not isinstance(output, str) or output.strip()): - _LOGGER.info("Output of command '%s' from '%s': %s", - cmd, target_device.entity_id, repr(output)) + # 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 service_download(service): + """Download a file from your Android TV / Fire TV device to your Home Assistant instance.""" + local_path = service.data[ATTR_LOCAL_PATH] + if not hass.config.is_allowed_path(local_path): + _LOGGER.warning("'%s' is not secure to load data from!", local_path) + return + + device_path = service.data[ATTR_DEVICE_PATH] + entity_id = service.data[ATTR_ENTITY_ID] + target_device = [ + dev + for dev in hass.data[ANDROIDTV_DOMAIN].values() + if dev.entity_id in entity_id + ][0] + + target_device.adb_pull(local_path, device_path) + + hass.services.register( + ANDROIDTV_DOMAIN, + SERVICE_DOWNLOAD, + service_download, + schema=SERVICE_DOWNLOAD_SCHEMA, + ) + + def service_upload(service): + """Upload a file from your Home Assistant instance to an Android TV / Fire TV device.""" + local_path = service.data[ATTR_LOCAL_PATH] + if not hass.config.is_allowed_path(local_path): + _LOGGER.warning("'%s' is not secure to load data from!", local_path) + return + + device_path = service.data[ATTR_DEVICE_PATH] + entity_id = service.data[ATTR_ENTITY_ID] + target_devices = [ + dev + for dev in hass.data[ANDROIDTV_DOMAIN].values() + if dev.entity_id in entity_id + ] - hass.services.register(ANDROIDTV_DOMAIN, SERVICE_ADB_COMMAND, - service_adb_command, - schema=SERVICE_ADB_COMMAND_SCHEMA) + for target_device in target_devices: + target_device.adb_push(local_path, device_path) + + hass.services.register( + ANDROIDTV_DOMAIN, SERVICE_UPLOAD, service_upload, schema=SERVICE_UPLOAD_SCHEMA + ) def adb_decorator(override_available=False): - """Send an ADB command if the device is available and catch exceptions.""" + """Wrap ADB methods and catch exceptions. + + Allows for overriding the available status of the ADB connection via the + `override_available` parameter. + """ + def _adb_decorator(func): - """Wait if previous ADB commands haven't finished.""" + """Wrap the provided ADB method and catch exceptions.""" + @functools.wraps(func) def _adb_exception_catcher(self, *args, **kwargs): - # If the device is unavailable, don't do anything + """Call an ADB-related method and catch exceptions.""" if not self.available and not override_available: return None try: return func(self, *args, **kwargs) + except LockNotAcquiredException: + # If the ADB lock could not be acquired, skip this command + _LOGGER.info( + "ADB command not executed because the connection is currently in use" + ) + return 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) + "establishing attempt in the next update. Error: %s", + err, + ) + self.aftv.adb_close() self._available = False # pylint: disable=protected-access return None @@ -190,42 +373,63 @@ def _adb_exception_catcher(self, *args, **kwargs): return _adb_decorator -class ADBDevice(MediaPlayerDevice): +class ADBDevice(MediaPlayerEntity): """Representation of an Android TV or Fire TV device.""" - def __init__(self, aftv, name, apps, turn_on_command, - turn_off_command): + def __init__( + self, + aftv, + name, + apps, + get_sources, + turn_on_command, + turn_off_command, + exclude_unnamed_apps, + screencap, + ): """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._app_id_to_name = APPS.copy() + self._app_id_to_name.update(apps) + self._app_name_to_id = { + value: key for key, value in self._app_id_to_name.items() if value + } + self._get_sources = get_sources self._keys = KEYS + self._device_properties = self.aftv.device_properties + self._unique_id = self._device_properties.get("serialno") + self.turn_on_command = turn_on_command self.turn_off_command = turn_off_command + self._exclude_unnamed_apps = exclude_unnamed_apps + self._screencap = screencap + # 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) + # Using "adb_shell" (Python ADB implementation) + self.exceptions = ( + AttributeError, + BrokenPipeError, + ConnectionResetError, + TypeError, + ValueError, + InvalidChecksumError, + InvalidCommandError, + InvalidResponseError, + TcpTimeoutException, + ) else: # Using "pure-python-adb" (communicate with ADB server) self.exceptions = (ConnectionResetError, RuntimeError) # Property attributes - self._available = self.aftv.available + self._adb_response = None + self._available = True self._current_app = None + self._sources = None self._state = None @property @@ -236,13 +440,18 @@ def app_id(self): @property def app_name(self): """Return the friendly name of the current app.""" - return self._apps.get(self._current_app, self._current_app) + return self._app_id_to_name.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.""" @@ -253,11 +462,46 @@ def should_poll(self): """Device should be polled.""" return True + @property + def source(self): + """Return the current app.""" + return self._app_id_to_name.get(self._current_app, self._current_app) + + @property + def source_list(self): + """Return a list of running apps.""" + return self._sources + @property def state(self): """Return the state of the player.""" return self._state + @property + def unique_id(self): + """Return the device unique id.""" + return self._unique_id + + async def async_get_media_image(self): + """Fetch current playing image.""" + if not self._screencap or self.state in [STATE_OFF, None] or not self.available: + return None, None + + media_data = await self.hass.async_add_executor_job(self.get_raw_media_data) + if media_data: + return media_data, "image/png" + return None, None + + @adb_decorator() + def get_raw_media_data(self): + """Raw image data.""" + return self.aftv.adb_screencap() + + @property + def media_image_hash(self): + """Hash value for media image.""" + return f"{datetime.now().timestamp()}" + @adb_decorator() def media_play(self): """Send play command.""" @@ -299,32 +543,88 @@ def media_next_track(self): """Send next track command (results in fast-forward).""" self.aftv.media_next_track() + @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(self._app_name_to_id.get(source, source)) + else: + source_ = source[1:].lstrip() + self.aftv.stop_app(self._app_name_to_id.get(source_, source_)) + @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: - return self.aftv.adb_shell('input keyevent {}'.format(key)) + self.aftv.adb_shell(f"input keyevent {key}") + self._adb_response = None + self.schedule_update_ha_state() + return - if cmd == 'GET_PROPERTIES': - return self.aftv.get_properties_dict() + if cmd == "GET_PROPERTIES": + self._adb_response = str(self.aftv.get_properties_dict()) + self.schedule_update_ha_state() + return self._adb_response - return self.aftv.adb_shell(cmd) + try: + response = self.aftv.adb_shell(cmd) + except UnicodeDecodeError: + self._adb_response = None + self.schedule_update_ha_state() + return + + 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 + + @adb_decorator() + def adb_pull(self, local_path, device_path): + """Download a file from your Android TV / Fire TV device to your Home Assistant instance.""" + self.aftv.adb_pull(local_path, device_path) + + @adb_decorator() + def adb_push(self, local_path, device_path): + """Upload a file from your Home Assistant instance to an Android TV / Fire TV device.""" + self.aftv.adb_push(local_path, device_path) class AndroidTVDevice(ADBDevice): """Representation of an Android TV device.""" - def __init__(self, aftv, name, apps, turn_on_command, - turn_off_command): + def __init__( + self, + aftv, + name, + apps, + get_sources, + turn_on_command, + turn_off_command, + exclude_unnamed_apps, + screencap, + ): """Initialize the Android TV device.""" - super().__init__(aftv, name, apps, turn_on_command, - turn_off_command) + super().__init__( + aftv, + name, + apps, + get_sources, + turn_on_command, + turn_off_command, + exclude_unnamed_apps, + screencap, + ) - 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) @@ -333,41 +633,52 @@ def update(self): # Check if device is disconnected. if not self._available: # Try to connect - self._available = self.aftv.connect(always_log_errors=False) + self._available = self.aftv.adb_connect(always_log_errors=False) - # To be safe, wait until the next update to run ADB commands. - return + # To be safe, wait until the next update to run ADB commands if + # using the Python ADB implementation. + if not self.aftv.adb_server_ip: + 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] + ( + state, + self._current_app, + running_apps, + _, + self._is_volume_muted, + self._volume_level, + ) = self.aftv.update(self._get_sources) + + self._state = ANDROIDTV_STATES.get(state) + if self._state is None: + self._available = False + + if running_apps: + sources = [ + self._app_id_to_name.get( + app_id, app_id if not self._exclude_unnamed_apps else None + ) + for app_id in running_apps + ] + self._sources = [source for source in sources if source] + else: + self._sources = None @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.""" @@ -383,6 +694,11 @@ def mute_volume(self, mute): """Mute the volume.""" self.aftv.mute_volume() + @adb_decorator() + def set_volume_level(self, volume): + """Set the volume level.""" + self.aftv.set_volume_level(volume) + @adb_decorator() def volume_down(self): """Send volume down command.""" @@ -397,45 +713,40 @@ def volume_up(self): 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) + self._available = self.aftv.adb_connect(always_log_errors=False) - # To be safe, wait until the next update to run ADB commands. - return + # To be safe, wait until the next update to run ADB commands if + # using the Python ADB implementation. + if not self.aftv.adb_server_ip: + 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 + state, self._current_app, running_apps = self.aftv.update(self._get_sources) + + self._state = ANDROIDTV_STATES.get(state) + if self._state is None: + self._available = False + + if running_apps: + sources = [ + self._app_id_to_name.get( + app_id, app_id if not self._exclude_unnamed_apps else None + ) + for app_id in running_apps + ] + self._sources = [source for source in sources if source] + else: + self._sources = None @property def supported_features(self): @@ -446,16 +757,3 @@ def supported_features(self): 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 index 78ff0a828f6c8..f5efe233271a4 100644 --- a/homeassistant/components/androidtv/services.yaml +++ b/homeassistant/components/androidtv/services.yaml @@ -5,7 +5,31 @@ adb_command: fields: entity_id: description: Name(s) of Android TV / Fire TV entities. - example: 'media_player.android_tv_living_room' + example: "media_player.android_tv_living_room" command: description: Either a key command or an ADB shell command. - example: 'HOME' + example: "HOME" +download: + description: Download a file from your Android TV / Fire TV device to your Home Assistant instance. + fields: + entity_id: + description: Name of Android TV / Fire TV entity. + example: "media_player.android_tv_living_room" + device_path: + description: The filepath on the Android TV / Fire TV device. + example: "/storage/emulated/0/Download/example.txt" + local_path: + description: The filepath on your Home Assistant instance. + example: "/config/www/example.txt" +upload: + description: Upload a file from your Home Assistant instance 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" + device_path: + description: The filepath on the Android TV / Fire TV device. + example: "/storage/emulated/0/Download/example.txt" + local_path: + description: The filepath on your Home Assistant instance. + example: "/config/www/example.txt" diff --git a/homeassistant/components/anel_pwrctrl/manifest.json b/homeassistant/components/anel_pwrctrl/manifest.json index 17802918cd226..891b485bd97f4 100644 --- a/homeassistant/components/anel_pwrctrl/manifest.json +++ b/homeassistant/components/anel_pwrctrl/manifest.json @@ -1,10 +1,7 @@ { "domain": "anel_pwrctrl", - "name": "Anel pwrctrl", - "documentation": "https://www.home-assistant.io/components/anel_pwrctrl", - "requirements": [ - "anel_pwrctrl-homeassistant==0.0.1.dev2" - ], - "dependencies": [], + "name": "Anel NET-PwrCtrl", + "documentation": "https://www.home-assistant.io/integrations/anel_pwrctrl", + "requirements": ["anel_pwrctrl-homeassistant==0.0.1.dev2"], "codeowners": [] } diff --git a/homeassistant/components/anel_pwrctrl/switch.py b/homeassistant/components/anel_pwrctrl/switch.py index 7552e35fe4b23..c769f51d5b6b6 100644 --- a/homeassistant/components/anel_pwrctrl/switch.py +++ b/homeassistant/components/anel_pwrctrl/switch.py @@ -1,47 +1,50 @@ """Support for ANEL PwrCtrl switches.""" -import logging -import socket from datetime import timedelta +import logging +from anel_pwrctrl import DeviceMaster import voluptuous as vol +from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchEntity +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME 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' +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, -}) +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 + host = config.get(CONF_HOST) + username = config[CONF_USERNAME] + password = config[CONF_PASSWORD] + port_recv = config[CONF_PORT_RECV] + port_send = config[CONF_PORT_SEND] try: master = DeviceMaster( - username=username, password=password, read_port=port_send, - write_port=port_recv) + username=username, + password=password, + read_port=port_send, + write_port=port_recv, + ) master.query(ip_addr=host) - except socket.error as ex: + except OSError as ex: _LOGGER.error("Unable to discover PwrCtrl device: %s", str(ex)) return False @@ -49,14 +52,13 @@ def setup_platform(hass, config, add_entities, discovery_info=None): for device in master.devices.values(): parent_device = PwrCtrlDevice(device) devices.extend( - PwrCtrlSwitch(switch, parent_device) - for switch in device.switches.values() + PwrCtrlSwitch(switch, parent_device) for switch in device.switches.values() ) add_entities(devices) -class PwrCtrlSwitch(SwitchDevice): +class PwrCtrlSwitch(SwitchEntity): """Representation of a PwrCtrl switch.""" def __init__(self, port, parent_device): @@ -72,10 +74,7 @@ def should_poll(self): @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() - ) + return f"{self._port.device.host}-{self._port.get_index()}" @property def name(self): diff --git a/homeassistant/components/anthemav/manifest.json b/homeassistant/components/anthemav/manifest.json index 9b2e3c697bb22..db9d8c7d3b9e4 100644 --- a/homeassistant/components/anthemav/manifest.json +++ b/homeassistant/components/anthemav/manifest.json @@ -1,10 +1,7 @@ { "domain": "anthemav", - "name": "Anthemav", - "documentation": "https://www.home-assistant.io/components/anthemav", - "requirements": [ - "anthemav==1.1.10" - ], - "dependencies": [], + "name": "Anthem A/V Receivers", + "documentation": "https://www.home-assistant.io/integrations/anthemav", + "requirements": ["anthemav==1.1.10"], "codeowners": [] } diff --git a/homeassistant/components/anthemav/media_player.py b/homeassistant/components/anthemav/media_player.py index 1a335fc2ce606..788fa8db7eb3a 100644 --- a/homeassistant/components/anthemav/media_player.py +++ b/homeassistant/components/anthemav/media_player.py @@ -1,54 +1,74 @@ """Support for Anthem Network Receivers and Processors.""" import logging +import anthemav import voluptuous as vol -from homeassistant.components.media_player import ( - MediaPlayerDevice, PLATFORM_SCHEMA) +from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerEntity from homeassistant.components.media_player.const import ( - SUPPORT_SELECT_SOURCE, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, - SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET) + 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) + CONF_HOST, + CONF_NAME, + CONF_PORT, + EVENT_HOMEASSISTANT_STOP, + STATE_OFF, + STATE_ON, +) +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) _LOGGER = logging.getLogger(__name__) -DOMAIN = 'anthemav' +DOMAIN = "anthemav" DEFAULT_PORT = 14999 -SUPPORT_ANTHEMAV = SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ - SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_SELECT_SOURCE +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, -}) +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): +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) + host = config[CONF_HOST] + port = config[CONF_PORT] name = config.get(CONF_NAME) device = None _LOGGER.info("Provisioning Anthem AVR device at %s:%d", host, port) + @callback 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()) + _LOGGER.debug("Received update callback from AVR: %s", message) + async_dispatcher_send(hass, DOMAIN) avr = await anthemav.Connection.create( - host=host, port=port, loop=hass.loop, - update_callback=async_anthemav_update_callback) + host=host, port=port, update_callback=async_anthemav_update_callback + ) device = AnthemAVR(avr, name) @@ -59,7 +79,7 @@ def async_anthemav_update_callback(message): async_add_entities([device]) -class AnthemAVR(MediaPlayerDevice): +class AnthemAVR(MediaPlayerEntity): """Entity reading values from Anthem AVR protocol.""" def __init__(self, avr, name): @@ -71,6 +91,12 @@ def __init__(self, avr, name): def _lookup(self, propname, dval=None): return getattr(self.avr.protocol, propname, dval) + async def async_added_to_hass(self): + """When entity is added to hass.""" + self.async_on_remove( + async_dispatcher_connect(self.hass, DOMAIN, self.async_write_ha_state) + ) + @property def supported_features(self): """Flag media player features that are supported.""" @@ -84,12 +110,12 @@ def should_poll(self): @property def name(self): """Return name of device.""" - return self._name or self._lookup('model') + return self._name or self._lookup("model") @property def state(self): """Return state of power on/off.""" - pwrstate = self._lookup('power') + pwrstate = self._lookup("power") if pwrstate is True: return STATE_ON @@ -100,64 +126,64 @@ def state(self): @property def is_volume_muted(self): """Return boolean reflecting mute state on device.""" - return self._lookup('mute', False) + 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) + 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') + 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', '') + return ( + f"{self._lookup('video_input_resolution_text', '')} " + f"{self._lookup('audio_input_name', '')}" + ) @property def source(self): """Return currently selected input.""" - return self._lookup('input_name', "Unknown") + return self._lookup("input_name", "Unknown") @property def source_list(self): """Return all active, configured inputs.""" - return self._lookup('input_list', ["Unknown"]) + 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) + self._update_avr("input_name", source) async def async_turn_off(self): """Turn AVR power off.""" - self._update_avr('power', False) + self._update_avr("power", False) async def async_turn_on(self): """Turn AVR power on.""" - self._update_avr('power', True) + 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) + self._update_avr("volume_as_percentage", volume) async def async_mute_volume(self, mute): """Engage AVR mute.""" - self._update_avr('mute', 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)) + _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())) + items_string = ", ".join(f"{item}: {item}" for item in attrs.items()) + return f"dump_avrdata: {items_string}" diff --git a/homeassistant/components/apache_kafka/__init__.py b/homeassistant/components/apache_kafka/__init__.py new file mode 100644 index 0000000000000..7bd23630bd089 --- /dev/null +++ b/homeassistant/components/apache_kafka/__init__.py @@ -0,0 +1,117 @@ +"""Support for Apache Kafka.""" +from datetime import datetime +import json +import logging + +from aiokafka import AIOKafkaProducer +import voluptuous as vol + +from homeassistant.const import ( + CONF_IP_ADDRESS, + CONF_PORT, + EVENT_HOMEASSISTANT_STOP, + EVENT_STATE_CHANGED, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entityfilter import FILTER_SCHEMA + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "apache_kafka" + +CONF_FILTER = "filter" +CONF_TOPIC = "topic" + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_IP_ADDRESS): cv.string, + vol.Required(CONF_PORT): cv.port, + vol.Required(CONF_TOPIC): cv.string, + vol.Optional(CONF_FILTER, default={}): FILTER_SCHEMA, + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass, config): + """Activate the Apache Kafka integration.""" + conf = config[DOMAIN] + + kafka = hass.data[DOMAIN] = KafkaManager( + hass, + conf[CONF_IP_ADDRESS], + conf[CONF_PORT], + conf[CONF_TOPIC], + conf[CONF_FILTER], + ) + + hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, kafka.shutdown()) + + await kafka.start() + + return True + + +class DateTimeJSONEncoder(json.JSONEncoder): + """Encode python objects. + + Additionally add encoding for datetime objects as isoformat. + """ + + def default(self, o): # pylint: disable=method-hidden + """Implement encoding logic.""" + if isinstance(o, datetime): + return o.isoformat() + return super().default(o) + + +class KafkaManager: + """Define a manager to buffer events to Kafka.""" + + def __init__(self, hass, ip_address, port, topic, entities_filter): + """Initialize.""" + self._encoder = DateTimeJSONEncoder() + self._entities_filter = entities_filter + self._hass = hass + self._producer = AIOKafkaProducer( + loop=hass.loop, + bootstrap_servers=f"{ip_address}:{port}", + compression_type="gzip", + ) + self._topic = topic + + def _encode_event(self, event): + """Translate events into a binary JSON payload.""" + state = event.data.get("new_state") + if ( + state is None + or state.state in (STATE_UNKNOWN, "", STATE_UNAVAILABLE) + or not self._entities_filter(state.entity_id) + ): + return + + return json.dumps(obj=state.as_dict(), default=self._encoder.encode).encode( + "utf-8" + ) + + async def start(self): + """Start the Kafka manager.""" + self._hass.bus.async_listen(EVENT_STATE_CHANGED, self.write) + await self._producer.start() + + async def shutdown(self): + """Shut the manager down.""" + await self._producer.stop() + + async def write(self, event): + """Write a binary payload to Kafka.""" + payload = self._encode_event(event) + + if payload: + await self._producer.send_and_wait(self._topic, payload) diff --git a/homeassistant/components/apache_kafka/manifest.json b/homeassistant/components/apache_kafka/manifest.json new file mode 100644 index 0000000000000..f4dd2cb6ae8d6 --- /dev/null +++ b/homeassistant/components/apache_kafka/manifest.json @@ -0,0 +1,7 @@ +{ + "domain": "apache_kafka", + "name": "Apache Kafka", + "documentation": "https://www.home-assistant.io/integrations/apache_kafka", + "requirements": ["aiokafka==0.5.1"], + "codeowners": ["@bachya"] +} diff --git a/homeassistant/components/apcupsd/__init__.py b/homeassistant/components/apcupsd/__init__.py index d4649db0203c3..1f024bf58823f 100644 --- a/homeassistant/components/apcupsd/__init__.py +++ b/homeassistant/components/apcupsd/__init__.py @@ -1,49 +1,52 @@ """Support for APCUPSd via its Network Information Server (NIS).""" -import logging from datetime import timedelta +import logging +from apcaccess import status import voluptuous as vol -from homeassistant.const import (CONF_HOST, CONF_PORT) +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_HOST = "localhost" DEFAULT_PORT = 3551 -DOMAIN = 'apcupsd' +DOMAIN = "apcupsd" -KEY_STATUS = 'STATUS' +KEY_STATUS = "STATFLAG" MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) -VALUE_ONLINE = 'ONLINE' +VALUE_ONLINE = 8 -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) +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) + host = conf[CONF_HOST] + port = conf[CONF_PORT] - DATA = APCUPSdData(host, port) + apcups_data = APCUPSdData(host, port) + hass.data[DOMAIN] = apcups_data # 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) + apcups_data.update(no_throttle=True) except Exception: # pylint: disable=broad-except _LOGGER.exception("Failure while testing APCUPSd status retrieval.") return False @@ -59,7 +62,7 @@ class APCUPSdData: def __init__(self, host, port): """Initialize the data object.""" - from apcaccess import status + self._host = host self._port = port self._status = None diff --git a/homeassistant/components/apcupsd/binary_sensor.py b/homeassistant/components/apcupsd/binary_sensor.py index 367b3c2b9b506..daf9592f3e6e9 100644 --- a/homeassistant/components/apcupsd/binary_sensor.py +++ b/homeassistant/components/apcupsd/binary_sensor.py @@ -1,24 +1,26 @@ """Support for tracking the online status of a UPS.""" import voluptuous as vol -from homeassistant.components.binary_sensor import ( - BinarySensorDevice, PLATFORM_SCHEMA) +from homeassistant.components.binary_sensor import PLATFORM_SCHEMA, BinarySensorEntity 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, -}) +from . import DOMAIN, KEY_STATUS, VALUE_ONLINE + +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) + apcups_data = hass.data[DOMAIN] + + add_entities([OnlineStatus(config, apcups_data)], True) -class OnlineStatus(BinarySensorDevice): +class OnlineStatus(BinarySensorEntity): """Representation of an UPS online status.""" def __init__(self, config, data): @@ -30,13 +32,13 @@ def __init__(self, config, data): @property def name(self): """Return the name of the UPS online status sensor.""" - return self._config.get(CONF_NAME) + return self._config[CONF_NAME] @property def is_on(self): """Return true if the UPS is online, else false.""" - return self._state == apcupsd.VALUE_ONLINE + return self._state & VALUE_ONLINE > 0 def update(self): """Get the status report from APCUPSd and set this entity's state.""" - self._state = self._data.status[apcupsd.KEY_STATUS] + self._state = int(self._data.status[KEY_STATUS], 16) diff --git a/homeassistant/components/apcupsd/manifest.json b/homeassistant/components/apcupsd/manifest.json index 813176728f284..643f42b420116 100644 --- a/homeassistant/components/apcupsd/manifest.json +++ b/homeassistant/components/apcupsd/manifest.json @@ -1,10 +1,7 @@ { "domain": "apcupsd", - "name": "Apcupsd", - "documentation": "https://www.home-assistant.io/components/apcupsd", - "requirements": [ - "apcaccess==0.0.13" - ], - "dependencies": [], + "name": "apcupsd", + "documentation": "https://www.home-assistant.io/integrations/apcupsd", + "requirements": ["apcaccess==0.0.13"], "codeowners": [] } diff --git a/homeassistant/components/apcupsd/sensor.py b/homeassistant/components/apcupsd/sensor.py index ae1ad10223d75..44c1c498c2838 100644 --- a/homeassistant/components/apcupsd/sensor.py +++ b/homeassistant/components/apcupsd/sensor.py @@ -1,110 +1,123 @@ """Support for APCUPSd sensors.""" import logging +from apcaccess.status import ALL_UNITS import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_RESOURCES, + FREQUENCY_HERTZ, + POWER_WATT, + TEMP_CELSIUS, + TIME_MINUTES, + TIME_SECONDS, + UNIT_PERCENTAGE, + VOLT, +) 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 +from . import DOMAIN + _LOGGER = logging.getLogger(__name__) -SENSOR_PREFIX = 'UPS ' +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'], + "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", VOLT, "mdi:flash"], + "bcharge": ["Battery", UNIT_PERCENTAGE, "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", VOLT, "mdi:flash"], + "hostname": ["Hostname", "", "mdi:information-outline"], + "humidity": ["Ambient Humidity", UNIT_PERCENTAGE, "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", FREQUENCY_HERTZ, "mdi:information-outline"], + "linev": ["Input Voltage", VOLT, "mdi:flash"], + "loadpct": ["Load", UNIT_PERCENTAGE, "mdi:gauge"], + "loadapnt": ["Load Apparent Power", UNIT_PERCENTAGE, "mdi:gauge"], + "lotrans": ["Transfer Low", VOLT, "mdi:flash"], + "mandate": ["Manufacture Date", "", "mdi:calendar"], + "masterupd": ["Master Update", "", "mdi:information-outline"], + "maxlinev": ["Input Voltage High", VOLT, "mdi:flash"], + "maxtime": ["Battery Timeout", "", "mdi:timer-off"], + "mbattchg": ["Battery Shutdown", UNIT_PERCENTAGE, "mdi:battery-alert"], + "minlinev": ["Input Voltage Low", VOLT, "mdi:flash"], + "mintimel": ["Shutdown Time", "", "mdi:timer"], + "model": ["Model", "", "mdi:information-outline"], + "nombattv": ["Battery Nominal Voltage", VOLT, "mdi:flash"], + "nominv": ["Nominal Input Voltage", VOLT, "mdi:flash"], + "nomoutv": ["Nominal Output Voltage", VOLT, "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", VOLT, "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", UNIT_PERCENTAGE, "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 -} +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': '%', + " Minutes": TIME_MINUTES, + " Seconds": TIME_SECONDS, + " Percent": UNIT_PERCENTAGE, + " Volts": VOLT, + " Ampere": "A", + " Volt-Ampere": "VA", + " Watts": POWER_WATT, + " Hz": FREQUENCY_HERTZ, + " C": TEMP_CELSIUS, + " Percent Load Capacity": UNIT_PERCENTAGE, } -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_RESOURCES, default=[]): - vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), -}) +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.""" + apcups_data = hass.data[DOMAIN] entities = [] for resource in config[CONF_RESOURCES]: @@ -112,14 +125,18 @@ def setup_platform(hass, config, add_entities, discovery_info=None): if sensor_type not in SENSOR_TYPES: SENSOR_TYPES[sensor_type] = [ - sensor_type.title(), '', 'mdi:information-outline'] + sensor_type.title(), + "", + "mdi:information-outline", + ] - if sensor_type.upper() not in apcupsd.DATA.status: + if sensor_type.upper() not in apcups_data.status: _LOGGER.warning( "Sensor type: %s does not appear in the APCUPSd status output", - sensor_type) + sensor_type, + ) - entities.append(APCUPSdSensor(apcupsd.DATA, sensor_type)) + entities.append(APCUPSdSensor(apcups_data, sensor_type)) add_entities(entities, True) @@ -130,10 +147,10 @@ def infer_unit(value): 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[: -len(unit)], INFERRED_UNITS.get(unit, unit.strip()) return value, None @@ -178,4 +195,5 @@ def update(self): self._inferred_unit = None else: self._state, self._inferred_unit = infer_unit( - self._data.status[self.type.upper()]) + self._data.status[self.type.upper()] + ) diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py index 0e860854af4ca..8d0cd44070cb0 100644 --- a/homeassistant/components/api/__init__.py +++ b/homeassistant/components/api/__init__.py @@ -8,32 +8,45 @@ import async_timeout import voluptuous as vol +from homeassistant.auth.permissions.const import POLICY_READ 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__) + EVENT_HOMEASSISTANT_STOP, + EVENT_TIME_CHANGED, + HTTP_BAD_REQUEST, + HTTP_CREATED, + HTTP_NOT_FOUND, + HTTP_OK, + 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_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.exceptions import ServiceNotFound, TemplateError, Unauthorized from homeassistant.helpers import template +from homeassistant.helpers.json import JSONEncoder 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' +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' +DOMAIN = "api" +STREAM_PING_PAYLOAD = "ping" STREAM_PING_INTERVAL = 50 # seconds @@ -62,7 +75,7 @@ class APIStatusView(HomeAssistantView): """View to handle Status requests.""" url = URL_API - name = 'api:status' + name = "api:status" @ha.callback def get(self, request): @@ -74,19 +87,19 @@ class APIEventStream(HomeAssistantView): """View to handle EventStream requests.""" url = URL_API_STREAM - name = '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: + if not request["hass_user"].is_admin: raise Unauthorized() - hass = request.app['hass'] + hass = request.app["hass"] stop_obj = object() - to_write = asyncio.Queue(loop=hass.loop) + to_write = asyncio.Queue() - restrict = request.query.get('restrict') + restrict = request.query.get("restrict") if restrict: - restrict = restrict.split(',') + [EVENT_HOMEASSISTANT_STOP] + restrict = restrict.split(",") + [EVENT_HOMEASSISTANT_STOP] async def forward_events(event): """Forward events to the open request.""" @@ -106,7 +119,7 @@ async def forward_events(event): await to_write.put(data) response = web.StreamResponse() - response.content_type = 'text/event-stream' + response.content_type = "text/event-stream" await response.prepare(request) unsub_stream = hass.bus.async_listen(MATCH_ALL, forward_events) @@ -119,17 +132,15 @@ async def forward_events(event): while True: try: - with async_timeout.timeout(STREAM_PING_INTERVAL, - loop=hass.loop): + 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')) + msg = f"data: {payload}\n\n" + _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) @@ -147,12 +158,12 @@ class APIConfigView(HomeAssistantView): """View to handle Configuration requests.""" url = URL_API_CONFIG - name = 'api:config' + name = "api:config" @ha.callback def get(self, request): """Get current configuration.""" - return self.json(request.app['hass'].config.as_dict()) + return self.json(request.app["hass"].config.as_dict()) class APIDiscoveryView(HomeAssistantView): @@ -160,19 +171,21 @@ class APIDiscoveryView(HomeAssistantView): requires_auth = False url = URL_API_DISCOVERY_INFO - name = 'api:discovery' + 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__, - }) + 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): @@ -184,11 +197,12 @@ class APIStatesView(HomeAssistantView): @ha.callback def get(self, request): """Get current states.""" - user = request['hass_user'] + 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') + state + for state in request.app["hass"].states.async_all() + if entity_perm(state.entity_id, "read") ] return self.json(states) @@ -196,60 +210,60 @@ def get(self, request): class APIEntityStateView(HomeAssistantView): """View to handle EntityState requests.""" - url = '/api/states/{entity_id}' - name = 'api:entity-state' + 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'] + 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) + 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: + if not request["hass_user"].is_admin: raise Unauthorized(entity_id=entity_id) - hass = request.app['hass'] + hass = request.app["hass"] try: data = await request.json() except ValueError: - return self.json_message( - "Invalid JSON specified.", HTTP_BAD_REQUEST) + return self.json_message("Invalid JSON specified.", HTTP_BAD_REQUEST) - new_state = data.get('state') + 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) + 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)) + 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 + status_code = HTTP_CREATED if is_new_state else HTTP_OK resp = self.json(hass.states.get(entity_id), status_code) - resp.headers.add('Location', URL_API_STATES_ENTITY.format(entity_id)) + resp.headers.add("Location", f"/api/states/{entity_id}") return resp @ha.callback def delete(self, request, entity_id): """Remove entity.""" - if not request['hass_user'].is_admin: + if not request["hass_user"].is_admin: raise Unauthorized(entity_id=entity_id) - if request.app['hass'].states.async_remove(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) @@ -258,86 +272,88 @@ class APIEventListenersView(HomeAssistantView): """View to handle EventListeners requests.""" url = URL_API_EVENTS - name = 'api:event-listeners' + name = "api:event-listeners" @ha.callback def get(self, request): """Get event listeners.""" - return self.json(async_events_json(request.app['hass'])) + 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' + url = "/api/events/{event_type}" + name = "api:event" async def post(self, request, event_type): """Fire events.""" - if not request['hass_user'].is_admin: + 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) + "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) + "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'): + 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)) + 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)) + return self.json_message(f"Event {event_type} fired.") class APIServicesView(HomeAssistantView): """View to handle Services requests.""" url = URL_API_SERVICES - name = 'api:services' + name = "api:services" async def get(self, request): """Get registered services.""" - services = await async_services_json(request.app['hass']) + 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' + 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'] + 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) + 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)) + domain, service, data, True, self.context(request) + ) except (vol.Invalid, ServiceNotFound): raise HTTPBadRequest() @@ -348,54 +364,57 @@ class APIComponentsView(HomeAssistantView): """View to handle Components requests.""" url = URL_API_COMPONENTS - name = 'api:components' + name = "api:components" @ha.callback def get(self, request): """Get current loaded components.""" - return self.json(request.app['hass'].config.components) + return self.json(request.app["hass"].config.components) class APITemplateView(HomeAssistantView): """View to handle Template requests.""" url = URL_API_TEMPLATE - name = 'api:template' + name = "api:template" async def post(self, request): """Render a template.""" - if not request['hass_user'].is_admin: + 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')) + 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) + f"Error rendering template: {ex}", HTTP_BAD_REQUEST + ) class APIErrorLog(HomeAssistantView): """View to fetch the API error log.""" url = URL_API_ERROR_LOG - name = 'api:error_log' + name = "api:error_log" async def get(self, request): """Retrieve API error log.""" - if not request['hass_user'].is_admin: + if not request["hass_user"].is_admin: raise Unauthorized() - return web.FileResponse(request.app['hass'].data[DATA_LOGGING]) + 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()] + return [{"domain": key, "services": value} for key, value in descriptions.items()] +@ha.callback 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()] + 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 index 25d9a76036eec..1f400470943e7 100644 --- a/homeassistant/components/api/manifest.json +++ b/homeassistant/components/api/manifest.json @@ -1,12 +1,8 @@ { "domain": "api", "name": "Home Assistant API", - "documentation": "https://www.home-assistant.io/components/api", - "requirements": [], - "dependencies": [ - "http" - ], - "codeowners": [ - "@home-assistant/core" - ] + "documentation": "https://www.home-assistant.io/integrations/api", + "dependencies": ["http"], + "codeowners": ["@home-assistant/core"], + "quality_scale": "internal" } diff --git a/homeassistant/components/apns/const.py b/homeassistant/components/apns/const.py new file mode 100644 index 0000000000000..a8dc1204aa194 --- /dev/null +++ b/homeassistant/components/apns/const.py @@ -0,0 +1,2 @@ +"""Constants for the apns component.""" +DOMAIN = "apns" diff --git a/homeassistant/components/apns/manifest.json b/homeassistant/components/apns/manifest.json index 9a310a096a5a4..0d3639040f72a 100644 --- a/homeassistant/components/apns/manifest.json +++ b/homeassistant/components/apns/manifest.json @@ -1,10 +1,8 @@ { "domain": "apns", - "name": "Apns", - "documentation": "https://www.home-assistant.io/components/apns", - "requirements": [ - "apns2==0.3.0" - ], - "dependencies": [], + "name": "Apple Push Notification Service (APNS)", + "documentation": "https://www.home-assistant.io/integrations/apns", + "requirements": ["apns2==0.3.0"], + "after_dependencies": ["device_tracker"], "codeowners": [] } diff --git a/homeassistant/components/apns/notify.py b/homeassistant/components/apns/notify.py index 365bdbcb4f557..59b2a7aa9fae3 100644 --- a/homeassistant/components/apns/notify.py +++ b/homeassistant/components/apns/notify.py @@ -1,52 +1,59 @@ """APNS Notification platform.""" import logging -import os +from apns2.client import APNsClient +from apns2.errors import Unregistered +from apns2.payload import Payload import voluptuous as vol +from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN +from homeassistant.components.notify import ( + ATTR_DATA, + ATTR_TARGET, + PLATFORM_SCHEMA, + BaseNotificationService, +) 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) +from .const import DOMAIN -APNS_DEVICES = 'apns.yaml' -CONF_CERTFILE = 'cert_file' -CONF_TOPIC = 'topic' -CONF_SANDBOX = 'sandbox' -DEVICE_TRACKER_DOMAIN = 'device_tracker' -SERVICE_REGISTER = 'apns_register' +APNS_DEVICES = "apns.yaml" +CONF_CERTFILE = "cert_file" +CONF_TOPIC = "topic" +CONF_SANDBOX = "sandbox" -ATTR_PUSH_ID = 'push_id' +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, -}) +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, -}) +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) + name = config[CONF_NAME] + cert_file = config[CONF_CERTFILE] + topic = config[CONF_TOPIC] + sandbox = config[CONF_SANDBOX] service = ApnsNotificationService(hass, name, topic, sandbox, cert_file) hass.services.register( - DOMAIN, 'apns_{}'.format(name), service.register, - schema=REGISTER_SERVICE_SCHEMA) + DOMAIN, f"apns_{name}", service.register, schema=REGISTER_SERVICE_SCHEMA + ) return service @@ -93,7 +100,7 @@ def full_tracking_device_id(self): The full id of a device that is tracked by the device tracking component. """ - return '{}.{}'.format(DEVICE_TRACKER_DOMAIN, self.tracking_id) + return f"{DEVICE_TRACKER_DOMAIN}.{self.tracking_id}" @property def disabled(self): @@ -119,13 +126,11 @@ def _write_device(out, device): """Write a single device to file.""" attributes = [] if device.name is not None: - attributes.append( - 'name: {}'.format(device.name)) + attributes.append(f"name: {device.name}") if device.tracking_device_id is not None: - attributes.append( - 'tracking_device_id: {}'.format(device.tracking_device_id)) + attributes.append(f"tracking_device_id: {device.tracking_device_id}") if device.disabled: - attributes.append('disabled: True') + attributes.append("disabled: True") out.write(device.push_id) out.write(": {") @@ -145,33 +150,34 @@ def __init__(self, hass, app_name, topic, sandbox, cert_file): self.app_name = app_name self.sandbox = sandbox self.certificate = cert_file - self.yaml_path = hass.config.path(app_name + '_' + APNS_DEVICES) + self.yaml_path = hass.config.path(f"{app_name}_{APNS_DEVICES}") self.devices = {} self.device_states = {} self.topic = topic - if os.path.isfile(self.yaml_path): + + try: self.devices = { str(key): ApnsDevice( str(key), - value.get('name'), - value.get('tracking_device_id'), - value.get('disabled', False) + value.get("name"), + value.get("tracking_device_id"), + value.get("disabled", False), ) - for (key, value) in - load_yaml_config_file(self.yaml_path).items() + 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) + 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. + Listen for state change. Track device state change if a device has a tracking id specified. """ @@ -179,7 +185,7 @@ def device_state_changed_listener(self, entity_id, from_s, to_s): def write_devices(self): """Write all known devices to file.""" - with open(self.yaml_path, 'w+') as out: + with open(self.yaml_path, "w+") as out: for _, device in self.devices.items(): _write_device(out, device) @@ -189,14 +195,15 @@ def register(self, call): 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 + 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: + with open(self.yaml_path, "a") as out: _write_device(out, device) return True @@ -208,14 +215,10 @@ def register(self, call): 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) + self.certificate, use_sandbox=self.sandbox, use_alternative_port=False + ) device_state = kwargs.get(ATTR_TARGET) message_data = kwargs.get(ATTR_DATA) @@ -228,15 +231,16 @@ def send_message(self, message=None, **kwargs): elif isinstance(message, template_helper.Template): rendered_message = message.render() else: - rendered_message = '' + 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)) + 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 @@ -244,13 +248,11 @@ def send_message(self, message=None, **kwargs): if not device.disabled: state = None if device.tracking_device_id is not None: - state = self.device_states.get( - device.full_tracking_device_id) + 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) + apns.send_notification(push_id, payload, topic=self.topic) except Unregistered: logging.error("Device %s has unregistered", push_id) device_update = True diff --git a/homeassistant/components/apple_tv/__init__.py b/homeassistant/components/apple_tv/__init__.py index 0ebe29ed47c9b..aae4165fe5fe2 100644 --- a/homeassistant/components/apple_tv/__init__.py +++ b/homeassistant/components/apple_tv/__init__.py @@ -3,6 +3,8 @@ import logging from typing import Sequence, TypeVar, Union +from pyatv import AppleTVDevice, connect_to_apple_tv, scan_for_apple_tvs +from pyatv.exceptions import DeviceAuthenticationError import voluptuous as vol from homeassistant.components.discovery import SERVICE_APPLE_TV @@ -13,31 +15,31 @@ _LOGGER = logging.getLogger(__name__) -DOMAIN = 'apple_tv' +DOMAIN = "apple_tv" -SERVICE_SCAN = 'apple_tv_scan' -SERVICE_AUTHENTICATE = 'apple_tv_authenticate' +SERVICE_SCAN = "apple_tv_scan" +SERVICE_AUTHENTICATE = "apple_tv_authenticate" -ATTR_ATV = 'atv' -ATTR_POWER = 'power' +ATTR_ATV = "atv" +ATTR_POWER = "power" -CONF_LOGIN_ID = 'login_id' -CONF_START_OFF = 'start_off' -CONF_CREDENTIALS = 'credentials' +CONF_LOGIN_ID = "login_id" +CONF_START_OFF = "start_off" +CONF_CREDENTIALS = "credentials" -DEFAULT_NAME = 'Apple TV' +DEFAULT_NAME = "Apple TV" -DATA_APPLE_TV = 'data_apple_tv' -DATA_ENTITIES = 'data_apple_tv_entities' +DATA_APPLE_TV = "data_apple_tv" +DATA_ENTITIES = "data_apple_tv_entities" -KEY_CONFIG = 'apple_tv_configuring' +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' +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 +T = TypeVar("T") # pylint: disable=invalid-name # This version of ensure_list interprets an empty dict as no value @@ -48,22 +50,30 @@ def ensure_list(value: Union[T, Sequence[T]]) -> Sequence[T]: 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) +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, -}) +APPLE_TV_AUTHENTICATE_SCHEMA = vol.Schema({ATTR_ENTITY_ID: cv.entity_ids}) def request_configuration(hass, config, atv, credentials): @@ -72,55 +82,60 @@ def request_configuration(hass, config, atv, credentials): async def configuration_callback(callback_data): """Handle the submitted configuration.""" - from pyatv import exceptions - pin = callback_data.get('pin') + + 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), + f"Authentication succeeded!

" + f"Add the following to credentials: " + f"in your apple_tv configuration:

{credentials}", title=NOTIFICATION_AUTH_TITLE, - notification_id=NOTIFICATION_AUTH_ID) - except exceptions.DeviceAuthenticationError as ex: + notification_id=NOTIFICATION_AUTH_ID, + ) + except DeviceAuthenticationError as ex: hass.components.persistent_notification.async_create( - 'Authentication failed! Did you enter correct PIN?

' - 'Details: {0}'.format(ex), + f"Authentication failed! Did you enter correct PIN?

Details: {ex}", title=NOTIFICATION_AUTH_TITLE, - notification_id=NOTIFICATION_AUTH_ID) + 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'}] + "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): +async def scan_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) + + atvs = await 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)) + login_id = "Home Sharing disabled" + devices.append( + f"Name: {atv.name}
Host: {atv.address}
Login ID: {login_id}" + ) if not devices: - devices = ['No device(s) found'] + devices = ["No device(s) found"] + + found_devices = "

".join(devices) hass.components.persistent_notification.async_create( - 'The following devices were found:

' + - '

'.join(devices), + f"The following devices were found:

{found_devices}", title=NOTIFICATION_SCAN_TITLE, - notification_id=NOTIFICATION_SCAN_ID) + notification_id=NOTIFICATION_SCAN_ID, + ) async def async_setup(hass, config): @@ -133,12 +148,15 @@ async def async_service_handler(service): entity_ids = service.data.get(ATTR_ENTITY_ID) if service.service == SERVICE_SCAN: - hass.async_add_job(scan_for_apple_tvs, hass) + hass.async_add_job(scan_apple_tvs, hass) return if entity_ids: - devices = [device for device in hass.data[DATA_ENTITIES] - if device.entity_id in entity_ids] + devices = [ + device + for device in hass.data[DATA_ENTITIES] + if device.entity_id in entity_ids + ] else: devices = hass.data[DATA_ENTITIES] @@ -149,40 +167,46 @@ async def async_service_handler(service): atv = device.atv credentials = await atv.airplay.generate_credentials() await atv.airplay.load_credentials(credentials) - _LOGGER.debug('Generated new credentials: %s', credentials) + _LOGGER.debug("Generated new credentials: %s", credentials) await atv.airplay.start_authentication() - hass.async_add_job(request_configuration, - hass, config, atv, credentials) + 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 - }) + 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, loop=hass.loop) + await asyncio.wait(tasks) hass.services.async_register( - DOMAIN, SERVICE_SCAN, async_service_handler, - schema=APPLE_TV_SCAN_SCHEMA) + 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) + 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) @@ -192,23 +216,24 @@ async def _setup_atv(hass, hass_config, atv_config): if host in hass.data[DATA_APPLE_TV]: return - details = pyatv.AppleTVDevice(name, host, login_id) + details = AppleTVDevice(name, host, login_id) session = async_get_clientsession(hass) - atv = pyatv.connect_to_apple_tv(details, hass.loop, session=session) + atv = 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.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, "media_player", DOMAIN, atv_config, hass_config + ) + ) - hass.async_create_task(discovery.async_load_platform( - hass, 'remote', DOMAIN, atv_config, hass_config)) + hass.async_create_task( + discovery.async_load_platform(hass, "remote", DOMAIN, atv_config, hass_config) + ) class AppleTVPowerManager: diff --git a/homeassistant/components/apple_tv/manifest.json b/homeassistant/components/apple_tv/manifest.json index f21de7333767f..8ca42beab61ac 100644 --- a/homeassistant/components/apple_tv/manifest.json +++ b/homeassistant/components/apple_tv/manifest.json @@ -1,10 +1,9 @@ { "domain": "apple_tv", - "name": "Apple tv", - "documentation": "https://www.home-assistant.io/components/apple_tv", - "requirements": [ - "pyatv==0.3.12" - ], + "name": "Apple TV", + "documentation": "https://www.home-assistant.io/integrations/apple_tv", + "requirements": ["pyatv==0.3.13"], "dependencies": ["configurator"], + "after_dependencies": ["discovery"], "codeowners": [] } diff --git a/homeassistant/components/apple_tv/media_player.py b/homeassistant/components/apple_tv/media_player.py index 9698ef4c704a4..72e7d88b364a9 100644 --- a/homeassistant/components/apple_tv/media_player.py +++ b/homeassistant/components/apple_tv/media_player.py @@ -1,14 +1,33 @@ """Support for Apple TV media player.""" import logging -from homeassistant.components.media_player import MediaPlayerDevice +import pyatv.const as atv_const + +from homeassistant.components.media_player import MediaPlayerEntity 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) + 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) + 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 @@ -16,13 +35,20 @@ _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 +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): +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the Apple TV platform.""" if not discovery_info: return @@ -50,7 +76,7 @@ def on_hass_stop(event): async_add_entities([entity]) -class AppleTvDevice(MediaPlayerDevice): +class AppleTvDevice(MediaPlayerEntity): """Representation of an Apple TV device.""" def __init__(self, atv, name, power): @@ -88,16 +114,22 @@ def state(self): 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): + if state in ( + atv_const.PLAY_STATE_IDLE, + atv_const.PLAY_STATE_NO_MEDIA, + atv_const.PLAY_STATE_LOADING, + ): return STATE_IDLE - if state == const.PLAY_STATE_PLAYING: + if state == atv_const.PLAY_STATE_PLAYING: return STATE_PLAYING - if state in (const.PLAY_STATE_PAUSED, - const.PLAY_STATE_FAST_FORWARD, - const.PLAY_STATE_FAST_BACKWARD): + if state in ( + atv_const.PLAY_STATE_PAUSED, + atv_const.PLAY_STATE_FAST_FORWARD, + atv_const.PLAY_STATE_FAST_BACKWARD, + atv_const.PLAY_STATE_STOPPED, + ): # Catch fast forward/backward here so "play" is default action return STATE_PAUSED return STATE_STANDBY # Bad or unknown state? @@ -106,13 +138,12 @@ def state(self): def playstatus_update(self, updater, playing): """Print what is currently playing when it changes.""" self._playing = playing - self.async_schedule_update_ha_state() + self.async_write_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) + _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 @@ -120,19 +151,19 @@ def playstatus_error(self, updater, exception): # implemented here later. updater.start(initial_delay=10) self._playing = None - self.async_schedule_update_ha_state() + self.async_write_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: + if media_type == atv_const.MEDIA_TYPE_VIDEO: return MEDIA_TYPE_VIDEO - if media_type == const.MEDIA_TYPE_MUSIC: + if media_type == atv_const.MEDIA_TYPE_MUSIC: return MEDIA_TYPE_MUSIC - if media_type == const.MEDIA_TYPE_TV: + if media_type == atv_const.MEDIA_TYPE_TV: return MEDIA_TYPE_TVSHOW @property @@ -169,7 +200,7 @@ 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 (await self.atv.metadata.artwork()), "image/png" return None, None @@ -178,11 +209,11 @@ def media_title(self): """Title of current playing media.""" if self._playing: if self.state == STATE_IDLE: - return 'Nothing playing' + return "Nothing playing" title = self._playing.title - return title if title else 'No title' + return title if title else "No title" - return 'Establishing a connection to {0}...'.format(self._name) + return f"Establishing a connection to {self._name}..." @property def supported_features(self): @@ -198,62 +229,42 @@ async def async_turn_off(self): 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. + async def async_media_play_pause(self): + """Pause media on media player.""" + if not self._playing: + return + state = self.state + if state == STATE_PAUSED: + await self.atv.remote_control.play() + elif state == STATE_PLAYING: + await self.atv.remote_control.pause() - This method must be run in the event loop and returns a coroutine. - """ + async def async_media_play(self): + """Play media.""" if self._playing: - return self.atv.remote_control.play() - - def async_media_stop(self): - """Stop the media player. + await self.atv.remote_control.play() - This method must be run in the event loop and returns a coroutine. - """ + async def async_media_stop(self): + """Stop the media player.""" if self._playing: - return self.atv.remote_control.stop() - - def async_media_pause(self): - """Pause the media player. + await self.atv.remote_control.stop() - This method must be run in the event loop and returns a coroutine. - """ + async def async_media_pause(self): + """Pause the media player.""" if self._playing: - return self.atv.remote_control.pause() + await 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. - """ + async def async_media_next_track(self): + """Send next track command.""" if self._playing: - return self.atv.remote_control.next() - - def async_media_previous_track(self): - """Send previous track command. + await self.atv.remote_control.next() - This method must be run in the event loop and returns a coroutine. - """ + async def async_media_previous_track(self): + """Send previous track command.""" if self._playing: - return self.atv.remote_control.previous() - - def async_media_seek(self, position): - """Send seek command. + await self.atv.remote_control.previous() - This method must be run in the event loop and returns a coroutine. - """ + async def async_media_seek(self, position): + """Send seek command.""" if self._playing: - return self.atv.remote_control.set_position(position) + await self.atv.remote_control.set_position(position) diff --git a/homeassistant/components/apple_tv/remote.py b/homeassistant/components/apple_tv/remote.py index 2839e3a5324c8..4f935ba0ab88f 100644 --- a/homeassistant/components/apple_tv/remote.py +++ b/homeassistant/components/apple_tv/remote.py @@ -5,8 +5,7 @@ from . import ATTR_ATV, ATTR_POWER, DATA_APPLE_TV -async def async_setup_platform( - hass, config, async_add_entities, discovery_info=None): +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 @@ -18,7 +17,7 @@ async def async_setup_platform( async_add_entities([AppleTVRemote(atv, power, name)]) -class AppleTVRemote(remote.RemoteDevice): +class AppleTVRemote(remote.RemoteEntity): """Device that sends commands to an Apple TV.""" def __init__(self, atv, power, name): @@ -62,17 +61,10 @@ async def async_turn_off(self, **kwargs): """ self._power.set_power_on(False) - def async_send_command(self, command, **kwargs): - """Send a command to one device. + async def async_send_command(self, command, **kwargs): + """Send a command to one device.""" + for single_command in command: + if not hasattr(self._atv.remote_control, single_command): + continue - 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() + await getattr(self._atv.remote_control, single_command)() diff --git a/homeassistant/components/apple_tv/services.yaml b/homeassistant/components/apple_tv/services.yaml index 01e26a5630b76..af1e052fa33fd 100644 --- a/homeassistant/components/apple_tv/services.yaml +++ b/homeassistant/components/apple_tv/services.yaml @@ -1,5 +1,8 @@ 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.} + 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/apprise/__init__.py b/homeassistant/components/apprise/__init__.py new file mode 100644 index 0000000000000..6ffdaf690d940 --- /dev/null +++ b/homeassistant/components/apprise/__init__.py @@ -0,0 +1 @@ +"""The apprise component.""" diff --git a/homeassistant/components/apprise/manifest.json b/homeassistant/components/apprise/manifest.json new file mode 100644 index 0000000000000..2f22b9f63445f --- /dev/null +++ b/homeassistant/components/apprise/manifest.json @@ -0,0 +1,7 @@ +{ + "domain": "apprise", + "name": "Apprise", + "documentation": "https://www.home-assistant.io/integrations/apprise", + "requirements": ["apprise==0.8.5"], + "codeowners": ["@caronc"] +} diff --git a/homeassistant/components/apprise/notify.py b/homeassistant/components/apprise/notify.py new file mode 100644 index 0000000000000..0c8c5b26eeca4 --- /dev/null +++ b/homeassistant/components/apprise/notify.py @@ -0,0 +1,71 @@ +"""Apprise platform for notify component.""" +import logging + +import apprise +import voluptuous as vol + +from homeassistant.components.notify import ( + ATTR_TARGET, + ATTR_TITLE, + ATTR_TITLE_DEFAULT, + PLATFORM_SCHEMA, + BaseNotificationService, +) +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +CONF_FILE = "config" +CONF_URL = "url" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_URL): vol.All(cv.ensure_list, [str]), + vol.Optional(CONF_FILE): cv.string, + } +) + + +def get_service(hass, config, discovery_info=None): + """Get the Apprise notification service.""" + + # Create our object + a_obj = apprise.Apprise() + + if config.get(CONF_FILE): + # Sourced from a Configuration File + a_config = apprise.AppriseConfig() + if not a_config.add(config[CONF_FILE]): + _LOGGER.error("Invalid Apprise config url provided") + return None + + if not a_obj.add(a_config): + _LOGGER.error("Invalid Apprise config url provided") + return None + + if config.get(CONF_URL): + # Ordered list of URLs + if not a_obj.add(config[CONF_URL]): + _LOGGER.error("Invalid Apprise URL(s) supplied") + return None + + return AppriseNotificationService(a_obj) + + +class AppriseNotificationService(BaseNotificationService): + """Implement the notification service for Apprise.""" + + def __init__(self, a_obj): + """Initialize the service.""" + self.apprise = a_obj + + def send_message(self, message="", **kwargs): + """Send a message to a specified target. + + If no target/tags are specified, then services are notified as is + However, if any tags are specified, then they will be applied + to the notification causing filtering (if set up that way). + """ + targets = kwargs.get(ATTR_TARGET) + title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) + self.apprise.notify(body=message, title=title, tag=targets) 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..fb29a0ac8c714 --- /dev/null +++ b/homeassistant/components/aprs/device_tracker.py @@ -0,0 +1,183 @@ +"""Support for APRS device tracking.""" + +import logging +import threading + +import aprslib +from aprslib import ConnectionError as AprsConnectionError, LoginError +import geopy.distance +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(f"b/{sign.upper()}" for sign in callsigns) + + +def gps_accuracy(gps, posambiguity: int) -> int: + """Calculate the GPS accuracy based on APRS posambiguity.""" + + 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 = f"APRS position ambiguity must be 0-4, not '{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__() + + 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) + + try: + _LOGGER.info( + "Opening connection to %s with callsign %s.", self.host, self.callsign + ) + self.ais.connect() + self.start_complete( + True, f"Connected to {self.host} with callsign {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..c2f4fe52fa129 --- /dev/null +++ b/homeassistant/components/aprs/manifest.json @@ -0,0 +1,7 @@ +{ + "domain": "aprs", + "name": "APRS", + "documentation": "https://www.home-assistant.io/integrations/aprs", + "codeowners": ["@PhilRW"], + "requirements": ["aprslib==0.6.46", "geopy==1.21.0"] +} diff --git a/homeassistant/components/aqualogic/__init__.py b/homeassistant/components/aqualogic/__init__.py index 6571846321885..7ed38206a1190 100644 --- a/homeassistant/components/aqualogic/__init__.py +++ b/homeassistant/components/aqualogic/__init__.py @@ -1,29 +1,35 @@ """Support for AquaLogic devices.""" from datetime import timedelta import logging -import time import threading +import time +from aqualogic.core import AquaLogic import voluptuous as vol -from homeassistant.const import (CONF_HOST, CONF_PORT, - EVENT_HOMEASSISTANT_START, - EVENT_HOMEASSISTANT_STOP) +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' +DOMAIN = "aqualogic" +UPDATE_TOPIC = f"{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) +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): @@ -66,7 +72,6 @@ def data_changed(self, panel): def run(self): """Event thread.""" - from aqualogic.core import AquaLogic while True: self._panel = AquaLogic() diff --git a/homeassistant/components/aqualogic/manifest.json b/homeassistant/components/aqualogic/manifest.json index 40f1805d83abe..2a8e2a78cacdb 100644 --- a/homeassistant/components/aqualogic/manifest.json +++ b/homeassistant/components/aqualogic/manifest.json @@ -1,10 +1,7 @@ { "domain": "aqualogic", - "name": "Aqualogic", - "documentation": "https://www.home-assistant.io/components/aqualogic", - "requirements": [ - "aqualogic==1.0" - ], - "dependencies": [], + "name": "AquaLogic", + "documentation": "https://www.home-assistant.io/integrations/aqualogic", + "requirements": ["aqualogic==1.0"], "codeowners": [] } diff --git a/homeassistant/components/aqualogic/sensor.py b/homeassistant/components/aqualogic/sensor.py index 454cdbd7f6b50..a53a8c1d34882 100644 --- a/homeassistant/components/aqualogic/sensor.py +++ b/homeassistant/components/aqualogic/sensor.py @@ -5,7 +5,12 @@ from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_MONITORED_CONDITIONS, TEMP_CELSIUS, TEMP_FAHRENHEIT) + CONF_MONITORED_CONDITIONS, + POWER_WATT, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, + UNIT_PERCENTAGE, +) from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -15,38 +20,40 @@ _LOGGER = logging.getLogger(__name__) TEMP_UNITS = [TEMP_CELSIUS, TEMP_FAHRENHEIT] -PERCENT_UNITS = ['%', '%'] -SALT_UNITS = ['g/L', 'PPM'] -WATT_UNITS = ['W', 'W'] +PERCENT_UNITS = [UNIT_PERCENTAGE, UNIT_PERCENTAGE] +SALT_UNITS = ["g/L", "PPM"] +WATT_UNITS = [POWER_WATT, POWER_WATT] 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'] + "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)]) -}) +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): +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): + for sensor_type in config[CONF_MONITORED_CONDITIONS]: sensors.append(AquaLogicSensor(processor, sensor_type)) async_add_entities(sensors) @@ -69,7 +76,7 @@ def state(self): @property def name(self): """Return the name of the sensor.""" - return "AquaLogic {}".format(SENSOR_TYPES[self._type][0]) + return f"AquaLogic {SENSOR_TYPES[self._type][0]}" @property def unit_of_measurement(self): @@ -93,8 +100,11 @@ def icon(self): async def async_added_to_hass(self): """Register callbacks.""" - self.hass.helpers.dispatcher.async_dispatcher_connect( - UPDATE_TOPIC, self.async_update_callback) + self.async_on_remove( + self.hass.helpers.dispatcher.async_dispatcher_connect( + UPDATE_TOPIC, self.async_update_callback + ) + ) @callback def async_update_callback(self): @@ -102,4 +112,4 @@ def async_update_callback(self): panel = self._processor.panel if panel is not None: self._state = getattr(panel, self._type) - self.async_schedule_update_ha_state() + self.async_write_ha_state() diff --git a/homeassistant/components/aqualogic/switch.py b/homeassistant/components/aqualogic/switch.py index b8bd8e41244c7..94822eaef1851 100644 --- a/homeassistant/components/aqualogic/switch.py +++ b/homeassistant/components/aqualogic/switch.py @@ -1,11 +1,11 @@ """Support for AquaLogic switches.""" import logging +from aqualogic.core import States import voluptuous as vol -from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchDevice +from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchEntity from homeassistant.const import CONF_MONITORED_CONDITIONS -from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from . import DOMAIN, UPDATE_TOPIC @@ -13,61 +13,62 @@ _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', + "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)]), -}) +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): +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): + for switch_type in config[CONF_MONITORED_CONDITIONS]: switches.append(AquaLogicSwitch(processor, switch_type)) async_add_entities(switches) -class AquaLogicSwitch(SwitchDevice): +class AquaLogicSwitch(SwitchEntity): """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 + "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]) + return f"AquaLogic {SWITCH_TYPES[self._type]}" @property def should_poll(self): @@ -99,10 +100,8 @@ def turn_off(self, **kwargs): 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() + self.async_on_remove( + self.hass.helpers.dispatcher.async_dispatcher_connect( + UPDATE_TOPIC, self.async_write_ha_state + ) + ) diff --git a/homeassistant/components/aquostv/manifest.json b/homeassistant/components/aquostv/manifest.json index 16865905ae984..cd402b3db90db 100644 --- a/homeassistant/components/aquostv/manifest.json +++ b/homeassistant/components/aquostv/manifest.json @@ -1,10 +1,7 @@ { "domain": "aquostv", - "name": "Aquostv", - "documentation": "https://www.home-assistant.io/components/aquostv", - "requirements": [ - "sharp_aquos_rc==0.3.2" - ], - "dependencies": [], + "name": "Sharp Aquos TV", + "documentation": "https://www.home-assistant.io/integrations/aquostv", + "requirements": ["sharp_aquos_rc==0.3.2"], "codeowners": [] } diff --git a/homeassistant/components/aquostv/media_player.py b/homeassistant/components/aquostv/media_player.py index a4e88f02a59fb..35c7e2ae64698 100644 --- a/homeassistant/components/aquostv/media_player.py +++ b/homeassistant/components/aquostv/media_player.py @@ -1,69 +1,93 @@ """Support for interface with an Aquos TV.""" import logging +import sharp_aquos_rc import voluptuous as vol -from homeassistant.components.media_player import ( - MediaPlayerDevice, PLATFORM_SCHEMA) +from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerEntity 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) + 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) + 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_NAME = "Sharp Aquos TV" DEFAULT_PORT = 10002 -DEFAULT_USERNAME = 'admin' -DEFAULT_PASSWORD = 'password' +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'} +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') + name = config[CONF_NAME] + port = config[CONF_PORT] + username = config[CONF_USERNAME] + password = config[CONF_PASSWORD] + power_on_enabled = config["power_on_enabled"] if discovery_info: - _LOGGER.debug('%s', discovery_info) - vals = discovery_info.split(':') + _LOGGER.debug("%s", discovery_info) + vals = discovery_info.split(":") if len(vals) > 1: port = vals[1] @@ -72,7 +96,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): add_entities([SharpAquosTVDevice(name, remote, power_on_enabled)]) return True - host = config.get(CONF_HOST) + host = config[CONF_HOST] remote = sharp_aquos_rc.TV(host, port, username, password, 15, 1) add_entities([SharpAquosTVDevice(name, remote, power_on_enabled)]) @@ -81,6 +105,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): def _retry(func): """Handle query retries.""" + def wrapper(obj, *args, **kwargs): """Wrap all query functions.""" update_retries = 5 @@ -92,18 +117,19 @@ def wrapper(obj, *args, **kwargs): update_retries -= 1 if update_retries == 0: obj.set_state(STATE_OFF) + return wrapper -class SharpAquosTVDevice(MediaPlayerDevice): +class SharpAquosTVDevice(MediaPlayerEntity): """Representation of a Aquos TV.""" def __init__(self, name, remote, power_on_enabled=False): """Initialize the aquos device.""" - global SUPPORT_SHARPTV + self._supported_features = SUPPORT_SHARPTV self._power_on_enabled = power_on_enabled if self._power_on_enabled: - SUPPORT_SHARPTV = SUPPORT_SHARPTV | SUPPORT_TURN_ON + self._supported_features |= SUPPORT_TURN_ON # Save a reference to the imported class self._name = name # Assume that the TV is not muted @@ -173,7 +199,7 @@ def is_volume_muted(self): @property def supported_features(self): """Flag media player features that are supported.""" - return SUPPORT_SHARPTV + return self._supported_features @_retry def turn_off(self): diff --git a/homeassistant/components/arcam_fmj/__init__.py b/homeassistant/components/arcam_fmj/__init__.py new file mode 100644 index 0000000000000..aa11e66d49c56 --- /dev/null +++ b/homeassistant/components/arcam_fmj/__init__.py @@ -0,0 +1,169 @@ +"""Arcam component.""" +import asyncio +import logging + +from arcam.fmj import ConnectionFailed +from arcam.fmj.client import Client +import async_timeout +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PORT, + CONF_SCAN_INTERVAL, + CONF_ZONE, + EVENT_HOMEASSISTANT_STOP, + SERVICE_TURN_ON, +) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import ConfigType, HomeAssistantType + +from .const import ( + DEFAULT_NAME, + DEFAULT_PORT, + DEFAULT_SCAN_INTERVAL, + DOMAIN, + DOMAIN_DATA_CONFIG, + DOMAIN_DATA_ENTRIES, + SIGNAL_CLIENT_DATA, + SIGNAL_CLIENT_STARTED, + SIGNAL_CLIENT_STOPPED, +) + +_LOGGER = logging.getLogger(__name__) + + +def _optional_zone(value): + if value: + return ZONE_SCHEMA(value) + return ZONE_SCHEMA({}) + + +def _zone_name_validator(config): + for zone, zone_config in config[CONF_ZONE].items(): + if CONF_NAME not in zone_config: + zone_config[ + CONF_NAME + ] = f"{DEFAULT_NAME} ({config[CONF_HOST]}:{config[CONF_PORT]}) - {zone}" + return config + + +ZONE_SCHEMA = vol.Schema( + { + vol.Optional(CONF_NAME): cv.string, + vol.Optional(SERVICE_TURN_ON): cv.SERVICE_SCHEMA, + } +) + +DEVICE_SCHEMA = vol.Schema( + vol.All( + { + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.positive_int, + vol.Optional(CONF_ZONE, default={1: _optional_zone(None)}): { + vol.In([1, 2]): _optional_zone + }, + vol.Optional( + CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL + ): cv.positive_int, + }, + _zone_name_validator, + ) +) + +CONFIG_SCHEMA = vol.Schema( + {DOMAIN: vol.All(cv.ensure_list, [DEVICE_SCHEMA])}, extra=vol.ALLOW_EXTRA +) + + +async def async_setup(hass: HomeAssistantType, config: ConfigType): + """Set up the component.""" + hass.data[DOMAIN_DATA_ENTRIES] = {} + hass.data[DOMAIN_DATA_CONFIG] = {} + + for device in config[DOMAIN]: + hass.data[DOMAIN_DATA_CONFIG][(device[CONF_HOST], device[CONF_PORT])] = device + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={CONF_HOST: device[CONF_HOST], CONF_PORT: device[CONF_PORT]}, + ) + ) + + return True + + +async def async_setup_entry(hass: HomeAssistantType, entry: config_entries.ConfigEntry): + """Set up an access point from a config entry.""" + client = Client(entry.data[CONF_HOST], entry.data[CONF_PORT]) + + config = hass.data[DOMAIN_DATA_CONFIG].get( + (entry.data[CONF_HOST], entry.data[CONF_PORT]), + DEVICE_SCHEMA( + {CONF_HOST: entry.data[CONF_HOST], CONF_PORT: entry.data[CONF_PORT]} + ), + ) + + hass.data[DOMAIN_DATA_ENTRIES][entry.entry_id] = { + "client": client, + "config": config, + } + + asyncio.ensure_future(_run_client(hass, client, config[CONF_SCAN_INTERVAL])) + + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, "media_player") + ) + + return True + + +async def _run_client(hass, client, interval): + task = asyncio.Task.current_task() + run = True + + async def _stop(_): + nonlocal run + run = False + task.cancel() + await task + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop) + + def _listen(_): + hass.helpers.dispatcher.async_dispatcher_send(SIGNAL_CLIENT_DATA, client.host) + + while run: + try: + with async_timeout.timeout(interval): + await client.start() + + _LOGGER.debug("Client connected %s", client.host) + hass.helpers.dispatcher.async_dispatcher_send( + SIGNAL_CLIENT_STARTED, client.host + ) + + try: + with client.listen(_listen): + await client.process() + finally: + await client.stop() + + _LOGGER.debug("Client disconnected %s", client.host) + hass.helpers.dispatcher.async_dispatcher_send( + SIGNAL_CLIENT_STOPPED, client.host + ) + + except ConnectionFailed: + await asyncio.sleep(interval) + except asyncio.TimeoutError: + continue + except asyncio.CancelledError: + return + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception, aborting arcam client") + return diff --git a/homeassistant/components/arcam_fmj/config_flow.py b/homeassistant/components/arcam_fmj/config_flow.py new file mode 100644 index 0000000000000..a92a2ec52a621 --- /dev/null +++ b/homeassistant/components/arcam_fmj/config_flow.py @@ -0,0 +1,27 @@ +"""Config flow to configure the Arcam FMJ component.""" +from operator import itemgetter + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST, CONF_PORT + +from .const import DOMAIN + +_GETKEY = itemgetter(CONF_HOST, CONF_PORT) + + +@config_entries.HANDLERS.register(DOMAIN) +class ArcamFmjFlowHandler(config_entries.ConfigFlow): + """Handle a SimpliSafe config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + async def async_step_import(self, import_config): + """Import a config entry from configuration.yaml.""" + entries = self.hass.config_entries.async_entries(DOMAIN) + import_key = _GETKEY(import_config) + for entry in entries: + if _GETKEY(entry.data) == import_key: + return self.async_abort(reason="already_setup") + + return self.async_create_entry(title="Arcam FMJ", data=import_config) diff --git a/homeassistant/components/arcam_fmj/const.py b/homeassistant/components/arcam_fmj/const.py new file mode 100644 index 0000000000000..dc5a576acec06 --- /dev/null +++ b/homeassistant/components/arcam_fmj/const.py @@ -0,0 +1,13 @@ +"""Constants used for arcam.""" +DOMAIN = "arcam_fmj" + +SIGNAL_CLIENT_STARTED = "arcam.client_started" +SIGNAL_CLIENT_STOPPED = "arcam.client_stopped" +SIGNAL_CLIENT_DATA = "arcam.client_data" + +DEFAULT_PORT = 50000 +DEFAULT_NAME = "Arcam FMJ" +DEFAULT_SCAN_INTERVAL = 5 + +DOMAIN_DATA_ENTRIES = f"{DOMAIN}.entries" +DOMAIN_DATA_CONFIG = f"{DOMAIN}.config" diff --git a/homeassistant/components/arcam_fmj/manifest.json b/homeassistant/components/arcam_fmj/manifest.json new file mode 100644 index 0000000000000..c304d7bf35174 --- /dev/null +++ b/homeassistant/components/arcam_fmj/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "arcam_fmj", + "name": "Arcam FMJ Receivers", + "config_flow": false, + "documentation": "https://www.home-assistant.io/integrations/arcam_fmj", + "requirements": ["arcam-fmj==0.4.4"], + "codeowners": ["@elupus"] +} diff --git a/homeassistant/components/arcam_fmj/media_player.py b/homeassistant/components/arcam_fmj/media_player.py new file mode 100644 index 0000000000000..125b3bf96b151 --- /dev/null +++ b/homeassistant/components/arcam_fmj/media_player.py @@ -0,0 +1,339 @@ +"""Arcam media player.""" +import logging +from typing import Optional + +from arcam.fmj import DecodeMode2CH, DecodeModeMCH, IncomingAudioFormat, SourceCodes +from arcam.fmj.state import State + +from homeassistant import config_entries +from homeassistant.components.media_player import MediaPlayerEntity +from homeassistant.components.media_player.const import ( + MEDIA_TYPE_MUSIC, + 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_NAME, + CONF_ZONE, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, +) +from homeassistant.core import callback +from homeassistant.helpers.service import async_call_from_config +from homeassistant.helpers.typing import ConfigType, HomeAssistantType + +from .const import ( + DOMAIN, + DOMAIN_DATA_ENTRIES, + SIGNAL_CLIENT_DATA, + SIGNAL_CLIENT_STARTED, + SIGNAL_CLIENT_STOPPED, +) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistantType, + config_entry: config_entries.ConfigEntry, + async_add_entities, +): + """Set up the configuration entry.""" + data = hass.data[DOMAIN_DATA_ENTRIES][config_entry.entry_id] + client = data["client"] + config = data["config"] + + async_add_entities( + [ + ArcamFmj( + State(client, zone), + zone_config[CONF_NAME], + zone_config.get(SERVICE_TURN_ON), + ) + for zone, zone_config in config[CONF_ZONE].items() + ], + True, + ) + + return True + + +class ArcamFmj(MediaPlayerEntity): + """Representation of a media device.""" + + def __init__(self, state: State, name: str, turn_on: Optional[ConfigType]): + """Initialize device.""" + self._state = state + self._name = name + self._turn_on = turn_on + self._support = ( + SUPPORT_SELECT_SOURCE + | SUPPORT_VOLUME_SET + | SUPPORT_VOLUME_MUTE + | SUPPORT_VOLUME_STEP + | SUPPORT_TURN_OFF + ) + if state.zn == 1: + self._support |= SUPPORT_SELECT_SOUND_MODE + + def _get_2ch(self): + """Return if source is 2 channel or not.""" + audio_format, _ = self._state.get_incoming_audio_format() + return bool( + audio_format + in ( + IncomingAudioFormat.PCM, + IncomingAudioFormat.ANALOGUE_DIRECT, + IncomingAudioFormat.UNDETECTED, + None, + ) + ) + + @property + def device_info(self): + """Return a device description for device registry.""" + return { + "identifiers": {(DOMAIN, self._state.client.host, self._state.client.port)}, + "model": "FMJ", + "manufacturer": "Arcam", + } + + @property + def should_poll(self) -> bool: + """No need to poll.""" + return False + + @property + def name(self): + """Return the name of the controlled device.""" + return self._name + + @property + def state(self): + """Return the state of the device.""" + if self._state.get_power(): + return STATE_ON + return STATE_OFF + + @property + def supported_features(self): + """Flag media player features that are supported.""" + support = self._support + if self._state.get_power() is not None or self._turn_on: + support |= SUPPORT_TURN_ON + return support + + async def async_added_to_hass(self): + """Once registered, add listener for events.""" + await self._state.start() + + @callback + def _data(host): + if host == self._state.client.host: + self.async_write_ha_state() + + @callback + def _started(host): + if host == self._state.client.host: + self.async_schedule_update_ha_state(force_refresh=True) + + @callback + def _stopped(host): + if host == self._state.client.host: + self.async_schedule_update_ha_state(force_refresh=True) + + self.async_on_remove( + self.hass.helpers.dispatcher.async_dispatcher_connect( + SIGNAL_CLIENT_DATA, _data + ) + ) + + self.async_on_remove( + self.hass.helpers.dispatcher.async_dispatcher_connect( + SIGNAL_CLIENT_STARTED, _started + ) + ) + + self.async_on_remove( + self.hass.helpers.dispatcher.async_dispatcher_connect( + SIGNAL_CLIENT_STOPPED, _stopped + ) + ) + + async def async_update(self): + """Force update of state.""" + _LOGGER.debug("Update state %s", self.name) + await self._state.update() + + async def async_mute_volume(self, mute): + """Send mute command.""" + await self._state.set_mute(mute) + self.async_write_ha_state() + + async def async_select_source(self, source): + """Select a specific source.""" + try: + value = SourceCodes[source] + except KeyError: + _LOGGER.error("Unsupported source %s", source) + return + + await self._state.set_source(value) + self.async_write_ha_state() + + async def async_select_sound_mode(self, sound_mode): + """Select a specific source.""" + try: + if self._get_2ch(): + await self._state.set_decode_mode_2ch(DecodeMode2CH[sound_mode]) + else: + await self._state.set_decode_mode_mch(DecodeModeMCH[sound_mode]) + except KeyError: + _LOGGER.error("Unsupported sound_mode %s", sound_mode) + return + + self.async_write_ha_state() + + async def async_set_volume_level(self, volume): + """Set volume level, range 0..1.""" + await self._state.set_volume(round(volume * 99.0)) + self.async_write_ha_state() + + async def async_volume_up(self): + """Turn volume up for media player.""" + await self._state.inc_volume() + self.async_write_ha_state() + + async def async_volume_down(self): + """Turn volume up for media player.""" + await self._state.dec_volume() + self.async_write_ha_state() + + async def async_turn_on(self): + """Turn the media player on.""" + if self._state.get_power() is not None: + _LOGGER.debug("Turning on device using connection") + await self._state.set_power(True) + elif self._turn_on: + _LOGGER.debug("Turning on device using service call") + await async_call_from_config( + self.hass, + self._turn_on, + variables=None, + blocking=True, + validate_config=False, + ) + else: + _LOGGER.error("Unable to turn on") + + async def async_turn_off(self): + """Turn the media player off.""" + await self._state.set_power(False) + + @property + def source(self): + """Return the current input source.""" + value = self._state.get_source() + if value is None: + return None + return value.name + + @property + def source_list(self): + """List of available input sources.""" + return [x.name for x in self._state.get_source_list()] + + @property + def sound_mode(self): + """Name of the current sound mode.""" + if self._state.zn != 1: + return None + + if self._get_2ch(): + value = self._state.get_decode_mode_2ch() + else: + value = self._state.get_decode_mode_mch() + if value: + return value.name + return None + + @property + def sound_mode_list(self): + """List of available sound modes.""" + if self._state.zn != 1: + return None + + if self._get_2ch(): + return [x.name for x in DecodeMode2CH] + return [x.name for x in DecodeModeMCH] + + @property + def is_volume_muted(self): + """Boolean if volume is currently muted.""" + value = self._state.get_mute() + if value is None: + return None + return value + + @property + def volume_level(self): + """Volume level of device.""" + value = self._state.get_volume() + if value is None: + return None + return value / 99.0 + + @property + def media_content_type(self): + """Content type of current playing media.""" + source = self._state.get_source() + if source == SourceCodes.DAB: + value = MEDIA_TYPE_MUSIC + elif source == SourceCodes.FM: + value = MEDIA_TYPE_MUSIC + else: + value = None + return value + + @property + def media_channel(self): + """Channel currently playing.""" + source = self._state.get_source() + if source == SourceCodes.DAB: + value = self._state.get_dab_station() + elif source == SourceCodes.FM: + value = self._state.get_rds_information() + else: + value = None + return value + + @property + def media_artist(self): + """Artist of current playing media, music track only.""" + source = self._state.get_source() + if source == SourceCodes.DAB: + value = self._state.get_dls_pdt() + else: + value = None + return value + + @property + def media_title(self): + """Title of current playing media.""" + source = self._state.get_source() + if source is None: + return None + + channel = self.media_channel + + if channel: + value = f"{source.name} - {channel}" + else: + value = source.name + return value diff --git a/homeassistant/components/arcam_fmj/translations/bg.json b/homeassistant/components/arcam_fmj/translations/bg.json new file mode 100644 index 0000000000000..b78b8cbaa7b99 --- /dev/null +++ b/homeassistant/components/arcam_fmj/translations/bg.json @@ -0,0 +1,3 @@ +{ + "title": "Arcam FMJ" +} \ No newline at end of file diff --git a/homeassistant/components/arcam_fmj/translations/ca.json b/homeassistant/components/arcam_fmj/translations/ca.json new file mode 100644 index 0000000000000..b78b8cbaa7b99 --- /dev/null +++ b/homeassistant/components/arcam_fmj/translations/ca.json @@ -0,0 +1,3 @@ +{ + "title": "Arcam FMJ" +} \ No newline at end of file diff --git a/homeassistant/components/arcam_fmj/translations/da.json b/homeassistant/components/arcam_fmj/translations/da.json new file mode 100644 index 0000000000000..b78b8cbaa7b99 --- /dev/null +++ b/homeassistant/components/arcam_fmj/translations/da.json @@ -0,0 +1,3 @@ +{ + "title": "Arcam FMJ" +} \ No newline at end of file diff --git a/homeassistant/components/arcam_fmj/translations/de.json b/homeassistant/components/arcam_fmj/translations/de.json new file mode 100644 index 0000000000000..b78b8cbaa7b99 --- /dev/null +++ b/homeassistant/components/arcam_fmj/translations/de.json @@ -0,0 +1,3 @@ +{ + "title": "Arcam FMJ" +} \ No newline at end of file diff --git a/homeassistant/components/arcam_fmj/translations/en.json b/homeassistant/components/arcam_fmj/translations/en.json new file mode 100644 index 0000000000000..b78b8cbaa7b99 --- /dev/null +++ b/homeassistant/components/arcam_fmj/translations/en.json @@ -0,0 +1,3 @@ +{ + "title": "Arcam FMJ" +} \ No newline at end of file diff --git a/homeassistant/components/arcam_fmj/translations/es-419.json b/homeassistant/components/arcam_fmj/translations/es-419.json new file mode 100644 index 0000000000000..b78b8cbaa7b99 --- /dev/null +++ b/homeassistant/components/arcam_fmj/translations/es-419.json @@ -0,0 +1,3 @@ +{ + "title": "Arcam FMJ" +} \ No newline at end of file diff --git a/homeassistant/components/arcam_fmj/translations/es.json b/homeassistant/components/arcam_fmj/translations/es.json new file mode 100644 index 0000000000000..b78b8cbaa7b99 --- /dev/null +++ b/homeassistant/components/arcam_fmj/translations/es.json @@ -0,0 +1,3 @@ +{ + "title": "Arcam FMJ" +} \ No newline at end of file diff --git a/homeassistant/components/arcam_fmj/translations/fr.json b/homeassistant/components/arcam_fmj/translations/fr.json new file mode 100644 index 0000000000000..b78b8cbaa7b99 --- /dev/null +++ b/homeassistant/components/arcam_fmj/translations/fr.json @@ -0,0 +1,3 @@ +{ + "title": "Arcam FMJ" +} \ No newline at end of file diff --git a/homeassistant/components/arcam_fmj/translations/it.json b/homeassistant/components/arcam_fmj/translations/it.json new file mode 100644 index 0000000000000..b78b8cbaa7b99 --- /dev/null +++ b/homeassistant/components/arcam_fmj/translations/it.json @@ -0,0 +1,3 @@ +{ + "title": "Arcam FMJ" +} \ No newline at end of file diff --git a/homeassistant/components/arcam_fmj/translations/ko.json b/homeassistant/components/arcam_fmj/translations/ko.json new file mode 100644 index 0000000000000..b78b8cbaa7b99 --- /dev/null +++ b/homeassistant/components/arcam_fmj/translations/ko.json @@ -0,0 +1,3 @@ +{ + "title": "Arcam FMJ" +} \ No newline at end of file diff --git a/homeassistant/components/arcam_fmj/translations/lb.json b/homeassistant/components/arcam_fmj/translations/lb.json new file mode 100644 index 0000000000000..b78b8cbaa7b99 --- /dev/null +++ b/homeassistant/components/arcam_fmj/translations/lb.json @@ -0,0 +1,3 @@ +{ + "title": "Arcam FMJ" +} \ No newline at end of file diff --git a/homeassistant/components/arcam_fmj/translations/nl.json b/homeassistant/components/arcam_fmj/translations/nl.json new file mode 100644 index 0000000000000..b78b8cbaa7b99 --- /dev/null +++ b/homeassistant/components/arcam_fmj/translations/nl.json @@ -0,0 +1,3 @@ +{ + "title": "Arcam FMJ" +} \ No newline at end of file diff --git a/homeassistant/components/arcam_fmj/translations/nn.json b/homeassistant/components/arcam_fmj/translations/nn.json new file mode 100644 index 0000000000000..b78b8cbaa7b99 --- /dev/null +++ b/homeassistant/components/arcam_fmj/translations/nn.json @@ -0,0 +1,3 @@ +{ + "title": "Arcam FMJ" +} \ No newline at end of file diff --git a/homeassistant/components/arcam_fmj/translations/no.json b/homeassistant/components/arcam_fmj/translations/no.json new file mode 100644 index 0000000000000..d8a4c45301515 --- /dev/null +++ b/homeassistant/components/arcam_fmj/translations/no.json @@ -0,0 +1,3 @@ +{ + "title": "" +} \ No newline at end of file diff --git a/homeassistant/components/arcam_fmj/translations/pl.json b/homeassistant/components/arcam_fmj/translations/pl.json new file mode 100644 index 0000000000000..b78b8cbaa7b99 --- /dev/null +++ b/homeassistant/components/arcam_fmj/translations/pl.json @@ -0,0 +1,3 @@ +{ + "title": "Arcam FMJ" +} \ No newline at end of file diff --git a/homeassistant/components/arcam_fmj/translations/pt-BR.json b/homeassistant/components/arcam_fmj/translations/pt-BR.json new file mode 100644 index 0000000000000..b78b8cbaa7b99 --- /dev/null +++ b/homeassistant/components/arcam_fmj/translations/pt-BR.json @@ -0,0 +1,3 @@ +{ + "title": "Arcam FMJ" +} \ No newline at end of file diff --git a/homeassistant/components/arcam_fmj/translations/ru.json b/homeassistant/components/arcam_fmj/translations/ru.json new file mode 100644 index 0000000000000..b78b8cbaa7b99 --- /dev/null +++ b/homeassistant/components/arcam_fmj/translations/ru.json @@ -0,0 +1,3 @@ +{ + "title": "Arcam FMJ" +} \ No newline at end of file diff --git a/homeassistant/components/arcam_fmj/translations/sl.json b/homeassistant/components/arcam_fmj/translations/sl.json new file mode 100644 index 0000000000000..b78b8cbaa7b99 --- /dev/null +++ b/homeassistant/components/arcam_fmj/translations/sl.json @@ -0,0 +1,3 @@ +{ + "title": "Arcam FMJ" +} \ No newline at end of file diff --git a/homeassistant/components/arcam_fmj/translations/sv.json b/homeassistant/components/arcam_fmj/translations/sv.json new file mode 100644 index 0000000000000..b78b8cbaa7b99 --- /dev/null +++ b/homeassistant/components/arcam_fmj/translations/sv.json @@ -0,0 +1,3 @@ +{ + "title": "Arcam FMJ" +} \ No newline at end of file diff --git a/homeassistant/components/arcam_fmj/translations/zh-Hant.json b/homeassistant/components/arcam_fmj/translations/zh-Hant.json new file mode 100644 index 0000000000000..b78b8cbaa7b99 --- /dev/null +++ b/homeassistant/components/arcam_fmj/translations/zh-Hant.json @@ -0,0 +1,3 @@ +{ + "title": "Arcam FMJ" +} \ No newline at end of file diff --git a/homeassistant/components/arduino/__init__.py b/homeassistant/components/arduino/__init__.py index a6841e075643e..e87a625522e11 100644 --- a/homeassistant/components/arduino/__init__.py +++ b/homeassistant/components/arduino/__init__.py @@ -1,56 +1,57 @@ """Support for Arduino boards running with the Firmata firmware.""" import logging +from PyMata.pymata import PyMata +import serial import voluptuous as vol from homeassistant.const import ( - EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) -from homeassistant.const import CONF_PORT + CONF_PORT, + EVENT_HOMEASSISTANT_START, + EVENT_HOMEASSISTANT_STOP, +) import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -BOARD = None +DOMAIN = "arduino" -DOMAIN = 'arduino' - -CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ - vol.Required(CONF_PORT): cv.string, - }), -}, extra=vol.ALLOW_EXTRA) +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) + 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: + 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") + _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() + 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) + hass.data[DOMAIN] = board return True @@ -60,27 +61,22 @@ class ArduinoBoard: 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) + 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.""" diff --git a/homeassistant/components/arduino/manifest.json b/homeassistant/components/arduino/manifest.json index cf21cbe87eafe..4266d55926b66 100644 --- a/homeassistant/components/arduino/manifest.json +++ b/homeassistant/components/arduino/manifest.json @@ -1,12 +1,7 @@ { "domain": "arduino", "name": "Arduino", - "documentation": "https://www.home-assistant.io/components/arduino", - "requirements": [ - "PyMata==2.14" - ], - "dependencies": [], - "codeowners": [ - "@fabaff" - ] + "documentation": "https://www.home-assistant.io/integrations/arduino", + "requirements": ["PyMata==2.20"], + "codeowners": ["@fabaff"] } diff --git a/homeassistant/components/arduino/sensor.py b/homeassistant/components/arduino/sensor.py index 0cc6e006b890c..8da656d217abe 100644 --- a/homeassistant/components/arduino/sensor.py +++ b/homeassistant/components/arduino/sensor.py @@ -4,52 +4,49 @@ 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 +from homeassistant.helpers.entity import Entity + +from . import DOMAIN _LOGGER = logging.getLogger(__name__) -CONF_PINS = 'pins' -CONF_TYPE = 'analog' +CONF_PINS = "pins" +CONF_TYPE = "analog" -PIN_SCHEMA = vol.Schema({ - vol.Required(CONF_NAME): cv.string, -}) +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}), -}) +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 + board = hass.data[DOMAIN] - pins = config.get(CONF_PINS) + pins = config[CONF_PINS] sensors = [] for pinnum, pin in pins.items(): - sensors.append(ArduinoSensor(pin.get(CONF_NAME), pinnum, CONF_TYPE)) + sensors.append(ArduinoSensor(pin.get(CONF_NAME), pinnum, CONF_TYPE, board)) add_entities(sensors) class ArduinoSensor(Entity): """Representation of an Arduino Sensor.""" - def __init__(self, name, pin, pin_type): + def __init__(self, name, pin, pin_type, board): """Initialize the sensor.""" self._pin = pin self._name = name self.pin_type = pin_type - self.direction = 'in' + self.direction = "in" self._value = None - arduino.BOARD.set_mode(self._pin, self.direction, self.pin_type) + board.set_mode(self._pin, self.direction, self.pin_type) + self._board = board @property def state(self): @@ -63,4 +60,4 @@ def name(self): def update(self): """Get the latest value from the pin.""" - self._value = arduino.BOARD.get_analog_inputs()[self._pin][1] + self._value = self._board.get_analog_inputs()[self._pin][1] diff --git a/homeassistant/components/arduino/switch.py b/homeassistant/components/arduino/switch.py index 92e91196a9aff..99b73c86fdbc9 100644 --- a/homeassistant/components/arduino/switch.py +++ b/homeassistant/components/arduino/switch.py @@ -3,65 +3,64 @@ import voluptuous as vol -from homeassistant.components import arduino -from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA) +from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchEntity from homeassistant.const import CONF_NAME import homeassistant.helpers.config_validation as cv +from . import DOMAIN + _LOGGER = logging.getLogger(__name__) -CONF_PINS = 'pins' -CONF_TYPE = 'digital' -CONF_NEGATE = 'negate' -CONF_INITIAL = 'initial' +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, -}) +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}), -}) +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 + board = hass.data[DOMAIN] - pins = config.get(CONF_PINS) + pins = config[CONF_PINS] switches = [] for pinnum, pin in pins.items(): - switches.append(ArduinoSwitch(pinnum, pin)) + switches.append(ArduinoSwitch(pinnum, pin, board)) add_entities(switches) -class ArduinoSwitch(SwitchDevice): +class ArduinoSwitch(SwitchEntity): """Representation of an Arduino switch.""" - def __init__(self, pin, options): + def __init__(self, pin, options, board): """Initialize the Pin.""" self._pin = pin - self._name = options.get(CONF_NAME) + self._name = options[CONF_NAME] self.pin_type = CONF_TYPE - self.direction = 'out' + self.direction = "out" - self._state = options.get(CONF_INITIAL) + self._state = options[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 + if options[CONF_NEGATE]: + self.turn_on_handler = board.set_digital_out_low + self.turn_off_handler = 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 + self.turn_on_handler = board.set_digital_out_high + self.turn_off_handler = board.set_digital_out_low - arduino.BOARD.set_mode(self._pin, self.direction, self.pin_type) + board.set_mode(self._pin, self.direction, self.pin_type) (self.turn_on_handler if self._state else self.turn_off_handler)(pin) @property diff --git a/homeassistant/components/arest/binary_sensor.py b/homeassistant/components/arest/binary_sensor.py index 3fd669a2bba3a..3cd9038f1a89b 100644 --- a/homeassistant/components/arest/binary_sensor.py +++ b/homeassistant/components/arest/binary_sensor.py @@ -1,40 +1,51 @@ """Support for an exposed aREST RESTful API of a device.""" -import logging from datetime import timedelta +import logging import requests import voluptuous as vol from homeassistant.components.binary_sensor import ( - BinarySensorDevice, PLATFORM_SCHEMA, DEVICE_CLASSES_SCHEMA) + DEVICE_CLASSES_SCHEMA, + PLATFORM_SCHEMA, + BinarySensorEntity, +) from homeassistant.const import ( - CONF_RESOURCE, CONF_PIN, CONF_NAME, CONF_DEVICE_CLASS) -from homeassistant.util import Throttle + CONF_DEVICE_CLASS, + CONF_NAME, + CONF_PIN, + CONF_RESOURCE, + HTTP_OK, +) import homeassistant.helpers.config_validation as cv +from homeassistant.util import Throttle _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, -}) +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) + resource = config[CONF_RESOURCE] + pin = config[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") + _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) @@ -42,12 +53,21 @@ def setup_platform(hass, config, add_entities, discovery_info=None): arest = ArestData(resource, pin) - add_entities([ArestBinarySensor( - arest, resource, config.get(CONF_NAME, response[CONF_NAME]), - device_class, pin)], True) - - -class ArestBinarySensor(BinarySensorDevice): + add_entities( + [ + ArestBinarySensor( + arest, + resource, + config.get(CONF_NAME, response[CONF_NAME]), + device_class, + pin, + ) + ], + True, + ) + + +class ArestBinarySensor(BinarySensorEntity): """Implement an aREST binary sensor for a pin.""" def __init__(self, arest, resource, name, device_class, pin): @@ -59,9 +79,8 @@ def __init__(self, arest, resource, name, device_class, pin): 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: + request = requests.get(f"{self._resource}/mode/{self._pin}/i", timeout=10) + if request.status_code != HTTP_OK: _LOGGER.error("Can't set mode of %s", self._resource) @property @@ -72,7 +91,7 @@ def name(self): @property def is_on(self): """Return true if the binary sensor is on.""" - return bool(self.arest.data.get('state')) + return bool(self.arest.data.get("state")) @property def device_class(self): @@ -97,8 +116,7 @@ def __init__(self, resource, pin): 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']} + response = requests.get(f"{self._resource}/digital/{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 index d5bcf92a39dc4..9ed57d2d982f4 100644 --- a/homeassistant/components/arest/manifest.json +++ b/homeassistant/components/arest/manifest.json @@ -1,10 +1,6 @@ { "domain": "arest", - "name": "Arest", - "documentation": "https://www.home-assistant.io/components/arest", - "requirements": [], - "dependencies": [], - "codeowners": [ - "@fabaff" - ] + "name": "aREST", + "documentation": "https://www.home-assistant.io/integrations/arest", + "codeowners": ["@fabaff"] } diff --git a/homeassistant/components/arest/sensor.py b/homeassistant/components/arest/sensor.py index fc443cd60b652..7e50b1df8fffc 100644 --- a/homeassistant/components/arest/sensor.py +++ b/homeassistant/components/arest/sensor.py @@ -1,55 +1,67 @@ """Support for an exposed aREST RESTful API of a device.""" -import logging from datetime import timedelta +import logging 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) + CONF_MONITORED_VARIABLES, + CONF_NAME, + CONF_RESOURCE, + CONF_UNIT_OF_MEASUREMENT, + CONF_VALUE_TEMPLATE, + HTTP_OK, +) from homeassistant.exceptions import TemplateError +import homeassistant.helpers.config_validation as cv 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}), -}) +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) + resource = config[CONF_RESOURCE] + var_conf = config[CONF_MONITORED_VARIABLES] + pins = config[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") + _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) @@ -66,7 +78,7 @@ def make_renderer(value_template): def _render(value): try: - return value_template.async_render({'value': value}) + return value_template.async_render({"value": value}) except TemplateError: _LOGGER.exception("Error parsing value") return value @@ -77,25 +89,37 @@ def _render(value): if var_conf is not None: for variable, var_data in var_conf.items(): - if variable not in response['variables']: + 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)) + 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)) + 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) @@ -103,12 +127,21 @@ def _render(value): 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): + 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._name = f"{location.title()} {name.title()}" self._variable = variable self._pin = pin self._state = None @@ -116,9 +149,8 @@ def __init__(self, arest, resource, location, name, variable=None, 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: + request = requests.get(f"{self._resource}/mode/{self._pin}/i", timeout=10) + if request.status_code != HTTP_OK: _LOGGER.error("Can't set mode of %s", self._resource) @property @@ -136,11 +168,10 @@ def state(self): """Return the state of the sensor.""" values = self.arest.data - if 'error' in values: - return values['error'] + if "error" in values: + return values["error"] - value = self._renderer( - values.get('value', values.get(self._variable, None))) + value = self._renderer(values.get("value", values.get(self._variable, None))) return value def update(self): @@ -169,17 +200,19 @@ def update(self): try: if self._pin is None: response = requests.get(self._resource, timeout=10) - self.data = response.json()['variables'] + 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']} + if str(self._pin[0]) == "A": + response = requests.get( + f"{self._resource}/analog/{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']} + response = requests.get( + f"{self._resource}/digital/{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) diff --git a/homeassistant/components/arest/switch.py b/homeassistant/components/arest/switch.py index 717acc2f33679..ddd6b51f76d32 100644 --- a/homeassistant/components/arest/switch.py +++ b/homeassistant/components/arest/switch.py @@ -5,70 +5,88 @@ import requests import voluptuous as vol -from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA) -from homeassistant.const import (CONF_NAME, CONF_RESOURCE) +from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchEntity +from homeassistant.const import CONF_NAME, CONF_RESOURCE, HTTP_OK 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}), -}) +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) + resource = config[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") + _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) + pins = config[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) + dev.append( + ArestSwitchPin( + resource, + config.get(CONF_NAME, response.json()[CONF_NAME]), + pin.get(CONF_NAME), + pinnum, + pin[CONF_INVERT], + ) + ) + + functions = config[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)) + dev.append( + ArestSwitchFunction( + resource, + config.get(CONF_NAME, response.json()[CONF_NAME]), + func.get(CONF_NAME), + funcname, + ) + ) add_entities(dev) -class ArestSwitchBase(SwitchDevice): +class ArestSwitchBase(SwitchEntity): """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._name = f"{location.title()} {name.title()}" self._state = None self._available = True @@ -96,15 +114,14 @@ def __init__(self, resource, location, name, func): super().__init__(resource, location, name) self._func = func - request = requests.get( - '{}/{}'.format(self._resource, self._func), timeout=10) + request = requests.get(f"{self._resource}/{self._func}", timeout=10) - if request.status_code != 200: + if request.status_code != HTTP_OK: _LOGGER.error("Can't find function") return try: - request.json()['return_value'] + request.json()["return_value"] except KeyError: _LOGGER.error("No return_value received") except ValueError: @@ -113,33 +130,32 @@ def __init__(self, resource, location, name, func): def turn_on(self, **kwargs): """Turn the device on.""" request = requests.get( - '{}/{}'.format(self._resource, self._func), timeout=10, - params={'params': '1'}) + f"{self._resource}/{self._func}", timeout=10, params={"params": "1"} + ) - if request.status_code == 200: + if request.status_code == HTTP_OK: self._state = True else: - _LOGGER.error( - "Can't turn on function %s at %s", self._func, self._resource) + _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'}) + f"{self._resource}/{self._func}", timeout=10, params={"params": "0"} + ) - if request.status_code == 200: + if request.status_code == HTTP_OK: self._state = False else: _LOGGER.error( - "Can't turn off function %s at %s", self._func, self._resource) + "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 + request = requests.get(f"{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) @@ -155,9 +171,8 @@ def __init__(self, resource, location, name, pin, invert): self._pin = pin self.invert = invert - request = requests.get( - '{}/mode/{}/o'.format(self._resource, self._pin), timeout=10) - if request.status_code != 200: + request = requests.get(f"{self._resource}/mode/{self._pin}/o", timeout=10) + if request.status_code != HTTP_OK: _LOGGER.error("Can't set mode") self._available = False @@ -165,35 +180,30 @@ 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: + f"{self._resource}/digital/{self._pin}/{turn_on_payload}", timeout=10 + ) + if request.status_code == HTTP_OK: self._state = True else: - _LOGGER.error( - "Can't turn on pin %s at %s", self._pin, self._resource) + _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: + f"{self._resource}/digital/{self._pin}/{turn_off_payload}", timeout=10 + ) + if request.status_code == HTTP_OK: self._state = False else: - _LOGGER.error( - "Can't turn off pin %s at %s", self._pin, self._resource) + _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) + request = requests.get(f"{self._resource}/digital/{self._pin}", timeout=10) status_value = int(self.invert) - self._state = request.json()['return_value'] != status_value + self._state = request.json()["return_value"] != status_value self._available = True except requests.exceptions.ConnectionError: _LOGGER.warning("No route to device %s", self._resource) diff --git a/homeassistant/components/arlo/__init__.py b/homeassistant/components/arlo/__init__.py index 38230c2f05fe8..3cc0ec607a5e8 100644 --- a/homeassistant/components/arlo/__init__.py +++ b/homeassistant/components/arlo/__init__.py @@ -1,58 +1,60 @@ """Support for Netgear Arlo IP cameras.""" -import logging from datetime import timedelta +import logging +from pyarlo import PyArlo +from requests.exceptions import ConnectTimeout, HTTPError import voluptuous as vol -from requests.exceptions import HTTPError, ConnectTimeout +from homeassistant.const import CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME 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 +from homeassistant.helpers.event import track_time_interval _LOGGER = logging.getLogger(__name__) ATTRIBUTION = "Data provided by arlo.netgear.com" -DATA_ARLO = 'data_arlo' -DEFAULT_BRAND = 'Netgear Arlo' -DOMAIN = 'arlo' +DATA_ARLO = "data_arlo" +DEFAULT_BRAND = "Netgear Arlo" +DOMAIN = "arlo" -NOTIFICATION_ID = 'arlo_notification' -NOTIFICATION_TITLE = 'Arlo Component Setup' +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) +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) + username = conf[CONF_USERNAME] + password = conf[CONF_PASSWORD] + scan_interval = conf[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) + 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() @@ -65,22 +67,20 @@ def setup(hass, config): 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), + f"Error: {ex}
You will need to restart hass after fixing.", title=NOTIFICATION_TITLE, - notification_id=NOTIFICATION_ID) + 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) + 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) + hass.services.register(DOMAIN, "update", hub_refresh) # register scan interval for ArloHub track_time_interval(hass, hub_refresh, scan_interval) diff --git a/homeassistant/components/arlo/alarm_control_panel.py b/homeassistant/components/arlo/alarm_control_panel.py index a7addfb86eac7..47328d5cbc223 100644 --- a/homeassistant/components/arlo/alarm_control_panel.py +++ b/homeassistant/components/arlo/alarm_control_panel.py @@ -4,10 +4,21 @@ import voluptuous as vol from homeassistant.components.alarm_control_panel import ( - PLATFORM_SCHEMA, AlarmControlPanel) + PLATFORM_SCHEMA, + AlarmControlPanelEntity, +) +from homeassistant.components.alarm_control_panel.const import ( + SUPPORT_ALARM_ARM_AWAY, + SUPPORT_ALARM_ARM_HOME, + SUPPORT_ALARM_ARM_NIGHT, +) from homeassistant.const import ( - ATTR_ATTRIBUTION, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, - STATE_ALARM_ARMED_NIGHT, STATE_ALARM_DISARMED) + 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 @@ -16,21 +27,23 @@ _LOGGER = logging.getLogger(__name__) -ARMED = 'armed' +ARMED = "armed" -CONF_HOME_MODE_NAME = 'home_mode_name' -CONF_AWAY_MODE_NAME = 'away_mode_name' -CONF_NIGHT_MODE_NAME = 'night_mode_name' +CONF_HOME_MODE_NAME = "home_mode_name" +CONF_AWAY_MODE_NAME = "away_mode_name" +CONF_NIGHT_MODE_NAME = "night_mode_name" -DISARMED = 'disarmed' +DISARMED = "disarmed" -ICON = 'mdi:security' +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, -}) +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): @@ -40,17 +53,20 @@ def setup_platform(hass, config, add_entities, discovery_info=None): 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) + home_mode_name = config[CONF_HOME_MODE_NAME] + away_mode_name = config[CONF_AWAY_MODE_NAME] + night_mode_name = config[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)) + base_stations.append( + ArloBaseStation( + base_station, home_mode_name, away_mode_name, night_mode_name + ) + ) add_entities(base_stations, True) -class ArloBaseStation(AlarmControlPanel): +class ArloBaseStation(AlarmControlPanelEntity): """Representation of an Arlo Alarm Control Panel.""" def __init__(self, data, home_mode_name, away_mode_name, night_mode_name): @@ -68,8 +84,11 @@ def icon(self): async def async_added_to_hass(self): """Register callbacks.""" - async_dispatcher_connect( - self.hass, SIGNAL_UPDATE_ARLO, self._update_callback) + self.async_on_remove( + async_dispatcher_connect( + self.hass, SIGNAL_UPDATE_ARLO, self._update_callback + ) + ) @callback def _update_callback(self): @@ -81,6 +100,11 @@ def state(self): """Return the state of the device.""" return self._state + @property + def supported_features(self) -> int: + """Return the list of supported features.""" + return SUPPORT_ALARM_ARM_HOME | SUPPORT_ALARM_ARM_AWAY | SUPPORT_ALARM_ARM_NIGHT + def update(self): """Update the state of the device.""" _LOGGER.debug("Updating Arlo Alarm Control Panel %s", self.name) @@ -90,19 +114,19 @@ def update(self): else: self._state = None - async def async_alarm_disarm(self, code=None): + def alarm_disarm(self, code=None): """Send disarm command.""" self._base_station.mode = DISARMED - async def async_alarm_arm_away(self, code=None): + def 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): + def 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): + def alarm_arm_night(self, code=None): """Send arm night command. Uses custom mode.""" self._base_station.mode = self._night_mode_name @@ -116,7 +140,7 @@ def device_state_attributes(self): """Return the state attributes.""" return { ATTR_ATTRIBUTION: ATTRIBUTION, - 'device_id': self._base_station.device_id + "device_id": self._base_station.device_id, } def _get_state_from_mode(self, mode): diff --git a/homeassistant/components/arlo/camera.py b/homeassistant/components/arlo/camera.py index 166e0781044c1..6f7e3796309d2 100644 --- a/homeassistant/components/arlo/camera.py +++ b/homeassistant/components/arlo/camera.py @@ -1,12 +1,12 @@ """Support for Netgear Arlo IP cameras.""" import logging +from haffmpeg.camera import CameraMjpeg 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 @@ -15,30 +15,26 @@ _LOGGER = logging.getLogger(__name__) -ARLO_MODE_ARMED = 'armed' -ARLO_MODE_DISARMED = 'disarmed' +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' +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' +CONF_FFMPEG_ARGUMENTS = "ffmpeg_arguments" +DEFAULT_ARGUMENTS = "-pred 1" -POWERSAVE_MODE_MAPPING = { - 1: 'best_battery_life', - 2: 'optimized', - 3: 'best_video' -} +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, -}) +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): @@ -72,34 +68,37 @@ def camera_image(self): 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() + self.async_on_remove( + async_dispatcher_connect( + self.hass, SIGNAL_UPDATE_ARLO, self.async_write_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 + video = await self.hass.async_add_executor_job( + getattr, 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) + error_msg = ( + f"Video not found for {self.name}. " + f"Is it older than {self._camera.min_days_vdo_cache} days?" + ) _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) + 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) + self.hass, + request, + stream_reader, + self._ffmpeg.ffmpeg_stream_content_type, + ) finally: await stream.close() @@ -112,17 +111,21 @@ def name(self): def device_state_attributes(self): """Return the state attributes.""" return { - name: value for name, value in ( + 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_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 + ) + if value is not None } @property diff --git a/homeassistant/components/arlo/manifest.json b/homeassistant/components/arlo/manifest.json index 35803d0d4f6af..41d4fc40e5f95 100644 --- a/homeassistant/components/arlo/manifest.json +++ b/homeassistant/components/arlo/manifest.json @@ -1,12 +1,8 @@ { "domain": "arlo", "name": "Arlo", - "documentation": "https://www.home-assistant.io/components/arlo", - "requirements": [ - "pyarlo==0.2.3" - ], - "dependencies": [ - "ffmpeg" - ], + "documentation": "https://www.home-assistant.io/integrations/arlo", + "requirements": ["pyarlo==0.2.3"], + "dependencies": ["ffmpeg"], "codeowners": [] } diff --git a/homeassistant/components/arlo/sensor.py b/homeassistant/components/arlo/sensor.py index f83caec386b84..9942ce687f43e 100644 --- a/homeassistant/components/arlo/sensor.py +++ b/homeassistant/components/arlo/sensor.py @@ -5,8 +5,14 @@ from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - ATTR_ATTRIBUTION, CONF_MONITORED_CONDITIONS, DEVICE_CLASS_HUMIDITY, - DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS) + ATTR_ATTRIBUTION, + CONCENTRATION_PARTS_PER_MILLION, + CONF_MONITORED_CONDITIONS, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_TEMPERATURE, + TEMP_CELSIUS, + UNIT_PERCENTAGE, +) from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -19,20 +25,23 @@ # 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'] + "last_capture": ["Last", None, "run-fast"], + "total_cameras": ["Arlo Cameras", None, "video"], + "captured_today": ["Captured Today", None, "file-video"], + "battery_level": ["Battery Level", UNIT_PERCENTAGE, "battery-50"], + "signal_strength": ["Signal Strength", None, "signal"], + "temperature": ["Temperature", TEMP_CELSIUS, "thermometer"], + "humidity": ["Humidity", UNIT_PERCENTAGE, "water-percent"], + "air_quality": ["Air Quality", CONCENTRATION_PARTS_PER_MILLION, "biohazard"], } -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)): - vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), -}) +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): @@ -42,24 +51,23 @@ def setup_platform(hass, config, add_entities, discovery_info=None): 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)) + for sensor_type in config[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'): + if sensor_type in ("temperature", "humidity", "air_quality"): continue - name = '{0} {1}'.format( - SENSOR_TYPES[sensor_type][0], camera.name) + name = f"{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) + if ( + sensor_type in ("temperature", "humidity", "air_quality") + and base_station.model_id == "ABC1000" + ): + name = f"{SENSOR_TYPES[sensor_type][0]} {base_station.name}" sensors.append(ArloSensor(name, base_station, sensor_type)) add_entities(sensors, True) @@ -70,12 +78,12 @@ class ArloSensor(Entity): def __init__(self, name, device, sensor_type): """Initialize an Arlo sensor.""" - _LOGGER.debug('ArloSensor created for %s', name) + _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]) + self._icon = f"mdi:{SENSOR_TYPES.get(self._sensor_type)[2]}" @property def name(self): @@ -84,8 +92,11 @@ def name(self): async def async_added_to_hass(self): """Register callbacks.""" - async_dispatcher_connect( - self.hass, SIGNAL_UPDATE_ARLO, self._update_callback) + self.async_on_remove( + async_dispatcher_connect( + self.hass, SIGNAL_UPDATE_ARLO, self._update_callback + ) + ) @callback def _update_callback(self): @@ -100,9 +111,10 @@ def state(self): @property def icon(self): """Icon to use in the frontend, if any.""" - if self._sensor_type == 'battery_level' and self._state is not None: - return icon_for_battery_level(battery_level=int(self._state), - charging=False) + if self._sensor_type == "battery_level" and self._state is not None: + return icon_for_battery_level( + battery_level=int(self._state), charging=False + ) return self._icon @property @@ -113,57 +125,58 @@ def unit_of_measurement(self): @property def device_class(self): """Return the device class of the sensor.""" - if self._sensor_type == 'temperature': + if self._sensor_type == "temperature": return DEVICE_CLASS_TEMPERATURE - if self._sensor_type == 'humidity': + 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': + if self._sensor_type == "total_cameras": self._state = len(self._data.cameras) - elif self._sensor_type == 'captured_today': + elif self._sensor_type == "captured_today": self._state = len(self._data.captured_today) - elif self._sensor_type == 'last_capture': + 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) + error_msg = ( + f"Video not found for {self.name}. " + f"Older than {self._data.min_days_vdo_cache} days?" + ) _LOGGER.debug(error_msg) self._state = None - elif self._sensor_type == 'battery_level': + elif self._sensor_type == "battery_level": try: self._state = self._data.battery_level except TypeError: self._state = None - elif self._sensor_type == 'signal_strength': + elif self._sensor_type == "signal_strength": try: self._state = self._data.signal_strength except TypeError: self._state = None - elif self._sensor_type == 'temperature': + elif self._sensor_type == "temperature": try: self._state = self._data.ambient_temperature except TypeError: self._state = None - elif self._sensor_type == 'humidity': + elif self._sensor_type == "humidity": try: self._state = self._data.ambient_humidity except TypeError: self._state = None - elif self._sensor_type == 'air_quality': + elif self._sensor_type == "air_quality": try: self._state = self._data.ambient_air_quality except TypeError: @@ -175,9 +188,9 @@ def device_state_attributes(self): attrs = {} attrs[ATTR_ATTRIBUTION] = ATTRIBUTION - attrs['brand'] = DEFAULT_BRAND + attrs["brand"] = DEFAULT_BRAND - if self._sensor_type != 'total_cameras': - attrs['model'] = self._data.model_id + 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 index e69de29bb2d1d..a35fec8fb737b 100644 --- a/homeassistant/components/arlo/services.yaml +++ b/homeassistant/components/arlo/services.yaml @@ -0,0 +1,4 @@ +# Describes the format for available arlo services + +update: + description: Update the state for all cameras and the base station. diff --git a/homeassistant/components/arris_tg2492lg/__init__.py b/homeassistant/components/arris_tg2492lg/__init__.py new file mode 100644 index 0000000000000..c08ddcba48fc5 --- /dev/null +++ b/homeassistant/components/arris_tg2492lg/__init__.py @@ -0,0 +1 @@ +"""The Arris TG2492LG component.""" diff --git a/homeassistant/components/arris_tg2492lg/device_tracker.py b/homeassistant/components/arris_tg2492lg/device_tracker.py new file mode 100644 index 0000000000000..d18d19806f97f --- /dev/null +++ b/homeassistant/components/arris_tg2492lg/device_tracker.py @@ -0,0 +1,70 @@ +"""Support for Arris TG2492LG router.""" +import logging +from typing import List + +from arris_tg2492lg import ConnectBox, Device +import voluptuous as vol + +from homeassistant.components.device_tracker import ( + DOMAIN, + PLATFORM_SCHEMA, + DeviceScanner, +) +from homeassistant.const import CONF_HOST, CONF_PASSWORD +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_HOST = "192.168.178.1" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, + } +) + + +def get_scanner(hass, config): + """Return the Arris device scanner.""" + conf = config[DOMAIN] + url = f"http://{conf[CONF_HOST]}" + connect_box = ConnectBox(url, conf[CONF_PASSWORD]) + return ArrisDeviceScanner(connect_box) + + +class ArrisDeviceScanner(DeviceScanner): + """This class queries a Arris TG2492LG router for connected devices.""" + + def __init__(self, connect_box: ConnectBox): + """Initialize the scanner.""" + self.connect_box = connect_box + self.last_results: List[Device] = [] + + def scan_devices(self): + """Scan for new devices and return a list with found device IDs.""" + self._update_info() + + return [device.mac for device in self.last_results] + + def get_device_name(self, device): + """Return the name of the given device or None if we don't know.""" + name = next( + (result.hostname for result in self.last_results if result.mac == device), + None, + ) + return name + + def _update_info(self): + """Ensure the information from the Arris TG2492LG router is up to date.""" + result = self.connect_box.get_connected_devices() + + last_results = [] + mac_addresses = set() + + for device in result: + if device.online and device.mac not in mac_addresses: + last_results.append(device) + mac_addresses.add(device.mac) + + self.last_results = last_results diff --git a/homeassistant/components/arris_tg2492lg/manifest.json b/homeassistant/components/arris_tg2492lg/manifest.json new file mode 100644 index 0000000000000..385bb95562722 --- /dev/null +++ b/homeassistant/components/arris_tg2492lg/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "arris_tg2492lg", + "name": "Arris TG2492LG", + "documentation": "https://www.home-assistant.io/integrations/arris_tg2492lg", + "requirements": [ + "arris-tg2492lg==1.0.0" + ], + "codeowners": [ + "@vanbalken" + ] +} diff --git a/homeassistant/components/aruba/device_tracker.py b/homeassistant/components/aruba/device_tracker.py index cde144e68f692..355bcad3aaf24 100644 --- a/homeassistant/components/aruba/device_tracker.py +++ b/homeassistant/components/aruba/device_tracker.py @@ -2,25 +2,32 @@ import logging import re +import pexpect import voluptuous as vol -import homeassistant.helpers.config_validation as cv from homeassistant.components.device_tracker import ( - DOMAIN, PLATFORM_SCHEMA, DeviceScanner) + 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__) _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+') + 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 -}) +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): @@ -48,15 +55,15 @@ def __init__(self, config): 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] + 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'] + if client["mac"] == device: + return client["name"] return None def _update_info(self): @@ -76,14 +83,21 @@ def _update_info(self): 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) + + connect = f"ssh {self.username}@{self.host}" + ssh = pexpect.spawn(connect) + 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 @@ -91,8 +105,8 @@ def get_aruba_data(self): _LOGGER.error("Unexpected response from router") return if query == 3: - ssh.sendline('yes') - ssh.expect('password:') + ssh.sendline("yes") + ssh.expect("password:") elif query == 4: _LOGGER.error("Host key changed") return @@ -103,19 +117,19 @@ def get_aruba_data(self): _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') + 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')) + 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') + 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 index 597975619e6a4..aa55cdba35567 100644 --- a/homeassistant/components/aruba/manifest.json +++ b/homeassistant/components/aruba/manifest.json @@ -1,10 +1,7 @@ { "domain": "aruba", "name": "Aruba", - "documentation": "https://www.home-assistant.io/components/aruba", - "requirements": [ - "pexpect==4.6.0" - ], - "dependencies": [], + "documentation": "https://www.home-assistant.io/integrations/aruba", + "requirements": ["pexpect==4.6.0"], "codeowners": [] } diff --git a/homeassistant/components/arwn/manifest.json b/homeassistant/components/arwn/manifest.json index 1c861aa67e28b..36ec1c79e585e 100644 --- a/homeassistant/components/arwn/manifest.json +++ b/homeassistant/components/arwn/manifest.json @@ -1,10 +1,7 @@ { "domain": "arwn", - "name": "Arwn", - "documentation": "https://www.home-assistant.io/components/arwn", - "requirements": [], - "dependencies": [ - "mqtt" - ], + "name": "Ambient Radio Weather Network", + "documentation": "https://www.home-assistant.io/integrations/arwn", + "dependencies": ["mqtt"], "codeowners": [] } diff --git a/homeassistant/components/arwn/sensor.py b/homeassistant/components/arwn/sensor.py index 94b552c6eba7e..5da860d8a50f9 100644 --- a/homeassistant/components/arwn/sensor.py +++ b/homeassistant/components/arwn/sensor.py @@ -3,17 +3,17 @@ import logging from homeassistant.components import mqtt +from homeassistant.const import DEGREE, TEMP_CELSIUS, TEMP_FAHRENHEIT 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' +DOMAIN = "arwn" -DATA_ARWN = 'arwn' -TOPIC = 'arwn/#' +DATA_ARWN = "arwn" +TOPIC = "arwn/#" def discover_sensors(topic, payload): @@ -21,39 +21,41 @@ def discover_sensors(topic, payload): Async friendly. """ - parts = topic.split('/') - unit = payload.get('units', '') + parts = topic.split("/") + unit = payload.get("units", "") domain = parts[1] - if domain == 'temperature': + if domain == "temperature": name = parts[2] - if unit == 'F': + if unit == "F": unit = TEMP_FAHRENHEIT else: unit = TEMP_CELSIUS - return ArwnSensor(name, 'temp', unit) + return ArwnSensor(name, "temp", unit) if domain == "moisture": - name = parts[2] + " Moisture" - return ArwnSensor(name, 'moisture', unit, "mdi:water-percent") + name = f"{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")) + 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", DEGREE, "mdi:compass"), + ) def _slug(name): - return 'sensor.arwn_{}'.format(slugify(name)) + return f"sensor.arwn_{slugify(name)}" -async def async_setup_platform(hass, config, async_add_entities, - discovery_info=None): +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. @@ -78,24 +80,25 @@ def async_sensor_event_received(msg): store = hass.data[DATA_ARWN] = {} if isinstance(sensors, ArwnSensor): - sensors = (sensors, ) + sensors = (sensors,) - if 'timestamp' in event: - del event['timestamp'] + 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)) + _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) + await mqtt.async_subscribe(hass, TOPIC, async_sensor_event_received, 0) return True @@ -116,7 +119,7 @@ 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() + self.async_write_ha_state() @property def state(self): diff --git a/homeassistant/components/asterisk_cdr/mailbox.py b/homeassistant/components/asterisk_cdr/mailbox.py index 647067b60d46d..12587e531d7e5 100644 --- a/homeassistant/components/asterisk_cdr/mailbox.py +++ b/homeassistant/components/asterisk_cdr/mailbox.py @@ -1,17 +1,19 @@ """Support for the Asterisk CDR interface.""" -import logging -import hashlib import datetime +import hashlib +import logging -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.asterisk_mbox import ( + DOMAIN as ASTERISK_DOMAIN, + SIGNAL_CDR_UPDATE, +) from homeassistant.components.mailbox import Mailbox +from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect _LOGGER = logging.getLogger(__name__) -MAILBOX_NAME = 'asterisk_cdr' +MAILBOX_NAME = "asterisk_cdr" async def async_get_handler(hass, config, discovery_info=None): @@ -26,8 +28,7 @@ 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) + async_dispatcher_connect(self.hass, SIGNAL_CDR_UPDATE, self._update_callback) @callback def _update_callback(self, msg): @@ -40,16 +41,20 @@ def _build_message(self): cdr = [] for entry in self.hass.data[ASTERISK_DOMAIN].cdr: timestamp = datetime.datetime.strptime( - entry['time'], "%Y-%m-%d %H:%M:%S").timestamp() + entry["time"], "%Y-%m-%d %H:%M:%S" + ).timestamp() info = { - 'origtime': timestamp, - 'callerid': entry['callerid'], - 'duration': entry['duration'], + "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}) + sha = hashlib.sha256(str(entry).encode("utf-8")).hexdigest() + msg = ( + f"Destination: {entry['dest']}\n" + f"Application: {entry['application']}\n " + f"Context: {entry['context']}" + ) + cdr.append({"info": info, "sha": sha, "text": msg}) self.cdr = cdr async def async_get_messages(self): diff --git a/homeassistant/components/asterisk_cdr/manifest.json b/homeassistant/components/asterisk_cdr/manifest.json index db1308b0483d7..8681c308ba3e5 100644 --- a/homeassistant/components/asterisk_cdr/manifest.json +++ b/homeassistant/components/asterisk_cdr/manifest.json @@ -1,10 +1,7 @@ { "domain": "asterisk_cdr", - "name": "Asterisk cdr", - "documentation": "https://www.home-assistant.io/components/asterisk_cdr", - "requirements": [], - "dependencies": [ - "asterisk_mbox" - ], + "name": "Asterisk Call Detail Records", + "documentation": "https://www.home-assistant.io/integrations/asterisk_cdr", + "dependencies": ["asterisk_mbox"], "codeowners": [] } diff --git a/homeassistant/components/asterisk_mbox/__init__.py b/homeassistant/components/asterisk_mbox/__init__.py index a354226bbc06a..ca5112673021b 100644 --- a/homeassistant/components/asterisk_mbox/__init__.py +++ b/homeassistant/components/asterisk_mbox/__init__.py @@ -1,41 +1,51 @@ """Support for Asterisk Voicemail interface.""" import logging +from asterisk_mbox import Client as asteriskClient +from asterisk_mbox.commands import ( + CMD_MESSAGE_CDR, + CMD_MESSAGE_CDR_AVAILABLE, + CMD_MESSAGE_LIST, +) 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) +from homeassistant.helpers.dispatcher import async_dispatcher_send, dispatcher_connect _LOGGER = logging.getLogger(__name__) -DOMAIN = 'asterisk_mbox' +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) +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) + host = conf[CONF_HOST] + port = conf[CONF_PORT] + password = conf[CONF_PASSWORD] hass.data[DOMAIN] = AsteriskData(hass, host, port, password, config) @@ -47,59 +57,58 @@ class AsteriskData: 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) + 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)) + 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)) + _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) + 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) + 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'] + _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_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)) + _LOGGER.debug( + "AsteriskVM sent unknown message '%d' len: %d", command, len(msg) + ) @callback def _request_messages(self): diff --git a/homeassistant/components/asterisk_mbox/mailbox.py b/homeassistant/components/asterisk_mbox/mailbox.py index f79c8922214f5..b3863eeb13f07 100644 --- a/homeassistant/components/asterisk_mbox/mailbox.py +++ b/homeassistant/components/asterisk_mbox/mailbox.py @@ -1,8 +1,10 @@ """Support for the Asterisk Voicemail interface.""" +from functools import partial import logging -from homeassistant.components.mailbox import ( - CONTENT_TYPE_MPEG, Mailbox, StreamError) +from asterisk_mbox import ServerError + +from homeassistant.components.mailbox import CONTENT_TYPE_MPEG, Mailbox, StreamError from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -10,8 +12,8 @@ _LOGGER = logging.getLogger(__name__) -SIGNAL_MESSAGE_REQUEST = 'asterisk_mbox.message_request' -SIGNAL_MESSAGE_UPDATE = 'asterisk_mbox.message_updated' +SIGNAL_MESSAGE_REQUEST = "asterisk_mbox.message_request" +SIGNAL_MESSAGE_UPDATE = "asterisk_mbox.message_updated" async def async_get_handler(hass, config, discovery_info=None): @@ -26,7 +28,8 @@ def __init__(self, hass, name): """Initialize Asterisk mailbox.""" super().__init__(hass, name) async_dispatcher_connect( - self.hass, SIGNAL_MESSAGE_UPDATE, self._update_callback) + self.hass, SIGNAL_MESSAGE_UPDATE, self._update_callback + ) @callback def _update_callback(self, msg): @@ -50,10 +53,12 @@ def has_media(self): 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) + return await self.hass.async_add_executor_job( + partial(client.mp3, msgid, sync=True) + ) except ServerError as err: raise StreamError(err) @@ -61,9 +66,9 @@ 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): + async def async_delete(self, msgid): """Delete the specified messages.""" client = self.hass.data[ASTERISK_DOMAIN].client _LOGGER.info("Deleting: %s", msgid) - client.delete(msgid) + await self.hass.async_add_executor_job(client.delete, msgid) return True diff --git a/homeassistant/components/asterisk_mbox/manifest.json b/homeassistant/components/asterisk_mbox/manifest.json index bafe43c480f4f..f02e964fb614b 100644 --- a/homeassistant/components/asterisk_mbox/manifest.json +++ b/homeassistant/components/asterisk_mbox/manifest.json @@ -1,10 +1,7 @@ { "domain": "asterisk_mbox", - "name": "Asterisk mbox", - "documentation": "https://www.home-assistant.io/components/asterisk_mbox", - "requirements": [ - "asterisk_mbox==0.5.0" - ], - "dependencies": [], + "name": "Asterisk Voicemail", + "documentation": "https://www.home-assistant.io/integrations/asterisk_mbox", + "requirements": ["asterisk_mbox==0.5.0"], "codeowners": [] } diff --git a/homeassistant/components/asuswrt/__init__.py b/homeassistant/components/asuswrt/__init__.py index cc51a15f8e871..d9c87beea5d98 100644 --- a/homeassistant/components/asuswrt/__init__.py +++ b/homeassistant/components/asuswrt/__init__.py @@ -1,67 +1,120 @@ """Support for ASUSWRT devices.""" import logging +from aioasuswrt.asuswrt import AsusWrt import voluptuous as vol from homeassistant.const import ( - CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_PORT, CONF_MODE, - CONF_PROTOCOL) + CONF_HOST, + CONF_MODE, + CONF_PASSWORD, + CONF_PORT, + CONF_PROTOCOL, + CONF_USERNAME, +) from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import async_load_platform +from homeassistant.helpers.event import async_call_later _LOGGER = logging.getLogger(__name__) -CONF_PUB_KEY = 'pub_key' -CONF_REQUIRE_IP = 'require_ip' -CONF_SENSORS = 'sensors' -CONF_SSH_KEY = 'ssh_key' +CONF_DNSMASQ = "dnsmasq" +CONF_INTERFACE = "interface" +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 +DEFAULT_INTERFACE = "eth0" +DEFAULT_DNSMASQ = "/var/lib/misc" + +FIRST_RETRY_TIME = 60 +MAX_RETRY_TIME = 900 + +SECRET_GROUP = "Password or SSH Key" +SENSOR_TYPES = ["devices", "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)] + ), + vol.Optional(CONF_INTERFACE, default=DEFAULT_INTERFACE): cv.string, + vol.Optional(CONF_DNSMASQ, default=DEFAULT_DNSMASQ): cv.string, + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + -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): +async def async_setup(hass, config, retry_delay=FIRST_RETRY_TIME): """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)) + api = AsusWrt( + conf[CONF_HOST], + conf[CONF_PORT], + conf[CONF_PROTOCOL] == "telnet", + conf[CONF_USERNAME], + conf.get(CONF_PASSWORD, ""), + conf.get("ssh_key", conf.get("pub_key", "")), + conf[CONF_MODE], + conf[CONF_REQUIRE_IP], + interface=conf[CONF_INTERFACE], + dnsmasq=conf[CONF_DNSMASQ], + ) + + try: + await api.connection.async_connect() + except OSError as ex: + _LOGGER.warning( + "Error [%s] connecting %s to %s. Will retry in %s seconds...", + str(ex), + DOMAIN, + conf[CONF_HOST], + retry_delay, + ) + + async def retry_setup(now): + """Retry setup if a error happens on asuswrt API.""" + await async_setup( + hass, config, retry_delay=min(2 * retry_delay, MAX_RETRY_TIME) + ) + + async_call_later(hass, retry_delay, retry_setup) + + return True - await api.connection.async_connect() if not api.is_connected: - _LOGGER.error("Unable to setup asuswrt component") + _LOGGER.error("Error connecting %s to %s.", DOMAIN, conf[CONF_HOST]) 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)) + 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 index 68641f670aa26..5e3297da8ff57 100644 --- a/homeassistant/components/asuswrt/device_tracker.py +++ b/homeassistant/components/asuswrt/device_tracker.py @@ -47,6 +47,6 @@ async def async_update_info(self): Return boolean if scanning successful. """ - _LOGGER.info('Checking Devices') + _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 index f36819f133ddb..274060404b7ff 100644 --- a/homeassistant/components/asuswrt/manifest.json +++ b/homeassistant/components/asuswrt/manifest.json @@ -1,12 +1,7 @@ { "domain": "asuswrt", - "name": "Asuswrt", - "documentation": "https://www.home-assistant.io/components/asuswrt", - "requirements": [ - "aioasuswrt==1.1.21" - ], - "dependencies": [], - "codeowners": [ - "@kennedyshead" - ] + "name": "ASUSWRT", + "documentation": "https://www.home-assistant.io/integrations/asuswrt", + "requirements": ["aioasuswrt==1.2.5"], + "codeowners": ["@kennedyshead"] } diff --git a/homeassistant/components/asuswrt/sensor.py b/homeassistant/components/asuswrt/sensor.py index 8ae629bd12d98..631e6e9d70f5f 100644 --- a/homeassistant/components/asuswrt/sensor.py +++ b/homeassistant/components/asuswrt/sensor.py @@ -1,6 +1,9 @@ """Asuswrt status sensors.""" import logging +from aioasuswrt.asuswrt import AsusWrt + +from homeassistant.const import DATA_GIGABYTES, DATA_RATE_MEGABITS_PER_SECOND from homeassistant.helpers.entity import Entity from . import DATA_ASUSWRT @@ -8,8 +11,7 @@ _LOGGER = logging.getLogger(__name__) -async def async_setup_platform( - hass, config, add_entities, discovery_info=None): +async def async_setup_platform(hass, config, add_entities, discovery_info=None): """Set up the asuswrt sensors.""" if discovery_info is None: return @@ -18,13 +20,15 @@ async def async_setup_platform( devices = [] - if 'download' in discovery_info: + if "devices" in discovery_info: + devices.append(AsuswrtDevicesSensor(api)) + if "download" in discovery_info: devices.append(AsuswrtTotalRXSensor(api)) - if 'upload' in discovery_info: + if "upload" in discovery_info: devices.append(AsuswrtTotalTXSensor(api)) - if 'download_speed' in discovery_info: + if "download_speed" in discovery_info: devices.append(AsuswrtRXSensor(api)) - if 'upload_speed' in discovery_info: + if "upload_speed" in discovery_info: devices.append(AsuswrtTXSensor(api)) add_entities(devices) @@ -33,12 +37,13 @@ async def async_setup_platform( class AsuswrtSensor(Entity): """Representation of a asuswrt sensor.""" - _name = 'generic' + _name = "generic" - def __init__(self, api): + def __init__(self, api: AsusWrt): """Initialize the sensor.""" self._api = api self._state = None + self._devices = None self._rates = None self._speed = None @@ -54,15 +59,28 @@ def state(self): async def async_update(self): """Fetch status from asuswrt.""" + self._devices = await self._api.async_get_connected_devices() self._rates = await self._api.async_get_bytes_total() self._speed = await self._api.async_get_current_transfer_rates() +class AsuswrtDevicesSensor(AsuswrtSensor): + """Representation of a asuswrt download speed sensor.""" + + _name = "Asuswrt Devices Connected" + + async def async_update(self): + """Fetch new state data for the sensor.""" + await super().async_update() + if self._devices: + self._state = len(self._devices) + + class AsuswrtRXSensor(AsuswrtSensor): """Representation of a asuswrt download speed sensor.""" - _name = 'Asuswrt Download Speed' - _unit = 'Mbit/s' + _name = "Asuswrt Download Speed" + _unit = DATA_RATE_MEGABITS_PER_SECOND @property def unit_of_measurement(self): @@ -79,8 +97,8 @@ async def async_update(self): class AsuswrtTXSensor(AsuswrtSensor): """Representation of a asuswrt upload speed sensor.""" - _name = 'Asuswrt Upload Speed' - _unit = 'Mbit/s' + _name = "Asuswrt Upload Speed" + _unit = DATA_RATE_MEGABITS_PER_SECOND @property def unit_of_measurement(self): @@ -97,8 +115,8 @@ async def async_update(self): class AsuswrtTotalRXSensor(AsuswrtSensor): """Representation of a asuswrt total download sensor.""" - _name = 'Asuswrt Download' - _unit = 'Gigabyte' + _name = "Asuswrt Download" + _unit = DATA_GIGABYTES @property def unit_of_measurement(self): @@ -115,8 +133,8 @@ async def async_update(self): class AsuswrtTotalTXSensor(AsuswrtSensor): """Representation of a asuswrt total upload sensor.""" - _name = 'Asuswrt Upload' - _unit = 'Gigabyte' + _name = "Asuswrt Upload" + _unit = DATA_GIGABYTES @property def unit_of_measurement(self): diff --git a/homeassistant/components/atag/__init__.py b/homeassistant/components/atag/__init__.py new file mode 100644 index 0000000000000..cb90df1650cdb --- /dev/null +++ b/homeassistant/components/atag/__init__.py @@ -0,0 +1,259 @@ +"""The ATAG Integration.""" +from datetime import timedelta +import logging + +import async_timeout +from pyatag import AtagDataStore, AtagException + +from homeassistant.components.climate import DOMAIN as CLIMATE +from homeassistant.components.sensor import DOMAIN as SENSOR +from homeassistant.components.water_heater import DOMAIN as WATER_HEATER +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_ICON, + ATTR_ID, + ATTR_MODE, + ATTR_NAME, + ATTR_UNIT_OF_MEASUREMENT, + DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_TEMPERATURE, + PRESSURE_BAR, + TEMP_CELSIUS, +) +from homeassistant.core import HomeAssistant, asyncio +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "atag" +DATA_LISTENER = f"{DOMAIN}_listener" +SIGNAL_UPDATE_ATAG = f"{DOMAIN}_update" +PLATFORMS = [CLIMATE, WATER_HEATER, SENSOR] +HOUR = "h" +FIRE = "fire" +PERCENTAGE = "%" + +ICONS = { + TEMP_CELSIUS: "mdi:thermometer", + PRESSURE_BAR: "mdi:gauge", + FIRE: "mdi:fire", + ATTR_MODE: "mdi:settings", +} + +ENTITY_TYPES = { + SENSOR: [ + { + ATTR_NAME: "Outside Temperature", + ATTR_ID: "outside_temp", + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_ICON: ICONS[TEMP_CELSIUS], + }, + { + ATTR_NAME: "Average Outside Temperature", + ATTR_ID: "tout_avg", + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_ICON: ICONS[TEMP_CELSIUS], + }, + { + ATTR_NAME: "Weather Status", + ATTR_ID: "weather_status", + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_DEVICE_CLASS: None, + ATTR_ICON: None, + }, + { + ATTR_NAME: "CH Water Pressure", + ATTR_ID: "ch_water_pres", + ATTR_UNIT_OF_MEASUREMENT: PRESSURE_BAR, + ATTR_DEVICE_CLASS: DEVICE_CLASS_PRESSURE, + ATTR_ICON: ICONS[PRESSURE_BAR], + }, + { + ATTR_NAME: "CH Water Temperature", + ATTR_ID: "ch_water_temp", + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_ICON: ICONS[TEMP_CELSIUS], + }, + { + ATTR_NAME: "CH Return Temperature", + ATTR_ID: "ch_return_temp", + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_ICON: ICONS[TEMP_CELSIUS], + }, + { + ATTR_NAME: "Burning Hours", + ATTR_ID: "burning_hours", + ATTR_UNIT_OF_MEASUREMENT: HOUR, + ATTR_DEVICE_CLASS: None, + ATTR_ICON: ICONS[FIRE], + }, + { + ATTR_NAME: "Flame", + ATTR_ID: "rel_mod_level", + ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, + ATTR_DEVICE_CLASS: None, + ATTR_ICON: ICONS[FIRE], + }, + ], + CLIMATE: { + ATTR_NAME: DOMAIN.title(), + ATTR_ID: CLIMATE, + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_DEVICE_CLASS: None, + ATTR_ICON: None, + }, + WATER_HEATER: { + ATTR_NAME: DOMAIN.title(), + ATTR_ID: WATER_HEATER, + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_DEVICE_CLASS: None, + ATTR_ICON: None, + }, +} + + +async def async_setup(hass: HomeAssistant, config): + """Set up the Atag component.""" + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up Atag integration from a config entry.""" + session = async_get_clientsession(hass) + + coordinator = AtagDataUpdateCoordinator(hass, session, entry) + + await coordinator.async_refresh() + + if not coordinator.last_update_success: + raise ConfigEntryNotReady + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = coordinator + + for platform in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, platform) + ) + + return True + + +class AtagDataUpdateCoordinator(DataUpdateCoordinator): + """Define an object to hold Atag data.""" + + def __init__(self, hass, session, entry): + """Initialize.""" + self.atag = AtagDataStore(session, paired=True, **entry.data) + + super().__init__( + hass, _LOGGER, name=DOMAIN, update_interval=timedelta(seconds=30) + ) + + async def _async_update_data(self): + """Update data via library.""" + with async_timeout.timeout(20): + try: + await self.atag.async_update() + except (AtagException) as error: + raise UpdateFailed(error) + + return self.atag.sensordata + + +async def async_unload_entry(hass, entry): + """Unload Atag config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ] + ) + ) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + return unload_ok + + +class AtagEntity(Entity): + """Defines a base Atag entity.""" + + def __init__(self, coordinator: AtagDataUpdateCoordinator, atag_type: dict) -> None: + """Initialize the Atag entity.""" + self.coordinator = coordinator + + self._id = atag_type[ATTR_ID] + self._name = atag_type[ATTR_NAME] + self._icon = atag_type[ATTR_ICON] + self._unit = atag_type[ATTR_UNIT_OF_MEASUREMENT] + self._class = atag_type[ATTR_DEVICE_CLASS] + + @property + def device_info(self) -> dict: + """Return info for device registry.""" + device = self.coordinator.atag.device + version = self.coordinator.atag.apiversion + return { + "identifiers": {(DOMAIN, device)}, + ATTR_NAME: "Atag Thermostat", + "model": "Atag One", + "sw_version": version, + "manufacturer": "Atag", + } + + @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.""" + self._icon = ( + self.coordinator.data.get(self._id, {}).get(ATTR_ICON) or self._icon + ) + return self._icon + + @property + def should_poll(self) -> bool: + """Return the polling requirement of the entity.""" + return False + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return self._unit + + @property + def device_class(self): + """Return the device class.""" + return self._class + + @property + def available(self): + """Return True if entity is available.""" + return self.coordinator.last_update_success + + @property + def unique_id(self): + """Return a unique ID to use for this entity.""" + return f"{self.coordinator.atag.device}-{self._id}" + + async def async_added_to_hass(self): + """Connect to dispatcher listening for entity data notifications.""" + self.async_on_remove( + self.coordinator.async_add_listener(self.async_write_ha_state) + ) + + async def async_update(self): + """Update Atag entity.""" + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/atag/climate.py b/homeassistant/components/atag/climate.py new file mode 100644 index 0000000000000..40bd8cd4cc762 --- /dev/null +++ b/homeassistant/components/atag/climate.py @@ -0,0 +1,106 @@ +"""Initialization of ATAG One climate platform.""" +from typing import List, Optional + +from homeassistant.components.climate import ClimateDevice +from homeassistant.components.climate.const import ( + CURRENT_HVAC_HEAT, + CURRENT_HVAC_IDLE, + HVAC_MODE_AUTO, + HVAC_MODE_HEAT, + PRESET_AWAY, + PRESET_BOOST, + SUPPORT_PRESET_MODE, + SUPPORT_TARGET_TEMPERATURE, +) +from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS, TEMP_FAHRENHEIT + +from . import CLIMATE, DOMAIN, ENTITY_TYPES, AtagEntity + +PRESET_SCHEDULE = "Auto" +PRESET_MANUAL = "Manual" +PRESET_EXTEND = "Extend" +SUPPORT_PRESET = [ + PRESET_MANUAL, + PRESET_SCHEDULE, + PRESET_EXTEND, + PRESET_AWAY, + PRESET_BOOST, +] +SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_PRESET_MODE +HVAC_MODES = [HVAC_MODE_AUTO, HVAC_MODE_HEAT] + + +async def async_setup_entry(hass, entry, async_add_entities): + """Load a config entry.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities([AtagThermostat(coordinator, ENTITY_TYPES[CLIMATE])]) + + +class AtagThermostat(AtagEntity, ClimateDevice): + """Atag climate device.""" + + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_FLAGS + + @property + def hvac_mode(self) -> Optional[str]: + """Return hvac operation ie. heat, cool mode.""" + if self.coordinator.atag.hvac_mode in HVAC_MODES: + return self.coordinator.atag.hvac_mode + return None + + @property + def hvac_modes(self) -> List[str]: + """Return the list of available hvac operation modes.""" + return HVAC_MODES + + @property + def hvac_action(self) -> Optional[str]: + """Return the current running hvac operation.""" + if self.coordinator.atag.cv_status: + return CURRENT_HVAC_HEAT + return CURRENT_HVAC_IDLE + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + if self.coordinator.atag.temp_unit in [TEMP_CELSIUS, TEMP_FAHRENHEIT]: + return self.coordinator.atag.temp_unit + return None + + @property + def current_temperature(self) -> Optional[float]: + """Return the current temperature.""" + return self.coordinator.atag.temperature + + @property + def target_temperature(self) -> Optional[float]: + """Return the temperature we try to reach.""" + return self.coordinator.atag.target_temperature + + @property + def preset_mode(self) -> Optional[str]: + """Return the current preset mode, e.g., auto, manual, fireplace, extend, etc.""" + return self.coordinator.atag.hold_mode + + @property + def preset_modes(self) -> Optional[List[str]]: + """Return a list of available preset modes.""" + return SUPPORT_PRESET + + async def async_set_temperature(self, **kwargs) -> None: + """Set new target temperature.""" + await self.coordinator.atag.set_temp(kwargs.get(ATTR_TEMPERATURE)) + self.async_write_ha_state() + + async def async_set_hvac_mode(self, hvac_mode: str) -> None: + """Set new target hvac mode.""" + await self.coordinator.atag.set_hvac_mode(hvac_mode) + self.async_write_ha_state() + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + await self.coordinator.atag.set_hold_mode(preset_mode) + self.async_write_ha_state() diff --git a/homeassistant/components/atag/config_flow.py b/homeassistant/components/atag/config_flow.py new file mode 100644 index 0000000000000..27b2b7a42f62d --- /dev/null +++ b/homeassistant/components/atag/config_flow.py @@ -0,0 +1,50 @@ +"""Config flow for the Atag component.""" +from pyatag import DEFAULT_PORT, AtagDataStore, AtagException +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_PORT +from homeassistant.core import callback +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from . import DOMAIN # pylint: disable=unused-import + +DATA_SCHEMA = { + vol.Required(CONF_HOST): str, + vol.Required(CONF_PORT, default=DEFAULT_PORT): vol.Coerce(int), +} + + +class AtagConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Config flow for Atag.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + + if self._async_current_entries(): + return self.async_abort(reason="already_configured") + + if not user_input: + return await self._show_form() + session = async_get_clientsession(self.hass) + try: + atag = AtagDataStore(session, **user_input) + await atag.async_check_pair_status() + + except AtagException: + return await self._show_form({"base": "connection_error"}) + + user_input.update({CONF_DEVICE: atag.device}) + return self.async_create_entry(title=atag.device, data=user_input) + + @callback + async def _show_form(self, errors=None): + """Show the form to the user.""" + return self.async_show_form( + step_id="user", + data_schema=vol.Schema(DATA_SCHEMA), + errors=errors if errors else {}, + ) diff --git a/homeassistant/components/atag/manifest.json b/homeassistant/components/atag/manifest.json new file mode 100644 index 0000000000000..902da2bff751b --- /dev/null +++ b/homeassistant/components/atag/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "atag", + "name": "Atag", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/atag/", + "requirements": ["pyatag==0.2.19"], + "codeowners": ["@MatsNL"] +} diff --git a/homeassistant/components/atag/sensor.py b/homeassistant/components/atag/sensor.py new file mode 100644 index 0000000000000..743b50ef40d4d --- /dev/null +++ b/homeassistant/components/atag/sensor.py @@ -0,0 +1,22 @@ +"""Initialization of ATAG One sensor platform.""" +from homeassistant.const import ATTR_STATE + +from . import DOMAIN, ENTITY_TYPES, SENSOR, AtagEntity + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Initialize sensor platform from config entry.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id] + entities = [] + for sensor in ENTITY_TYPES[SENSOR]: + entities.append(AtagSensor(coordinator, sensor)) + async_add_entities(entities) + + +class AtagSensor(AtagEntity): + """Representation of a AtagOne Sensor.""" + + @property + def state(self): + """Return the state of the sensor.""" + return self.coordinator.data[self._id][ATTR_STATE] diff --git a/homeassistant/components/atag/strings.json b/homeassistant/components/atag/strings.json new file mode 100644 index 0000000000000..094fde70dc95f --- /dev/null +++ b/homeassistant/components/atag/strings.json @@ -0,0 +1,20 @@ +{ + "title": "Atag", + "config": { + "step": { + "user": { + "title": "Connect to the device", + "data": { + "host": "Host", + "port": "Port (10000)" + } + } + }, + "error": { + "connection_error": "Failed to connect, please try again" + }, + "abort": { + "already_configured": "Only one Atag device can be added to Home Assistant" + } + } +} diff --git a/homeassistant/components/atag/translations/ca.json b/homeassistant/components/atag/translations/ca.json new file mode 100644 index 0000000000000..994cc3c8fbecb --- /dev/null +++ b/homeassistant/components/atag/translations/ca.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Nom\u00e9s es pot afegir un sol dispositiu Atag a Home Assistant" + }, + "error": { + "connection_error": "No s'ha pogut connectar, torna-ho a provar" + }, + "step": { + "user": { + "data": { + "host": "Amfitri\u00f3", + "port": "Port (10000)" + }, + "title": "Connexi\u00f3 amb el dispositiu" + } + } + }, + "title": "Atag" +} \ No newline at end of file diff --git a/homeassistant/components/atag/translations/de.json b/homeassistant/components/atag/translations/de.json new file mode 100644 index 0000000000000..f9d40a035a3f4 --- /dev/null +++ b/homeassistant/components/atag/translations/de.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Nur ein Atag-Ger\u00e4t kann mit Home Assistant verbunden werden." + }, + "error": { + "connection_error": "Verbindung fehlgeschlagen, versuchen Sie es erneut" + }, + "step": { + "user": { + "data": { + "host": "Host", + "port": "Port (10000)" + }, + "title": "Stellen Sie eine Verbindung zum Ger\u00e4t her" + } + } + }, + "title": "Atag" +} \ No newline at end of file diff --git a/homeassistant/components/atag/translations/en.json b/homeassistant/components/atag/translations/en.json new file mode 100644 index 0000000000000..edee94a8e044c --- /dev/null +++ b/homeassistant/components/atag/translations/en.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Only one Atag device can be added to Home Assistant" + }, + "error": { + "connection_error": "Failed to connect, please try again" + }, + "step": { + "user": { + "data": { + "host": "Host", + "port": "Port (10000)" + }, + "title": "Connect to the device" + } + } + }, + "title": "Atag" +} \ No newline at end of file diff --git a/homeassistant/components/atag/translations/es-419.json b/homeassistant/components/atag/translations/es-419.json new file mode 100644 index 0000000000000..a833218e31137 --- /dev/null +++ b/homeassistant/components/atag/translations/es-419.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Solo se puede agregar un dispositivo Atag a Home Assistant" + }, + "error": { + "connection_error": "No se pudo conectar, intente nuevamente" + }, + "step": { + "user": { + "data": { + "port": "Puerto (10000)" + }, + "title": "Conectarse al dispositivo" + } + } + }, + "title": "Atag" +} \ No newline at end of file diff --git a/homeassistant/components/atag/translations/es.json b/homeassistant/components/atag/translations/es.json new file mode 100644 index 0000000000000..b02a20e09a13c --- /dev/null +++ b/homeassistant/components/atag/translations/es.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "S\u00f3lo se puede a\u00f1adir un dispositivo Atag a Home Assistant" + }, + "error": { + "connection_error": "No se pudo conectar, por favor, int\u00e9ntalo de nuevo" + }, + "step": { + "user": { + "data": { + "host": "Host", + "port": "Puerto (10000)" + }, + "title": "Conectarse al dispositivo" + } + } + }, + "title": "Atag" +} \ No newline at end of file diff --git a/homeassistant/components/atag/translations/fi.json b/homeassistant/components/atag/translations/fi.json new file mode 100644 index 0000000000000..0483d8d28045a --- /dev/null +++ b/homeassistant/components/atag/translations/fi.json @@ -0,0 +1,14 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "Palvelin", + "port": "Portti (10000)" + }, + "title": "Yhdist\u00e4 laitteeseen" + } + } + }, + "title": "Atag" +} \ No newline at end of file diff --git a/homeassistant/components/atag/translations/fr.json b/homeassistant/components/atag/translations/fr.json new file mode 100644 index 0000000000000..ace565408f641 --- /dev/null +++ b/homeassistant/components/atag/translations/fr.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "H\u00f4te", + "port": "Port (10000)" + }, + "title": "Se connecter \u00e0 l'appareil" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/atag/translations/hi.json b/homeassistant/components/atag/translations/hi.json new file mode 100644 index 0000000000000..e2b57f18e7953 --- /dev/null +++ b/homeassistant/components/atag/translations/hi.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "connection_error": "\u0915\u0928\u0947\u0915\u094d\u091f \u0915\u0930\u0928\u0947 \u092e\u0947\u0902 \u0935\u093f\u092b\u0932, \u0915\u0943\u092a\u092f\u093e \u092a\u0941\u0928\u0903 \u092a\u094d\u0930\u092f\u093e\u0938 \u0915\u0930\u0947\u0902" + }, + "step": { + "user": { + "data": { + "host": "Host", + "port": "\u092a\u094b\u0930\u094d\u091f (10000)" + }, + "title": "\u0921\u093f\u0935\u093e\u0907\u0938 \u0938\u0947 \u0915\u0928\u0947\u0915\u094d\u091f \u0915\u0930\u0947\u0902" + } + } + }, + "title": "A\u091f\u0948\u0917" +} \ No newline at end of file diff --git a/homeassistant/components/atag/translations/hu.json b/homeassistant/components/atag/translations/hu.json new file mode 100644 index 0000000000000..22687b6944a50 --- /dev/null +++ b/homeassistant/components/atag/translations/hu.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "port": "Port (10000)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/atag/translations/it.json b/homeassistant/components/atag/translations/it.json new file mode 100644 index 0000000000000..190da0f14d7d8 --- /dev/null +++ b/homeassistant/components/atag/translations/it.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "\u00c8 possibile aggiungere un solo dispositivo Atag ad Home Assistant" + }, + "error": { + "connection_error": "Impossibile connettersi, si prega di riprovare" + }, + "step": { + "user": { + "data": { + "host": "Host", + "port": "Porta (10000)" + }, + "title": "Connettersi al dispositivo" + } + } + }, + "title": "Atag" +} \ No newline at end of file diff --git a/homeassistant/components/atag/translations/ko.json b/homeassistant/components/atag/translations/ko.json new file mode 100644 index 0000000000000..bc5c0a08f9fbd --- /dev/null +++ b/homeassistant/components/atag/translations/ko.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Home Assistant \uc5d0\ub294 \ud558\ub098\uc758 Atag \uae30\uae30\ub9cc \ucd94\uac00\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4" + }, + "error": { + "connection_error": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694." + }, + "step": { + "user": { + "data": { + "host": "\ud638\uc2a4\ud2b8", + "port": "\ud3ec\ud2b8 (10000)" + }, + "title": "\uae30\uae30\uc5d0 \uc5f0\uacb0\ud558\uae30" + } + } + }, + "title": "Atag" +} \ No newline at end of file diff --git a/homeassistant/components/atag/translations/lb.json b/homeassistant/components/atag/translations/lb.json new file mode 100644 index 0000000000000..dcb32f3eedc90 --- /dev/null +++ b/homeassistant/components/atag/translations/lb.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "N\u00ebmmen 1 Atag Apparat kann am Home Assistant dob\u00e4igesat ginn" + }, + "error": { + "connection_error": "Feeler beim verbannen, prob\u00e9ier w.e.g. nach emol." + }, + "step": { + "user": { + "data": { + "host": "Apparat", + "port": "Port (10000)" + }, + "title": "Mam Apparat verbannen" + } + } + }, + "title": "Atag" +} \ No newline at end of file diff --git a/homeassistant/components/atag/translations/nl.json b/homeassistant/components/atag/translations/nl.json new file mode 100644 index 0000000000000..14da45b8eb944 --- /dev/null +++ b/homeassistant/components/atag/translations/nl.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Er kan slechts \u00e9\u00e9n Atag-apparaat worden toegevoegd aan Home Assistant " + }, + "error": { + "connection_error": "Verbinding mislukt, probeer het opnieuw" + }, + "step": { + "user": { + "data": { + "host": "Host", + "port": "Poort (10000)" + }, + "title": "Verbinding maken met het apparaat" + } + } + }, + "title": "Atag" +} \ No newline at end of file diff --git a/homeassistant/components/atag/translations/no.json b/homeassistant/components/atag/translations/no.json new file mode 100644 index 0000000000000..38d35d2394317 --- /dev/null +++ b/homeassistant/components/atag/translations/no.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Bare en Atag-enhet kan legges til Home Assistant" + }, + "error": { + "connection_error": "Klarte ikke \u00e5 koble til, vennligst pr\u00f8v igjen" + }, + "step": { + "user": { + "data": { + "host": "Vert", + "port": "" + }, + "title": "Koble til enheten" + } + } + }, + "title": "Atag " +} \ No newline at end of file diff --git a/homeassistant/components/atag/translations/pl.json b/homeassistant/components/atag/translations/pl.json new file mode 100644 index 0000000000000..e931c1fc10f5d --- /dev/null +++ b/homeassistant/components/atag/translations/pl.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Do Home Assistant mo\u017cna doda\u0107 tylko jedno urz\u0105dzenie Atag" + }, + "error": { + "connection_error": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia, spr\u00f3buj ponownie." + }, + "step": { + "user": { + "data": { + "host": "Nazwa hosta lub adres IP", + "port": "Port (10000)" + }, + "title": "Po\u0142\u0105cz z urz\u0105dzeniem" + } + } + }, + "title": "Atag" +} \ No newline at end of file diff --git a/homeassistant/components/atag/translations/ru.json b/homeassistant/components/atag/translations/ru.json new file mode 100644 index 0000000000000..f1c734dc933ed --- /dev/null +++ b/homeassistant/components/atag/translations/ru.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "\u041c\u043e\u0436\u043d\u043e \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u0442\u043e\u043b\u044c\u043a\u043e \u043e\u0434\u043d\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e." + }, + "error": { + "connection_error": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f, \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0435\u0449\u0435 \u0440\u0430\u0437." + }, + "step": { + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442", + "port": "\u041f\u043e\u0440\u0442 (10000)" + }, + "title": "\u041f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435 \u043a \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443" + } + } + }, + "title": "Atag" +} \ No newline at end of file diff --git a/homeassistant/components/atag/translations/sv.json b/homeassistant/components/atag/translations/sv.json new file mode 100644 index 0000000000000..938a0191ee367 --- /dev/null +++ b/homeassistant/components/atag/translations/sv.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "connection_error": "Det gick inte att ansluta, f\u00f6rs\u00f6k igen" + }, + "step": { + "user": { + "data": { + "host": "V\u00e4rd", + "port": "Port (10000)" + }, + "title": "Anslut till enheten" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/atag/translations/zh-Hant.json b/homeassistant/components/atag/translations/zh-Hant.json new file mode 100644 index 0000000000000..aa1c6a90d2b91 --- /dev/null +++ b/homeassistant/components/atag/translations/zh-Hant.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "\u50c5\u80fd\u65b0\u589e\u4e00\u7d44 Atag \u8a2d\u5099\u81f3 Home Assistant" + }, + "error": { + "connection_error": "\u9023\u7dda\u5931\u6557\uff0c\u8acb\u518d\u8a66\u4e00\u6b21" + }, + "step": { + "user": { + "data": { + "host": "\u4e3b\u6a5f\u7aef", + "port": "\u901a\u8a0a\u57e0\uff0810000\uff09" + }, + "title": "\u9023\u7dda\u81f3\u8a2d\u5099" + } + } + }, + "title": "Atag" +} \ No newline at end of file diff --git a/homeassistant/components/atag/water_heater.py b/homeassistant/components/atag/water_heater.py new file mode 100644 index 0000000000000..bb1f72d6a8e09 --- /dev/null +++ b/homeassistant/components/atag/water_heater.py @@ -0,0 +1,70 @@ +"""ATAG water heater component.""" +from homeassistant.components.water_heater import ( + ATTR_TEMPERATURE, + STATE_ECO, + STATE_PERFORMANCE, + WaterHeaterDevice, +) +from homeassistant.const import STATE_OFF, TEMP_CELSIUS + +from . import DOMAIN, ENTITY_TYPES, WATER_HEATER, AtagEntity + +SUPPORT_FLAGS_HEATER = 0 +OPERATION_LIST = [STATE_OFF, STATE_ECO, STATE_PERFORMANCE] + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Initialize DHW device from config entry.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id] + async_add_entities([AtagWaterHeater(coordinator, ENTITY_TYPES[WATER_HEATER])]) + + +class AtagWaterHeater(AtagEntity, WaterHeaterDevice): + """Representation of an ATAG water heater.""" + + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_FLAGS_HEATER + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return TEMP_CELSIUS + + @property + def current_temperature(self): + """Return the current temperature.""" + return self.coordinator.atag.dhw_temperature + + @property + def current_operation(self): + """Return current operation.""" + if self.coordinator.atag.dhw_status: + return STATE_PERFORMANCE + return STATE_OFF + + @property + def operation_list(self): + """List of available operation modes.""" + return OPERATION_LIST + + async def set_temperature(self, **kwargs): + """Set new target temperature.""" + if await self.coordinator.atag.dhw_set_temp(kwargs.get(ATTR_TEMPERATURE)): + self.async_write_ha_state() + + @property + def target_temperature(self): + """Return the setpoint if water demand, otherwise return base temp (comfort level).""" + return self.coordinator.atag.dhw_target_temperature + + @property + def max_temp(self): + """Return the maximum temperature.""" + return self.coordinator.atag.dhw_max_temp + + @property + def min_temp(self): + """Return the minimum temperature.""" + return self.coordinator.atag.dhw_min_temp diff --git a/homeassistant/components/aten_pe/__init__.py b/homeassistant/components/aten_pe/__init__.py new file mode 100644 index 0000000000000..2a0fb277a48c3 --- /dev/null +++ b/homeassistant/components/aten_pe/__init__.py @@ -0,0 +1 @@ +"""The ATEN PE component.""" diff --git a/homeassistant/components/aten_pe/manifest.json b/homeassistant/components/aten_pe/manifest.json new file mode 100644 index 0000000000000..fdfcb4de0475b --- /dev/null +++ b/homeassistant/components/aten_pe/manifest.json @@ -0,0 +1,7 @@ +{ + "domain": "aten_pe", + "name": "ATEN Rack PDU", + "documentation": "https://www.home-assistant.io/integrations/aten_pe", + "requirements": ["atenpdu==0.3.0"], + "codeowners": ["@mtdcr"] +} diff --git a/homeassistant/components/aten_pe/switch.py b/homeassistant/components/aten_pe/switch.py new file mode 100644 index 0000000000000..e5970fc4d3b21 --- /dev/null +++ b/homeassistant/components/aten_pe/switch.py @@ -0,0 +1,122 @@ +"""The ATEN PE switch component.""" + +import logging + +from atenpdu import AtenPE, AtenPEError +import voluptuous as vol + +from homeassistant.components.switch import ( + DEVICE_CLASS_OUTLET, + PLATFORM_SCHEMA, + SwitchEntity, +) +from homeassistant.const import CONF_HOST, CONF_PORT, CONF_USERNAME +from homeassistant.exceptions import PlatformNotReady +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +CONF_AUTH_KEY = "auth_key" +CONF_COMMUNITY = "community" +CONF_PRIV_KEY = "priv_key" +DEFAULT_COMMUNITY = "private" +DEFAULT_PORT = "161" +DEFAULT_USERNAME = "administrator" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_COMMUNITY, default=DEFAULT_COMMUNITY): cv.string, + vol.Optional(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string, + vol.Optional(CONF_AUTH_KEY): cv.string, + vol.Optional(CONF_PRIV_KEY): cv.string, + } +) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the ATEN PE switch.""" + node = config[CONF_HOST] + serv = config[CONF_PORT] + + dev = AtenPE( + node=node, + serv=serv, + community=config[CONF_COMMUNITY], + username=config[CONF_USERNAME], + authkey=config.get(CONF_AUTH_KEY), + privkey=config.get(CONF_PRIV_KEY), + ) + + try: + await hass.async_add_executor_job(dev.initialize) + mac = await dev.deviceMAC() + outlets = dev.outlets() + except AtenPEError as exc: + _LOGGER.error("Failed to initialize %s:%s: %s", node, serv, str(exc)) + raise PlatformNotReady + + switches = [] + async for outlet in outlets: + switches.append(AtenSwitch(dev, mac, outlet.id, outlet.name)) + + async_add_entities(switches) + + +class AtenSwitch(SwitchEntity): + """Represents an ATEN PE switch.""" + + def __init__(self, device, mac, outlet, name): + """Initialize an ATEN PE switch.""" + self._device = device + self._mac = mac + self._outlet = outlet + self._name = name or f"Outlet {outlet}" + self._enabled = False + self._outlet_power = 0.0 + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return f"{self._mac}-{self._outlet}" + + @property + def name(self) -> str: + """Return the name of the entity.""" + return self._name + + @property + def device_class(self) -> str: + """Return the class of this device, from component DEVICE_CLASSES.""" + return DEVICE_CLASS_OUTLET + + @property + def is_on(self) -> bool: + """Return True if entity is on.""" + return self._enabled + + @property + def current_power_w(self) -> float: + """Return the current power usage in W.""" + return self._outlet_power + + async def async_turn_on(self, **kwargs): + """Turn the switch on.""" + await self._device.setOutletStatus(self._outlet, "on") + self._enabled = True + + async def async_turn_off(self, **kwargs): + """Turn the switch off.""" + await self._device.setOutletStatus(self._outlet, "off") + self._enabled = False + + async def async_update(self): + """Process update from entity.""" + status = await self._device.displayOutletStatus(self._outlet) + if status == "on": + self._enabled = True + self._outlet_power = await self._device.outletPower(self._outlet) + elif status == "off": + self._enabled = False + self._outlet_power = 0.0 diff --git a/homeassistant/components/atome/__init__.py b/homeassistant/components/atome/__init__.py new file mode 100644 index 0000000000000..6f524606a817b --- /dev/null +++ b/homeassistant/components/atome/__init__.py @@ -0,0 +1 @@ +"""Support for Atome devices connected to a Linky Energy Meter.""" diff --git a/homeassistant/components/atome/manifest.json b/homeassistant/components/atome/manifest.json new file mode 100644 index 0000000000000..9479f76c7d82d --- /dev/null +++ b/homeassistant/components/atome/manifest.json @@ -0,0 +1,7 @@ +{ + "domain": "atome", + "name": "Atome Linky", + "documentation": "https://www.home-assistant.io/integrations/atome", + "codeowners": ["@baqs"], + "requirements": ["pyatome==0.1.1"] +} diff --git a/homeassistant/components/atome/sensor.py b/homeassistant/components/atome/sensor.py new file mode 100644 index 0000000000000..f9dd6b2dd6129 --- /dev/null +++ b/homeassistant/components/atome/sensor.py @@ -0,0 +1,278 @@ +"""Linky Atome.""" +from datetime import timedelta +import logging + +from pyatome.client import AtomeClient, PyAtomeError +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_NAME, + CONF_PASSWORD, + CONF_USERNAME, + DEVICE_CLASS_POWER, + ENERGY_KILO_WATT_HOUR, + POWER_WATT, +) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = "atome" + +LIVE_SCAN_INTERVAL = timedelta(seconds=30) +DAILY_SCAN_INTERVAL = timedelta(seconds=150) +WEEKLY_SCAN_INTERVAL = timedelta(hours=1) +MONTHLY_SCAN_INTERVAL = timedelta(hours=1) +YEARLY_SCAN_INTERVAL = timedelta(days=1) + +LIVE_NAME = "Atome Live Power" +DAILY_NAME = "Atome Daily" +WEEKLY_NAME = "Atome Weekly" +MONTHLY_NAME = "Atome Monthly" +YEARLY_NAME = "Atome Yearly" + +LIVE_TYPE = "live" +DAILY_TYPE = "day" +WEEKLY_TYPE = "week" +MONTHLY_TYPE = "month" +YEARLY_TYPE = "year" + +ICON = "mdi:flash" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Atome sensor.""" + username = config[CONF_USERNAME] + password = config[CONF_PASSWORD] + + try: + atome_client = AtomeClient(username, password) + atome_client.login() + except PyAtomeError as exp: + _LOGGER.error(exp) + return + + data = AtomeData(atome_client) + + sensors = [] + sensors.append(AtomeSensor(data, LIVE_NAME, LIVE_TYPE)) + sensors.append(AtomeSensor(data, DAILY_NAME, DAILY_TYPE)) + sensors.append(AtomeSensor(data, WEEKLY_NAME, WEEKLY_TYPE)) + sensors.append(AtomeSensor(data, MONTHLY_NAME, MONTHLY_TYPE)) + sensors.append(AtomeSensor(data, YEARLY_NAME, YEARLY_TYPE)) + + add_entities(sensors, True) + + +class AtomeData: + """Stores data retrieved from Neurio sensor.""" + + def __init__(self, client: AtomeClient): + """Initialize the data.""" + self.atome_client = client + self._live_power = None + self._subscribed_power = None + self._is_connected = None + self._day_usage = None + self._day_price = None + self._week_usage = None + self._week_price = None + self._month_usage = None + self._month_price = None + self._year_usage = None + self._year_price = None + + @property + def live_power(self): + """Return latest active power value.""" + return self._live_power + + @property + def subscribed_power(self): + """Return latest active power value.""" + return self._subscribed_power + + @property + def is_connected(self): + """Return latest active power value.""" + return self._is_connected + + @Throttle(LIVE_SCAN_INTERVAL) + def update_live_usage(self): + """Return current power value.""" + try: + values = self.atome_client.get_live() + self._live_power = values["last"] + self._subscribed_power = values["subscribed"] + self._is_connected = values["isConnected"] + _LOGGER.debug( + "Updating Atome live data. Got: %d, isConnected: %s, subscribed: %d", + self._live_power, + self._is_connected, + self._subscribed_power, + ) + + except KeyError as error: + _LOGGER.error("Missing last value in values: %s: %s", values, error) + + @property + def day_usage(self): + """Return latest daily usage value.""" + return self._day_usage + + @property + def day_price(self): + """Return latest daily usage value.""" + return self._day_price + + @Throttle(DAILY_SCAN_INTERVAL) + def update_day_usage(self): + """Return current daily power usage.""" + try: + values = self.atome_client.get_consumption(DAILY_TYPE) + self._day_usage = values["total"] / 1000 + self._day_price = values["price"] + _LOGGER.debug("Updating Atome daily data. Got: %d.", self._day_usage) + + except KeyError as error: + _LOGGER.error("Missing last value in values: %s: %s", values, error) + + @property + def week_usage(self): + """Return latest weekly usage value.""" + return self._week_usage + + @property + def week_price(self): + """Return latest weekly usage value.""" + return self._week_price + + @Throttle(WEEKLY_SCAN_INTERVAL) + def update_week_usage(self): + """Return current weekly power usage.""" + try: + values = self.atome_client.get_consumption(WEEKLY_TYPE) + self._week_usage = values["total"] / 1000 + self._week_price = values["price"] + _LOGGER.debug("Updating Atome weekly data. Got: %d.", self._week_usage) + + except KeyError as error: + _LOGGER.error("Missing last value in values: %s: %s", values, error) + + @property + def month_usage(self): + """Return latest monthly usage value.""" + return self._month_usage + + @property + def month_price(self): + """Return latest monthly usage value.""" + return self._month_price + + @Throttle(MONTHLY_SCAN_INTERVAL) + def update_month_usage(self): + """Return current monthly power usage.""" + try: + values = self.atome_client.get_consumption(MONTHLY_TYPE) + self._month_usage = values["total"] / 1000 + self._month_price = values["price"] + _LOGGER.debug("Updating Atome monthly data. Got: %d.", self._month_usage) + + except KeyError as error: + _LOGGER.error("Missing last value in values: %s: %s", values, error) + + @property + def year_usage(self): + """Return latest yearly usage value.""" + return self._year_usage + + @property + def year_price(self): + """Return latest yearly usage value.""" + return self._year_price + + @Throttle(YEARLY_SCAN_INTERVAL) + def update_year_usage(self): + """Return current yearly power usage.""" + try: + values = self.atome_client.get_consumption(YEARLY_TYPE) + self._year_usage = values["total"] / 1000 + self._year_price = values["price"] + _LOGGER.debug("Updating Atome yearly data. Got: %d.", self._year_usage) + + except KeyError as error: + _LOGGER.error("Missing last value in values: %s: %s", values, error) + + +class AtomeSensor(Entity): + """Representation of a sensor entity for Atome.""" + + def __init__(self, data, name, sensor_type): + """Initialize the sensor.""" + self._name = name + self._data = data + self._state = None + self._attributes = {} + + self._sensor_type = sensor_type + + if sensor_type == LIVE_TYPE: + self._unit_of_measurement = POWER_WATT + else: + self._unit_of_measurement = ENERGY_KILO_WATT_HOUR + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return self._attributes + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self._unit_of_measurement + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return ICON + + @property + def device_class(self): + """Return the device class.""" + return DEVICE_CLASS_POWER + + def update(self): + """Update device state.""" + update_function = getattr(self._data, f"update_{self._sensor_type}_usage") + update_function() + + if self._sensor_type == LIVE_TYPE: + self._state = self._data.live_power + self._attributes["subscribed_power"] = self._data.subscribed_power + self._attributes["is_connected"] = self._data.is_connected + else: + self._state = getattr(self._data, f"{self._sensor_type}_usage") + self._attributes["price"] = getattr( + self._data, f"{self._sensor_type}_price" + ) diff --git a/homeassistant/components/august/__init__.py b/homeassistant/components/august/__init__.py index e18c25706c170..1b25564b8a6c1 100644 --- a/homeassistant/components/august/__init__.py +++ b/homeassistant/components/august/__init__.py @@ -1,348 +1,394 @@ """Support for August devices.""" +import asyncio +import itertools import logging -from datetime import timedelta +from aiohttp import ClientError +from august.authenticator import ValidationResult +from august.exceptions import AugustApiAIOHTTPError import voluptuous as vol -from requests import RequestException +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError 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' -] +from .activity import ActivityStream +from .const import ( + AUGUST_COMPONENTS, + CONF_ACCESS_TOKEN_CACHE_FILE, + CONF_INSTALL_ID, + CONF_LOGIN_METHOD, + DATA_AUGUST, + DEFAULT_AUGUST_CONFIG_FILE, + DEFAULT_NAME, + DEFAULT_TIMEOUT, + DOMAIN, + LOGIN_METHODS, + MIN_TIME_BETWEEN_DETAIL_UPDATES, + VERIFICATION_CODE_KEY, +) +from .exceptions import InvalidAuth, RequireValidation +from .gateway import AugustGateway +from .subscriber import AugustSubscriberMixin +_LOGGER = logging.getLogger(__name__) -def request_configuration(hass, config, api, authenticator): - """Request configuration steps from the user.""" +TWO_FA_REVALIDATE = "verify_configurator" + +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, +) + + +async def async_request_validation(hass, config_entry, august_gateway): + """Request a new verification code from the user.""" + + # + # In the future this should start a new config flow + # instead of using the legacy configurator + # + _LOGGER.error("Access token is no longer valid.") configurator = hass.components.configurator + entry_id = config_entry.entry_id - 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')) + async def async_august_configuration_validation_callback(data): + code = data.get(VERIFICATION_CODE_KEY) + result = await august_gateway.authenticator.async_validate_verification_code( + code + ) if result == ValidationResult.INVALID_VERIFICATION_CODE: - configurator.notify_errors(_CONFIGURING[DOMAIN], - "Invalid verification code") + configurator.async_notify_errors( + hass.data[DOMAIN][entry_id][TWO_FA_REVALIDATE], + "Invalid verification code, please make sure you are using the latest code and try again.", + ) 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'}] + return await async_setup_august(hass, config_entry, august_gateway) + + return False + + if TWO_FA_REVALIDATE not in hass.data[DOMAIN][entry_id]: + await august_gateway.authenticator.async_send_verification_code() + + entry_data = config_entry.data + login_method = entry_data.get(CONF_LOGIN_METHOD) + username = entry_data.get(CONF_USERNAME) + + hass.data[DOMAIN][entry_id][TWO_FA_REVALIDATE] = configurator.async_request_config( + f"{DEFAULT_NAME} ({username})", + async_august_configuration_validation_callback, + description=( + "August must be re-verified. " + f"Please check your {login_method} ({username}) " + "and enter the verification code below" + ), + submit_caption="Verify", + fields=[ + {"id": VERIFICATION_CODE_KEY, "name": "Verification code", "type": "string"} + ], ) + return -def setup_august(hass, config, api, authenticator): +async def async_setup_august(hass, config_entry, august_gateway): """Set up the August component.""" - from august.authenticator import AuthenticationState - authentication = None + entry_id = config_entry.entry_id + hass.data[DOMAIN].setdefault(entry_id, {}) + try: - authentication = authenticator.authenticate() - except RequestException as ex: - _LOGGER.error("Unable to connect to August service: %s", str(ex)) + await august_gateway.async_authenticate() + except RequireValidation: + await async_request_validation(hass, config_entry, august_gateway) + return False + except InvalidAuth: + _LOGGER.error("Password is no longer valid. Please set up August again") + return False - hass.components.persistent_notification.create( - "Error: {}
" - "You will need to restart hass after fixing." - "".format(ex), - title=NOTIFICATION_TITLE, - notification_id=NOTIFICATION_ID) + # We still use the configurator to get a new 2fa code + # when needed since config_flow doesn't have a way + # to re-request if it expires + if TWO_FA_REVALIDATE in hass.data[DOMAIN][entry_id]: + hass.components.configurator.async_request_done( + hass.data[DOMAIN][entry_id].pop(TWO_FA_REVALIDATE) + ) - state = authentication.state + hass.data[DOMAIN][entry_id][DATA_AUGUST] = AugustData(hass, august_gateway) - if state == AuthenticationState.AUTHENTICATED: - if DOMAIN in _CONFIGURING: - hass.components.configurator.request_done(_CONFIGURING.pop(DOMAIN)) + await hass.data[DOMAIN][entry_id][DATA_AUGUST].async_setup() - hass.data[DATA_AUGUST] = AugustData( - hass, api, authentication.access_token) + for component in AUGUST_COMPONENTS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(config_entry, component) + ) - for component in AUGUST_COMPONENTS: - discovery.load_platform(hass, component, DOMAIN, {}, config) + return True - 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 +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the August component from YAML.""" + conf = config.get(DOMAIN) + hass.data.setdefault(DOMAIN, {}) -def setup(hass, config): - """Set up the August component.""" - from august.api import Api - from august.authenticator import Authenticator - from requests import Session + if not conf: + return True - 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)) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_LOGIN_METHOD: conf.get(CONF_LOGIN_METHOD), + CONF_USERNAME: conf.get(CONF_USERNAME), + CONF_PASSWORD: conf.get(CONF_PASSWORD), + CONF_INSTALL_ID: conf.get(CONF_INSTALL_ID), + CONF_ACCESS_TOKEN_CACHE_FILE: DEFAULT_AUGUST_CONFIG_FILE, + }, + ) + ) + return True - 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)) +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up August from a config entry.""" - 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 + august_gateway = AugustGateway(hass) - _LOGGER.debug("August HTTP session closed.") + try: + await august_gateway.async_setup(entry.data) + return await async_setup_august(hass, entry, august_gateway) + except asyncio.TimeoutError: + raise ConfigEntryNotReady + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in AUGUST_COMPONENTS + ] + ) + ) - hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, close_http_session) - _LOGGER.debug("Registered for HASS stop event") + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) - return setup_august(hass, config, api, authenticator) + return unload_ok -class AugustData: +class AugustData(AugustSubscriberMixin): """August data object.""" - def __init__(self, hass, api, access_token): + def __init__(self, hass, august_gateway): """Init August data object.""" + super().__init__(hass, MIN_TIME_BETWEEN_DETAIL_UPDATES) 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 + self._august_gateway = august_gateway + self.activity_stream = None + self._api = august_gateway.api + self._device_detail_by_id = {} + self._doorbells_by_id = {} + self._locks_by_id = {} + self._house_ids = set() + + async def async_setup(self): + """Async setup of august device data and activities.""" + locks = ( + await self._api.async_get_operable_locks(self._august_gateway.access_token) + or [] + ) + doorbells = ( + await self._api.async_get_doorbells(self._august_gateway.access_token) or [] + ) + + self._doorbells_by_id = {device.device_id: device for device in doorbells} + self._locks_by_id = {device.device_id: device for device in locks} + self._house_ids = { + device.house_id for device in itertools.chain(locks, doorbells) + } + + await self._async_refresh_device_detail_by_ids( + [device.device_id for device in itertools.chain(locks, doorbells)] + ) + + # We remove all devices that we are missing + # detail as we cannot determine if they are usable. + # This also allows us to avoid checking for + # detail being None all over the place + self._remove_inoperative_locks() + self._remove_inoperative_doorbells() + + self.activity_stream = ActivityStream( + self._hass, self._api, self._august_gateway, self._house_ids + ) + await self.activity_stream.async_setup() @property def doorbells(self): - """Return a list of doorbells.""" - return self._doorbells + """Return a list of py-august Doorbell objects.""" + return self._doorbells_by_id.values() @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): + """Return a list of py-august Lock objects.""" + return self._locks_by_id.values() + + def get_device_detail(self, device_id): + """Return the py-august LockDetail or DoorbellDetail object for a device.""" + return self._device_detail_by_id[device_id] + + async def _async_refresh(self, time): + await self._async_refresh_device_detail_by_ids(self._subscriptions.keys()) + + async def _async_refresh_device_detail_by_ids(self, device_ids_list): + for device_id in device_ids_list: + if device_id in self._locks_by_id: + await self._async_update_device_detail( + self._locks_by_id[device_id], self._api.async_get_lock_detail + ) + # keypads are always attached to locks + if ( + device_id in self._device_detail_by_id + and self._device_detail_by_id[device_id].keypad is not None + ): + keypad = self._device_detail_by_id[device_id].keypad + self._device_detail_by_id[keypad.device_id] = keypad + elif device_id in self._doorbells_by_id: + await self._async_update_device_detail( + self._doorbells_by_id[device_id], + self._api.async_get_doorbell_detail, + ) + _LOGGER.debug( + "async_signal_device_id_update (from detail updates): %s", device_id + ) + self.async_signal_device_id_update(device_id) + + async def _async_update_device_detail(self, device, api_call): + _LOGGER.debug( + "Started retrieving detail for %s (%s)", + device.device_name, + device.device_id, + ) + + try: + self._device_detail_by_id[device.device_id] = await api_call( + self._august_gateway.access_token, device.device_id + ) + except ClientError as ex: + _LOGGER.error( + "Request error trying to retrieve %s details for %s. %s", + device.device_id, + device.device_name, + ex, + ) + _LOGGER.debug( + "Completed retrieving detail for %s (%s)", + device.device_name, + device.device_id, + ) + + def _get_device_name(self, device_id): + """Return doorbell or lock name as August has it stored.""" + if self._locks_by_id.get(device_id): + return self._locks_by_id[device_id].device_name + if self._doorbells_by_id.get(device_id): + return self._doorbells_by_id[device_id].device_name + + async def async_lock(self, device_id): """Lock the device.""" - return self._api.lock(self._access_token, device_id) - - def unlock(self, device_id): + return await self._async_call_api_op_requires_bridge( + device_id, + self._api.async_lock_return_activities, + self._august_gateway.access_token, + device_id, + ) + + async def async_unlock(self, device_id): """Unlock the device.""" - return self._api.unlock(self._access_token, device_id) + return await self._async_call_api_op_requires_bridge( + device_id, + self._api.async_unlock_return_activities, + self._august_gateway.access_token, + device_id, + ) + + async def _async_call_api_op_requires_bridge( + self, device_id, func, *args, **kwargs + ): + """Call an API that requires the bridge to be online and will change the device state.""" + ret = None + try: + ret = await func(*args, **kwargs) + except AugustApiAIOHTTPError as err: + device_name = self._get_device_name(device_id) + if device_name is None: + device_name = f"DeviceID: {device_id}" + raise HomeAssistantError(f"{device_name}: {err}") + + return ret + + def _remove_inoperative_doorbells(self): + doorbells = list(self.doorbells) + for doorbell in doorbells: + device_id = doorbell.device_id + doorbell_is_operative = False + doorbell_detail = self._device_detail_by_id.get(device_id) + if doorbell_detail is None: + _LOGGER.info( + "The doorbell %s could not be setup because the system could not fetch details about the doorbell.", + doorbell.device_name, + ) + else: + doorbell_is_operative = True + + if not doorbell_is_operative: + del self._doorbells_by_id[device_id] + del self._device_detail_by_id[device_id] + + def _remove_inoperative_locks(self): + # Remove non-operative locks as there must + # be a bridge (August Connect) for them to + # be usable + locks = list(self.locks) + + for lock in locks: + device_id = lock.device_id + lock_is_operative = False + lock_detail = self._device_detail_by_id.get(device_id) + if lock_detail is None: + _LOGGER.info( + "The lock %s could not be setup because the system could not fetch details about the lock.", + lock.device_name, + ) + elif lock_detail.bridge is None: + _LOGGER.info( + "The lock %s could not be setup because it does not have a bridge (Connect).", + lock.device_name, + ) + elif not lock_detail.bridge.operative: + _LOGGER.info( + "The lock %s could not be setup because the bridge (Connect) is not operative.", + lock.device_name, + ) + else: + lock_is_operative = True + + if not lock_is_operative: + del self._locks_by_id[device_id] + del self._device_detail_by_id[device_id] diff --git a/homeassistant/components/august/activity.py b/homeassistant/components/august/activity.py new file mode 100644 index 0000000000000..c7a7d68d95984 --- /dev/null +++ b/homeassistant/components/august/activity.py @@ -0,0 +1,124 @@ +"""Consume the august activity stream.""" +import logging + +from aiohttp import ClientError + +from homeassistant.util.dt import utcnow + +from .const import ACTIVITY_UPDATE_INTERVAL +from .subscriber import AugustSubscriberMixin + +_LOGGER = logging.getLogger(__name__) + +ACTIVITY_STREAM_FETCH_LIMIT = 10 +ACTIVITY_CATCH_UP_FETCH_LIMIT = 1000 + + +class ActivityStream(AugustSubscriberMixin): + """August activity stream handler.""" + + def __init__(self, hass, api, august_gateway, house_ids): + """Init August activity stream object.""" + super().__init__(hass, ACTIVITY_UPDATE_INTERVAL) + self._hass = hass + self._august_gateway = august_gateway + self._api = api + self._house_ids = house_ids + self._latest_activities_by_id_type = {} + self._last_update_time = None + self._abort_async_track_time_interval = None + + async def async_setup(self): + """Token refresh check and catch up the activity stream.""" + await self._async_refresh(utcnow) + + def get_latest_device_activity(self, device_id, activity_types): + """Return latest activity that is one of the acitivty_types.""" + if device_id not in self._latest_activities_by_id_type: + return None + + latest_device_activities = self._latest_activities_by_id_type[device_id] + latest_activity = None + + for activity_type in activity_types: + if activity_type in latest_device_activities: + if ( + latest_activity is not None + and latest_device_activities[activity_type].activity_start_time + <= latest_activity.activity_start_time + ): + continue + latest_activity = latest_device_activities[activity_type] + + return latest_activity + + async def _async_refresh(self, time): + """Update the activity stream from August.""" + + # This is the only place we refresh the api token + await self._august_gateway.async_refresh_access_token_if_needed() + await self._async_update_device_activities(time) + + async def _async_update_device_activities(self, time): + _LOGGER.debug("Start retrieving device activities") + + limit = ( + ACTIVITY_STREAM_FETCH_LIMIT + if self._last_update_time + else ACTIVITY_CATCH_UP_FETCH_LIMIT + ) + + for house_id in self._house_ids: + _LOGGER.debug("Updating device activity for house id %s", house_id) + try: + activities = await self._api.async_get_house_activities( + self._august_gateway.access_token, house_id, limit=limit + ) + except ClientError as ex: + _LOGGER.error( + "Request error trying to retrieve activity for house id %s: %s", + house_id, + ex, + ) + # Make sure we process the next house if one of them fails + continue + + _LOGGER.debug( + "Completed retrieving device activities for house id %s", house_id + ) + + updated_device_ids = self._process_newer_device_activities(activities) + + if updated_device_ids: + for device_id in updated_device_ids: + _LOGGER.debug( + "async_signal_device_id_update (from activity stream): %s", + device_id, + ) + self.async_signal_device_id_update(device_id) + + self._last_update_time = time + + def _process_newer_device_activities(self, activities): + updated_device_ids = set() + for activity in activities: + self._latest_activities_by_id_type.setdefault(activity.device_id, {}) + + lastest_activity = self._latest_activities_by_id_type[ + activity.device_id + ].get(activity.activity_type) + + # Ignore activities that are older than the latest one + if ( + lastest_activity + and lastest_activity.activity_start_time >= activity.activity_start_time + ): + continue + + self._latest_activities_by_id_type[activity.device_id][ + activity.activity_type + ] = activity + + updated_device_ids.add(activity.device_id) + + return updated_device_ids diff --git a/homeassistant/components/august/binary_sensor.py b/homeassistant/components/august/binary_sensor.py index d1f696458029d..6602cfe866130 100644 --- a/homeassistant/components/august/binary_sensor.py +++ b/homeassistant/components/august/binary_sensor.py @@ -2,160 +2,172 @@ from datetime import datetime, timedelta import logging -from homeassistant.components.binary_sensor import BinarySensorDevice - -from . import DATA_AUGUST +from august.activity import ActivityType +from august.lock import LockDoorStatus +from august.util import update_lock_detail_from_activity + +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_CONNECTIVITY, + DEVICE_CLASS_DOOR, + DEVICE_CLASS_MOTION, + DEVICE_CLASS_OCCUPANCY, + BinarySensorEntity, +) +from homeassistant.core import callback +from homeassistant.helpers.event import async_track_point_in_utc_time +from homeassistant.util.dt import utcnow + +from .const import DATA_AUGUST, DOMAIN +from .entity import AugustEntityMixin _LOGGER = logging.getLogger(__name__) -SCAN_INTERVAL = timedelta(seconds=5) +TIME_TO_DECLARE_DETECTION = timedelta(seconds=60) -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, detail): + """Get the latest state of the sensor.""" + # The doorbell will go into standby mode when there is no motion + # for a short while. It will wake by itself when needed so we need + # to consider is available or we will not report motion or dings + return detail.is_online or detail.is_standby -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, detail): + return _activity_time_based_state( + data, + detail.device_id, + [ActivityType.DOORBELL_MOTION, ActivityType.DOORBELL_DING], + ) -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, detail): -def _retrieve_ding_state(data, doorbell): - from august.activity import ActivityType - return _activity_time_based_state(data, doorbell, - [ActivityType.DOORBELL_DING]) + return _activity_time_based_state( + data, detail.device_id, [ActivityType.DOORBELL_DING] + ) -def _activity_time_based_state(data, doorbell, activity_types): +def _activity_time_based_state(data, device_id, activity_types): """Get the latest state of the sensor.""" - latest = data.get_latest_device_activity(doorbell.device_id, - *activity_types) + latest = data.activity_stream.get_latest_device_activity(device_id, activity_types) if latest is not None: start = latest.activity_start_time - end = latest.activity_end_time + timedelta(seconds=30) + end = latest.activity_end_time + TIME_TO_DECLARE_DETECTION 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_NAME = 0 +SENSOR_DEVICE_CLASS = 1 +SENSOR_STATE_PROVIDER = 2 +SENSOR_STATE_IS_TIME_BASED = 3 +# sensor_type: [name, device_class, state_provider, is_time_based] SENSOR_TYPES_DOORBELL = { - 'doorbell_ding': ['Ding', 'occupancy', _retrieve_ding_state], - 'doorbell_motion': ['Motion', 'motion', _retrieve_motion_state], - 'doorbell_online': ['Online', 'connectivity', _retrieve_online_state], + "doorbell_ding": ["Ding", DEVICE_CLASS_OCCUPANCY, _retrieve_ding_state, True], + "doorbell_motion": ["Motion", DEVICE_CLASS_MOTION, _retrieve_motion_state, True], + "doorbell_online": [ + "Online", + DEVICE_CLASS_CONNECTIVITY, + _retrieve_online_state, + False, + ], } -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the August binary sensors.""" - data = hass.data[DATA_AUGUST] + data = hass.data[DOMAIN][config_entry.entry_id][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 - + detail = data.get_device_detail(door.device_id) + if not detail.doorsense: _LOGGER.debug( - "Adding sensor class %s for %s", - SENSOR_TYPES_DOOR[sensor_type][1], door.device_name + "Not adding sensor class door for lock %s because it does not have doorsense.", + door.device_name, ) - devices.append(AugustDoorBinarySensor(data, sensor_type, door)) + continue + + _LOGGER.debug("Adding sensor class door for %s", door.device_name) + devices.append(AugustDoorBinarySensor(data, "door_open", 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) + _LOGGER.debug( + "Adding doorbell sensor class %s for %s", + SENSOR_TYPES_DOORBELL[sensor_type][SENSOR_DEVICE_CLASS], + doorbell.device_name, ) + devices.append(AugustDoorbellBinarySensor(data, sensor_type, doorbell)) - add_entities(devices, True) + async_add_entities(devices, True) -class AugustDoorBinarySensor(BinarySensorDevice): +class AugustDoorBinarySensor(AugustEntityMixin, BinarySensorEntity): """Representation of an August Door binary sensor.""" - def __init__(self, data, sensor_type, door): + def __init__(self, data, sensor_type, device): """Initialize the sensor.""" + super().__init__(data, device) self._data = data self._sensor_type = sensor_type - self._door = door - self._state = None - self._available = False + self._device = device + self._update_from_data() @property def available(self): """Return the availability of this sensor.""" - return self._available + return self._detail.bridge_is_online @property def is_on(self): """Return true if the binary sensor is on.""" - return self._state + return self._detail.door_state == LockDoorStatus.OPEN @property def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return SENSOR_TYPES_DOOR[self._sensor_type][1] + """Return the class of this device.""" + return DEVICE_CLASS_DOOR @property def name(self): """Return the name of the binary sensor.""" - return "{} {}".format(self._door.device_name, - SENSOR_TYPES_DOOR[self._sensor_type][0]) + return f"{self._device.device_name} Open" - 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 + @callback + def _update_from_data(self): + """Get the latest state of the sensor and update activity.""" + door_activity = self._data.activity_stream.get_latest_device_activity( + self._device_id, [ActivityType.DOOR_OPERATION] + ) - from august.lock import LockDoorStatus - self._state = self._state == LockDoorStatus.OPEN + if door_activity is not None: + update_lock_detail_from_activity(self._detail, door_activity) @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()) + return f"{self._device_id}_open" -class AugustDoorbellBinarySensor(BinarySensorDevice): +class AugustDoorbellBinarySensor(AugustEntityMixin, BinarySensorEntity): """Representation of an August binary sensor.""" - def __init__(self, data, sensor_type, doorbell): + def __init__(self, data, sensor_type, device): """Initialize the sensor.""" + super().__init__(data, device) + self._check_for_off_update_listener = None self._data = data self._sensor_type = sensor_type - self._doorbell = doorbell + self._device = device self._state = None self._available = False + self._update_from_data() @property def available(self): @@ -170,23 +182,73 @@ def is_on(self): @property def device_class(self): """Return the class of this device, from component DEVICE_CLASSES.""" - return SENSOR_TYPES_DOORBELL[self._sensor_type][1] + return SENSOR_TYPES_DOORBELL[self._sensor_type][SENSOR_DEVICE_CLASS] @property def name(self): """Return the name of the binary sensor.""" - return "{} {}".format(self._doorbell.device_name, - SENSOR_TYPES_DOORBELL[self._sensor_type][0]) + return f"{self._device.device_name} {SENSOR_TYPES_DOORBELL[self._sensor_type][SENSOR_NAME]}" + + @property + def _state_provider(self): + """Return the state provider for the binary sensor.""" + return SENSOR_TYPES_DOORBELL[self._sensor_type][SENSOR_STATE_PROVIDER] + + @property + def _is_time_based(self): + """Return true of false if the sensor is time based.""" + return SENSOR_TYPES_DOORBELL[self._sensor_type][SENSOR_STATE_IS_TIME_BASED] - def update(self): + @callback + def _update_from_data(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 + self._cancel_any_pending_updates() + self._state = self._state_provider(self._data, self._detail) + + if self._is_time_based: + self._available = _retrieve_online_state(self._data, self._detail) + self._schedule_update_to_recheck_turn_off_sensor() + else: + self._available = True + + def _schedule_update_to_recheck_turn_off_sensor(self): + """Schedule an update to recheck the sensor to see if it is ready to turn off.""" + + # If the sensor is already off there is nothing to do + if not self._state: + return + + # self.hass is only available after setup is completed + # and we will recheck in async_added_to_hass + if not self.hass: + return + + @callback + def _scheduled_update(now): + """Timer callback for sensor update.""" + self._check_for_off_update_listener = None + self._update_from_data() + + self._check_for_off_update_listener = async_track_point_in_utc_time( + self.hass, _scheduled_update, utcnow() + TIME_TO_DECLARE_DETECTION + ) + + def _cancel_any_pending_updates(self): + """Cancel any updates to recheck a sensor to see if it is ready to turn off.""" + if self._check_for_off_update_listener: + _LOGGER.debug("%s: canceled pending update", self.entity_id) + self._check_for_off_update_listener() + self._check_for_off_update_listener = None + + async def async_added_to_hass(self): + """Call the mixin to subscribe and setup an async_track_point_in_utc_time to turn off the sensor if needed.""" + self._schedule_update_to_recheck_turn_off_sensor() + await super().async_added_to_hass() @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()) + return ( + f"{self._device_id}_" + f"{SENSOR_TYPES_DOORBELL[self._sensor_type][SENSOR_NAME].lower()}" + ) diff --git a/homeassistant/components/august/camera.py b/homeassistant/components/august/camera.py index 0bf8a28f90488..4037489fa229a 100644 --- a/homeassistant/components/august/camera.py +++ b/homeassistant/components/august/camera.py @@ -1,34 +1,35 @@ -"""Support for August camera.""" -from datetime import timedelta +"""Support for August doorbell camera.""" -import requests +from august.activity import ActivityType +from august.util import update_doorbell_image_from_activity from homeassistant.components.camera import Camera +from homeassistant.core import callback +from homeassistant.helpers import aiohttp_client -from . import DATA_AUGUST, DEFAULT_TIMEOUT +from .const import DATA_AUGUST, DEFAULT_NAME, DEFAULT_TIMEOUT, DOMAIN +from .entity import AugustEntityMixin -SCAN_INTERVAL = timedelta(seconds=5) - -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_entry(hass, config_entry, async_add_entities): """Set up August cameras.""" - data = hass.data[DATA_AUGUST] + data = hass.data[DOMAIN][config_entry.entry_id][DATA_AUGUST] devices = [] for doorbell in data.doorbells: devices.append(AugustCamera(data, doorbell, DEFAULT_TIMEOUT)) - add_entities(devices, True) + async_add_entities(devices, True) -class AugustCamera(Camera): - """An implementation of a Canary security camera.""" +class AugustCamera(AugustEntityMixin, Camera): + """An implementation of a August security camera.""" - def __init__(self, data, doorbell, timeout): - """Initialize a Canary security camera.""" - super().__init__() + def __init__(self, data, device, timeout): + """Initialize a August security camera.""" + super().__init__(data, device) self._data = data - self._doorbell = doorbell + self._device = device self._timeout = timeout self._image_url = None self._image_content = None @@ -36,12 +37,12 @@ def __init__(self, data, doorbell, timeout): @property def name(self): """Return the name of this device.""" - return self._doorbell.device_name + return f"{self._device.device_name} Camera" @property def is_recording(self): """Return true if the device is recording.""" - return self._doorbell.has_subscription + return self._device.has_subscription @property def motion_detection_enabled(self): @@ -51,25 +52,35 @@ def motion_detection_enabled(self): @property def brand(self): """Return the camera brand.""" - return 'August' + return DEFAULT_NAME @property def model(self): """Return the camera model.""" - return 'Doorbell' + return self._detail.model - def camera_image(self): - """Return bytes of camera image.""" - latest = self._data.get_doorbell_detail(self._doorbell.device_id) + @callback + def _update_from_data(self): + """Get the latest state of the sensor.""" + doorbell_activity = self._data.activity_stream.get_latest_device_activity( + self._device_id, [ActivityType.DOORBELL_MOTION] + ) - 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 + if doorbell_activity is not None: + update_doorbell_image_from_activity(self._detail, doorbell_activity) + + async def async_camera_image(self): + """Return bytes of camera image.""" + self._update_from_data() + if self._image_url is not self._detail.image_url: + self._image_url = self._detail.image_url + self._image_content = await self._detail.async_get_doorbell_image( + aiohttp_client.async_get_clientsession(self.hass), timeout=self._timeout + ) 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) + return f"{self._device_id:s}_camera" diff --git a/homeassistant/components/august/config_flow.py b/homeassistant/components/august/config_flow.py new file mode 100644 index 0000000000000..acdfb1d4b6397 --- /dev/null +++ b/homeassistant/components/august/config_flow.py @@ -0,0 +1,133 @@ +"""Config flow for August integration.""" +import logging + +from august.authenticator import ValidationResult +import voluptuous as vol + +from homeassistant import config_entries, core +from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME + +from .const import ( + CONF_LOGIN_METHOD, + DEFAULT_TIMEOUT, + LOGIN_METHODS, + VERIFICATION_CODE_KEY, +) +from .const import DOMAIN # pylint:disable=unused-import +from .exceptions import CannotConnect, InvalidAuth, RequireValidation +from .gateway import AugustGateway + +_LOGGER = logging.getLogger(__name__) + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_LOGIN_METHOD, default="phone"): vol.In(LOGIN_METHODS), + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): vol.Coerce(int), + } +) + + +async def async_validate_input( + hass: core.HomeAssistant, data, august_gateway, +): + """Validate the user input allows us to connect. + + Data has the keys from DATA_SCHEMA with values provided by the user. + + Request configuration steps from the user. + """ + + code = data.get(VERIFICATION_CODE_KEY) + + if code is not None: + result = await august_gateway.authenticator.async_validate_verification_code( + code + ) + _LOGGER.debug("Verification code validation: %s", result) + if result != ValidationResult.VALIDATED: + raise RequireValidation + + try: + await august_gateway.async_authenticate() + except RequireValidation: + _LOGGER.debug( + "Requesting new verification code for %s via %s", + data.get(CONF_USERNAME), + data.get(CONF_LOGIN_METHOD), + ) + if code is None: + await august_gateway.authenticator.async_send_verification_code() + raise + + return { + "title": data.get(CONF_USERNAME), + "data": august_gateway.config_entry(), + } + + +class AugustConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for August.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + def __init__(self): + """Store an AugustGateway().""" + self._august_gateway = None + self.user_auth_details = {} + super().__init__() + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + if self._august_gateway is None: + self._august_gateway = AugustGateway(self.hass) + errors = {} + if user_input is not None: + await self._august_gateway.async_setup(user_input) + + try: + info = await async_validate_input( + self.hass, user_input, self._august_gateway, + ) + await self.async_set_unique_id(user_input[CONF_USERNAME]) + return self.async_create_entry(title=info["title"], data=info["data"]) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except RequireValidation: + self.user_auth_details = user_input + + return await self.async_step_validation() + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + async def async_step_validation(self, user_input=None): + """Handle validation (2fa) step.""" + if user_input: + return await self.async_step_user({**self.user_auth_details, **user_input}) + + return self.async_show_form( + step_id="validation", + data_schema=vol.Schema( + {vol.Required(VERIFICATION_CODE_KEY): vol.All(str, vol.Strip)} + ), + description_placeholders={ + CONF_USERNAME: self.user_auth_details.get(CONF_USERNAME), + CONF_LOGIN_METHOD: self.user_auth_details.get(CONF_LOGIN_METHOD), + }, + ) + + async def async_step_import(self, user_input): + """Handle import.""" + await self.async_set_unique_id(user_input[CONF_USERNAME]) + self._abort_if_unique_id_configured() + + return await self.async_step_user(user_input) diff --git a/homeassistant/components/august/const.py b/homeassistant/components/august/const.py new file mode 100644 index 0000000000000..e8b8637b6cb2c --- /dev/null +++ b/homeassistant/components/august/const.py @@ -0,0 +1,44 @@ +"""Constants for August devices.""" + +from datetime import timedelta + +DEFAULT_TIMEOUT = 10 + +CONF_ACCESS_TOKEN_CACHE_FILE = "access_token_cache_file" +CONF_LOGIN_METHOD = "login_method" +CONF_INSTALL_ID = "install_id" + +VERIFICATION_CODE_KEY = "verification_code" + +NOTIFICATION_ID = "august_notification" +NOTIFICATION_TITLE = "August" + +DEFAULT_AUGUST_CONFIG_FILE = ".august.conf" + +DATA_AUGUST = "data_august" + +DEFAULT_NAME = "August" +DOMAIN = "august" + +OPERATION_METHOD_AUTORELOCK = "autorelock" +OPERATION_METHOD_REMOTE = "remote" +OPERATION_METHOD_KEYPAD = "keypad" +OPERATION_METHOD_MOBILE_DEVICE = "mobile" + +ATTR_OPERATION_AUTORELOCK = "autorelock" +ATTR_OPERATION_METHOD = "method" +ATTR_OPERATION_REMOTE = "remote" +ATTR_OPERATION_KEYPAD = "keypad" + +# Limit battery, online, and hardware updates to hourly +# in order to reduce the number of api requests and +# avoid hitting rate limits +MIN_TIME_BETWEEN_DETAIL_UPDATES = timedelta(hours=1) + +# Activity needs to be checked more frequently as the +# doorbell motion and rings are included here +ACTIVITY_UPDATE_INTERVAL = timedelta(seconds=10) + +LOGIN_METHODS = ["phone", "email"] + +AUGUST_COMPONENTS = ["camera", "binary_sensor", "lock", "sensor"] diff --git a/homeassistant/components/august/entity.py b/homeassistant/components/august/entity.py new file mode 100644 index 0000000000000..a3f72da44beb8 --- /dev/null +++ b/homeassistant/components/august/entity.py @@ -0,0 +1,67 @@ +"""Base class for August entity.""" + +import logging + +from homeassistant.core import callback +from homeassistant.helpers.entity import Entity + +from . import DEFAULT_NAME, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class AugustEntityMixin(Entity): + """Base implementation for August device.""" + + def __init__(self, data, device): + """Initialize an August device.""" + super().__init__() + self._data = data + self._device = device + + @property + def should_poll(self): + """Return False, updates are controlled via the hub.""" + return False + + @property + def _device_id(self): + return self._device.device_id + + @property + def _detail(self): + return self._data.get_device_detail(self._device.device_id) + + @property + def device_info(self): + """Return the device_info of the device.""" + return { + "identifiers": {(DOMAIN, self._device_id)}, + "name": self._device.device_name, + "manufacturer": DEFAULT_NAME, + "sw_version": self._detail.firmware_version, + "model": self._detail.model, + } + + @callback + def _update_from_data_and_write_state(self): + self._update_from_data() + self.async_write_ha_state() + + async def async_added_to_hass(self): + """Subscribe to updates.""" + self._data.async_subscribe_device_id( + self._device_id, self._update_from_data_and_write_state + ) + self._data.activity_stream.async_subscribe_device_id( + self._device_id, self._update_from_data_and_write_state + ) + + async def async_will_remove_from_hass(self): + """Undo subscription.""" + self._data.async_unsubscribe_device_id( + self._device_id, self._update_from_data_and_write_state + ) + self._data.activity_stream.async_unsubscribe_device_id( + self._device_id, self._update_from_data_and_write_state + ) diff --git a/homeassistant/components/august/exceptions.py b/homeassistant/components/august/exceptions.py new file mode 100644 index 0000000000000..78c467ab3a1b7 --- /dev/null +++ b/homeassistant/components/august/exceptions.py @@ -0,0 +1,15 @@ +"""Shared excecption for the august integration.""" + +from homeassistant import exceptions + + +class RequireValidation(exceptions.HomeAssistantError): + """Error to indicate we require validation (2fa).""" + + +class CannotConnect(exceptions.HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(exceptions.HomeAssistantError): + """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/august/gateway.py b/homeassistant/components/august/gateway.py new file mode 100644 index 0000000000000..bb39523a9847b --- /dev/null +++ b/homeassistant/components/august/gateway.py @@ -0,0 +1,133 @@ +"""Handle August connection setup and authentication.""" + +import asyncio +import logging + +from aiohttp import ClientError +from august.api_async import ApiAsync +from august.authenticator_async import AuthenticationState, AuthenticatorAsync + +from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME +from homeassistant.helpers import aiohttp_client + +from .const import ( + CONF_ACCESS_TOKEN_CACHE_FILE, + CONF_INSTALL_ID, + CONF_LOGIN_METHOD, + DEFAULT_AUGUST_CONFIG_FILE, + VERIFICATION_CODE_KEY, +) +from .exceptions import CannotConnect, InvalidAuth, RequireValidation + +_LOGGER = logging.getLogger(__name__) + + +class AugustGateway: + """Handle the connection to August.""" + + def __init__(self, hass): + """Init the connection.""" + self._aiohttp_session = aiohttp_client.async_get_clientsession(hass) + self._token_refresh_lock = asyncio.Lock() + self._access_token_cache_file = None + self._hass = hass + self._config = None + self._api = None + self._authenticator = None + self._authentication = None + + @property + def authenticator(self): + """August authentication object from py-august.""" + return self._authenticator + + @property + def authentication(self): + """August authentication object from py-august.""" + return self._authentication + + @property + def access_token(self): + """Access token for the api.""" + return self._authentication.access_token + + @property + def api(self): + """August api object from py-august.""" + return self._api + + def config_entry(self): + """Config entry.""" + return { + CONF_LOGIN_METHOD: self._config[CONF_LOGIN_METHOD], + CONF_USERNAME: self._config[CONF_USERNAME], + CONF_PASSWORD: self._config[CONF_PASSWORD], + CONF_INSTALL_ID: self._config.get(CONF_INSTALL_ID), + CONF_TIMEOUT: self._config.get(CONF_TIMEOUT), + CONF_ACCESS_TOKEN_CACHE_FILE: self._access_token_cache_file, + } + + async def async_setup(self, conf): + """Create the api and authenticator objects.""" + if conf.get(VERIFICATION_CODE_KEY): + return + + self._access_token_cache_file = conf.get( + CONF_ACCESS_TOKEN_CACHE_FILE, + f".{conf[CONF_USERNAME]}{DEFAULT_AUGUST_CONFIG_FILE}", + ) + self._config = conf + + self._api = ApiAsync( + self._aiohttp_session, timeout=self._config.get(CONF_TIMEOUT) + ) + + self._authenticator = AuthenticatorAsync( + self._api, + self._config[CONF_LOGIN_METHOD], + self._config[CONF_USERNAME], + self._config[CONF_PASSWORD], + install_id=self._config.get(CONF_INSTALL_ID), + access_token_cache_file=self._hass.config.path( + self._access_token_cache_file + ), + ) + + await self._authenticator.async_setup_authentication() + + async def async_authenticate(self): + """Authenticate with the details provided to setup.""" + self._authentication = None + try: + self._authentication = await self.authenticator.async_authenticate() + except ClientError as ex: + _LOGGER.error("Unable to connect to August service: %s", str(ex)) + raise CannotConnect + + if self._authentication.state == AuthenticationState.BAD_PASSWORD: + raise InvalidAuth + + if self._authentication.state == AuthenticationState.REQUIRES_VALIDATION: + raise RequireValidation + + if self._authentication.state != AuthenticationState.AUTHENTICATED: + _LOGGER.error( + "Unknown authentication state: %s", self._authentication.state + ) + raise InvalidAuth + + return self._authentication + + async def async_refresh_access_token_if_needed(self): + """Refresh the august access token if needed.""" + if self.authenticator.should_refresh(): + async with self._token_refresh_lock: + refreshed_authentication = await self.authenticator.async_refresh_access_token( + force=False + ) + _LOGGER.info( + "Refreshed august access token. The old token expired at %s, and the new token expires at %s", + self.authentication.access_token_expires, + refreshed_authentication.access_token_expires, + ) + self._authentication = refreshed_authentication diff --git a/homeassistant/components/august/lock.py b/homeassistant/components/august/lock.py index 5ad2bdc3b5bc2..e16c603d919ea 100644 --- a/homeassistant/components/august/lock.py +++ b/homeassistant/components/august/lock.py @@ -1,68 +1,91 @@ """Support for August lock.""" -from datetime import timedelta import logging -from homeassistant.components.lock import LockDevice +from august.activity import ActivityType +from august.lock import LockStatus +from august.util import update_lock_detail_from_activity + +from homeassistant.components.lock import ATTR_CHANGED_BY, LockEntity from homeassistant.const import ATTR_BATTERY_LEVEL +from homeassistant.core import callback +from homeassistant.helpers.restore_state import RestoreEntity -from . import DATA_AUGUST +from .const import DATA_AUGUST, DOMAIN +from .entity import AugustEntityMixin _LOGGER = logging.getLogger(__name__) -SCAN_INTERVAL = timedelta(seconds=5) - -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_entry(hass, config_entry, async_add_entities): """Set up August locks.""" - data = hass.data[DATA_AUGUST] + data = hass.data[DOMAIN][config_entry.entry_id][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) + async_add_entities(devices, True) -class AugustLock(LockDevice): +class AugustLock(AugustEntityMixin, RestoreEntity, LockEntity): """Representation of an August lock.""" - def __init__(self, data, lock): + def __init__(self, data, device): """Initialize the lock.""" + super().__init__(data, device) self._data = data - self._lock = lock + self._device = device self._lock_status = None - self._lock_detail = None self._changed_by = None self._available = False + self._update_from_data() - def lock(self, **kwargs): + async def async_lock(self, **kwargs): """Lock the device.""" - self._data.lock(self._lock.device_id) + await self._call_lock_operation(self._data.async_lock) - def unlock(self, **kwargs): + async def async_unlock(self, **kwargs): """Unlock the device.""" - self._data.unlock(self._lock.device_id) + await self._call_lock_operation(self._data.async_unlock) + + async def _call_lock_operation(self, lock_operation): + activities = await lock_operation(self._device_id) + for lock_activity in activities: + update_lock_detail_from_activity(self._detail, lock_activity) - 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 + if self._update_lock_status_from_detail(): + _LOGGER.debug( + "async_signal_device_id_update (from lock operation): %s", + self._device_id, + ) + self._data.async_signal_device_id_update(self._device_id) - self._lock_detail = self._data.get_lock_detail(self._lock.device_id) + def _update_lock_status_from_detail(self): + self._available = self._detail.bridge_is_online - from august.activity import ActivityType - activity = self._data.get_latest_device_activity( - self._lock.device_id, - ActivityType.LOCK_OPERATION) + if self._lock_status != self._detail.lock_status: + self._lock_status = self._detail.lock_status + return True + return False - if activity is not None: - self._changed_by = activity.operated_by + @callback + def _update_from_data(self): + """Get the latest state of the sensor and update activity.""" + lock_activity = self._data.activity_stream.get_latest_device_activity( + self._device_id, [ActivityType.LOCK_OPERATION] + ) + + if lock_activity is not None: + self._changed_by = lock_activity.operated_by + update_lock_detail_from_activity(self._detail, lock_activity) + + self._update_lock_status_from_detail() @property def name(self): """Return the name of this device.""" - return self._lock.device_name + return self._device.device_name @property def available(self): @@ -72,7 +95,8 @@ def available(self): @property def is_locked(self): """Return true if device is on.""" - from august.lock import LockStatus + if self._lock_status is None or self._lock_status is LockStatus.UNKNOWN: + return None return self._lock_status is LockStatus.LOCKED @property @@ -83,14 +107,25 @@ def changed_by(self): @property def device_state_attributes(self): """Return the device specific state attributes.""" - if self._lock_detail is None: - return None + attributes = {ATTR_BATTERY_LEVEL: self._detail.battery_level} + + if self._detail.keypad is not None: + attributes["keypad_battery_level"] = self._detail.keypad.battery_level + + return attributes + + async def async_added_to_hass(self): + """Restore ATTR_CHANGED_BY on startup since it is likely no longer in the activity log.""" + await super().async_added_to_hass() + + last_state = await self.async_get_last_state() + if not last_state: + return - return { - ATTR_BATTERY_LEVEL: self._lock_detail.battery_level, - } + if ATTR_CHANGED_BY in last_state.attributes: + self._changed_by = last_state.attributes[ATTR_CHANGED_BY] @property def unique_id(self) -> str: """Get the unique id of the lock.""" - return '{:s}_lock'.format(self._lock.device_id) + return f"{self._device_id:s}_lock" diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index e41491c4b0ac8..c2c383468f6d4 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -1,10 +1,9 @@ { "domain": "august", "name": "August", - "documentation": "https://www.home-assistant.io/components/august", - "requirements": [ - "py-august==0.7.0" - ], + "documentation": "https://www.home-assistant.io/integrations/august", + "requirements": ["py-august==0.25.0"], "dependencies": ["configurator"], - "codeowners": [] + "codeowners": ["@bdraco"], + "config_flow": true } diff --git a/homeassistant/components/august/sensor.py b/homeassistant/components/august/sensor.py new file mode 100644 index 0000000000000..3276f8b073b3f --- /dev/null +++ b/homeassistant/components/august/sensor.py @@ -0,0 +1,273 @@ +"""Support for August sensors.""" +import logging + +from august.activity import ActivityType + +from homeassistant.components.sensor import DEVICE_CLASS_BATTERY +from homeassistant.const import ATTR_ENTITY_PICTURE, UNIT_PERCENTAGE +from homeassistant.core import callback +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_registry import async_get_registry +from homeassistant.helpers.restore_state import RestoreEntity + +from .const import ( + ATTR_OPERATION_AUTORELOCK, + ATTR_OPERATION_KEYPAD, + ATTR_OPERATION_METHOD, + ATTR_OPERATION_REMOTE, + DATA_AUGUST, + DOMAIN, + OPERATION_METHOD_AUTORELOCK, + OPERATION_METHOD_KEYPAD, + OPERATION_METHOD_MOBILE_DEVICE, + OPERATION_METHOD_REMOTE, +) +from .entity import AugustEntityMixin + +_LOGGER = logging.getLogger(__name__) + + +def _retrieve_device_battery_state(detail): + """Get the latest state of the sensor.""" + return detail.battery_level + + +def _retrieve_linked_keypad_battery_state(detail): + """Get the latest state of the sensor.""" + return detail.battery_percentage + + +SENSOR_TYPES_BATTERY = { + "device_battery": {"state_provider": _retrieve_device_battery_state}, + "linked_keypad_battery": {"state_provider": _retrieve_linked_keypad_battery_state}, +} + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the August sensors.""" + data = hass.data[DOMAIN][config_entry.entry_id][DATA_AUGUST] + devices = [] + migrate_unique_id_devices = [] + operation_sensors = [] + batteries = { + "device_battery": [], + "linked_keypad_battery": [], + } + for device in data.doorbells: + batteries["device_battery"].append(device) + for device in data.locks: + batteries["device_battery"].append(device) + batteries["linked_keypad_battery"].append(device) + operation_sensors.append(device) + + for device in batteries["device_battery"]: + state_provider = SENSOR_TYPES_BATTERY["device_battery"]["state_provider"] + detail = data.get_device_detail(device.device_id) + if detail is None or state_provider(detail) is None: + _LOGGER.debug( + "Not adding battery sensor for %s because it is not present", + device.device_name, + ) + continue + _LOGGER.debug( + "Adding battery sensor for %s", device.device_name, + ) + devices.append(AugustBatterySensor(data, "device_battery", device, device)) + + for device in batteries["linked_keypad_battery"]: + detail = data.get_device_detail(device.device_id) + + if detail.keypad is None: + _LOGGER.debug( + "Not adding keypad battery sensor for %s because it is not present", + device.device_name, + ) + continue + _LOGGER.debug( + "Adding keypad battery sensor for %s", device.device_name, + ) + keypad_battery_sensor = AugustBatterySensor( + data, "linked_keypad_battery", detail.keypad, device + ) + devices.append(keypad_battery_sensor) + migrate_unique_id_devices.append(keypad_battery_sensor) + + for device in operation_sensors: + devices.append(AugustOperatorSensor(data, device)) + + await _async_migrate_old_unique_ids(hass, migrate_unique_id_devices) + + async_add_entities(devices, True) + + +async def _async_migrate_old_unique_ids(hass, devices): + """Keypads now have their own serial number.""" + registry = await async_get_registry(hass) + for device in devices: + old_entity_id = registry.async_get_entity_id( + "sensor", DOMAIN, device.old_unique_id + ) + if old_entity_id is not None: + _LOGGER.debug( + "Migrating unique_id from [%s] to [%s]", + device.old_unique_id, + device.unique_id, + ) + registry.async_update_entity(old_entity_id, new_unique_id=device.unique_id) + + +class AugustOperatorSensor(AugustEntityMixin, RestoreEntity, Entity): + """Representation of an August lock operation sensor.""" + + def __init__(self, data, device): + """Initialize the sensor.""" + super().__init__(data, device) + self._data = data + self._device = device + self._state = None + self._operated_remote = None + self._operated_keypad = None + self._operated_autorelock = None + self._operated_time = None + self._available = False + self._entity_picture = None + self._update_from_data() + + @property + def available(self): + """Return the availability of this sensor.""" + return self._available + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def name(self): + """Return the name of the sensor.""" + return f"{self._device.device_name} Operator" + + @callback + def _update_from_data(self): + """Get the latest state of the sensor and update activity.""" + lock_activity = self._data.activity_stream.get_latest_device_activity( + self._device_id, [ActivityType.LOCK_OPERATION] + ) + + if lock_activity is not None: + self._available = True + self._state = lock_activity.operated_by + self._operated_remote = lock_activity.operated_remote + self._operated_keypad = lock_activity.operated_keypad + self._operated_autorelock = lock_activity.operated_autorelock + self._entity_picture = lock_activity.operator_thumbnail_url + + @property + def device_state_attributes(self): + """Return the device specific state attributes.""" + attributes = {} + + if self._operated_remote is not None: + attributes[ATTR_OPERATION_REMOTE] = self._operated_remote + if self._operated_keypad is not None: + attributes[ATTR_OPERATION_KEYPAD] = self._operated_keypad + if self._operated_autorelock is not None: + attributes[ATTR_OPERATION_AUTORELOCK] = self._operated_autorelock + + if self._operated_remote: + attributes[ATTR_OPERATION_METHOD] = OPERATION_METHOD_REMOTE + elif self._operated_keypad: + attributes[ATTR_OPERATION_METHOD] = OPERATION_METHOD_KEYPAD + elif self._operated_autorelock: + attributes[ATTR_OPERATION_METHOD] = OPERATION_METHOD_AUTORELOCK + else: + attributes[ATTR_OPERATION_METHOD] = OPERATION_METHOD_MOBILE_DEVICE + + return attributes + + async def async_added_to_hass(self): + """Restore ATTR_CHANGED_BY on startup since it is likely no longer in the activity log.""" + await super().async_added_to_hass() + + last_state = await self.async_get_last_state() + if not last_state: + return + + self._state = last_state.state + if ATTR_ENTITY_PICTURE in last_state.attributes: + self._entity_picture = last_state.attributes[ATTR_ENTITY_PICTURE] + if ATTR_OPERATION_REMOTE in last_state.attributes: + self._operated_remote = last_state.attributes[ATTR_OPERATION_REMOTE] + if ATTR_OPERATION_KEYPAD in last_state.attributes: + self._operated_keypad = last_state.attributes[ATTR_OPERATION_KEYPAD] + if ATTR_OPERATION_AUTORELOCK in last_state.attributes: + self._operated_autorelock = last_state.attributes[ATTR_OPERATION_AUTORELOCK] + + @property + def entity_picture(self): + """Return the entity picture to use in the frontend, if any.""" + return self._entity_picture + + @property + def unique_id(self) -> str: + """Get the unique id of the device sensor.""" + return f"{self._device_id}_lock_operator" + + +class AugustBatterySensor(AugustEntityMixin, Entity): + """Representation of an August sensor.""" + + def __init__(self, data, sensor_type, device, old_device): + """Initialize the sensor.""" + super().__init__(data, device) + self._data = data + self._sensor_type = sensor_type + self._device = device + self._old_device = old_device + self._state = None + self._available = False + self._update_from_data() + + @property + def available(self): + """Return the availability of this sensor.""" + return self._available + + @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 UNIT_PERCENTAGE + + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + return DEVICE_CLASS_BATTERY + + @property + def name(self): + """Return the name of the sensor.""" + device_name = self._device.device_name + return f"{device_name} Battery" + + @callback + def _update_from_data(self): + """Get the latest state of the sensor.""" + state_provider = SENSOR_TYPES_BATTERY[self._sensor_type]["state_provider"] + self._state = state_provider(self._detail) + self._available = self._state is not None + + @property + def unique_id(self) -> str: + """Get the unique id of the device sensor.""" + return f"{self._device_id}_{self._sensor_type}" + + @property + def old_unique_id(self) -> str: + """Get the old unique id of the device sensor.""" + return f"{self._old_device.device_id}_{self._sensor_type}" diff --git a/homeassistant/components/august/strings.json b/homeassistant/components/august/strings.json new file mode 100644 index 0000000000000..bffca81ab3378 --- /dev/null +++ b/homeassistant/components/august/strings.json @@ -0,0 +1,27 @@ +{ + "config": { + "error": { + "unknown": "Unexpected error", + "cannot_connect": "Failed to connect, please try again", + "invalid_auth": "Invalid authentication" + }, + "abort": { "already_configured": "Account is already configured" }, + "step": { + "validation": { + "title": "Two factor authentication", + "data": { "code": "Verification code" }, + "description": "Please check your {login_method} ({username}) and enter the verification code below" + }, + "user": { + "description": "If the Login Method is 'email', Username is the email address. If the Login Method is 'phone', Username is the phone number in the format '+NNNNNNNNN'.", + "data": { + "timeout": "Timeout (seconds)", + "password": "Password", + "username": "Username", + "login_method": "Login Method" + }, + "title": "Setup an August account" + } + } + } +} diff --git a/homeassistant/components/august/subscriber.py b/homeassistant/components/august/subscriber.py new file mode 100644 index 0000000000000..81538fa011eb7 --- /dev/null +++ b/homeassistant/components/august/subscriber.py @@ -0,0 +1,45 @@ +"""Base class for August entity.""" + + +from homeassistant.core import callback +from homeassistant.helpers.event import async_track_time_interval + + +class AugustSubscriberMixin: + """Base implementation for a subscriber.""" + + def __init__(self, hass, update_interval): + """Initialize an subscriber.""" + super().__init__() + self._hass = hass + self._update_interval = update_interval + self._subscriptions = {} + self._unsub_interval = None + + @callback + def async_subscribe_device_id(self, device_id, update_callback): + """Add an callback subscriber.""" + if not self._subscriptions: + self._unsub_interval = async_track_time_interval( + self._hass, self._async_refresh, self._update_interval + ) + self._subscriptions.setdefault(device_id, []).append(update_callback) + + @callback + def async_unsubscribe_device_id(self, device_id, update_callback): + """Remove a callback subscriber.""" + self._subscriptions[device_id].remove(update_callback) + if not self._subscriptions[device_id]: + del self._subscriptions[device_id] + if not self._subscriptions: + self._unsub_interval() + self._unsub_interval = None + + @callback + def async_signal_device_id_update(self, device_id): + """Call the callbacks for a device_id.""" + if not self._subscriptions.get(device_id): + return + + for update_callback in self._subscriptions[device_id]: + update_callback() diff --git a/homeassistant/components/august/translations/ca.json b/homeassistant/components/august/translations/ca.json new file mode 100644 index 0000000000000..4f8f9cebe63ec --- /dev/null +++ b/homeassistant/components/august/translations/ca.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "El compte ja ha estat configurat" + }, + "error": { + "cannot_connect": "No s'ha pogut connectar, torna-ho a provar", + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", + "unknown": "Error inesperat" + }, + "step": { + "user": { + "data": { + "login_method": "M\u00e8tode d'inici de sessi\u00f3", + "password": "Contrasenya", + "timeout": "Temps d'espera (segons)", + "username": "Nom d'usuari" + }, + "description": "Si el m\u00e8tode d'inici de sessi\u00f3 \u00e9s 'email', el nom d'usuari \u00e9s l'adre\u00e7a de correu electr\u00f2nic. Si el m\u00e8tode d'inici de sessi\u00f3 \u00e9s 'phone', el nom d'usuari \u00e9s el n\u00famero de tel\u00e8fon en el format \"+NNNNNNNNN\".", + "title": "Configuraci\u00f3 de compte August" + }, + "validation": { + "data": { + "code": "Codi de verificaci\u00f3" + }, + "description": "Comprova el teu {login_method} ({username}) i introdueix el codi de verificaci\u00f3 a continuaci\u00f3", + "title": "Autenticaci\u00f3 de dos factors" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/august/translations/da.json b/homeassistant/components/august/translations/da.json new file mode 100644 index 0000000000000..e022fac379069 --- /dev/null +++ b/homeassistant/components/august/translations/da.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "Kontoen er allerede konfigureret" + }, + "error": { + "cannot_connect": "Kunne ikke oprette forbindelse. Pr\u00f8v igen", + "invalid_auth": "Ugyldig godkendelse", + "unknown": "Uventet fejl" + }, + "step": { + "user": { + "data": { + "login_method": "Loginmetode", + "password": "Adgangskode", + "timeout": "Timeout (sekunder)", + "username": "Brugernavn" + }, + "description": "Hvis loginmetoden er 'e-mail', er brugernavn e-mailadressen. Hvis loginmetoden er 'telefon', er brugernavn telefonnummeret i formatet '+NNNNNNNNNN'.", + "title": "Konfigurer en August-konto" + }, + "validation": { + "data": { + "code": "Bekr\u00e6ftelseskode" + }, + "description": "Kontroller dit {login_method} ({username}), og angiv bekr\u00e6ftelseskoden nedenfor", + "title": "Tofaktorgodkendelse" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/august/translations/de.json b/homeassistant/components/august/translations/de.json new file mode 100644 index 0000000000000..d46be650e2c5f --- /dev/null +++ b/homeassistant/components/august/translations/de.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "Konto ist bereits konfiguriert" + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen, versuchen Sie es erneut", + "invalid_auth": "Ung\u00fcltige Authentifizierung", + "unknown": "Unerwarteter Fehler" + }, + "step": { + "user": { + "data": { + "login_method": "Anmeldemethode", + "password": "Passwort", + "timeout": "Zeit\u00fcberschreitung (Sekunden)", + "username": "Benutzername" + }, + "description": "Wenn die Anmeldemethode \"E-Mail\" lautet, ist Benutzername die E-Mail-Adresse. Wenn die Anmeldemethode \"Telefon\" ist, ist Benutzername die Telefonnummer im Format \"+ NNNNNNNNN\".", + "title": "Richten Sie ein August-Konto ein" + }, + "validation": { + "data": { + "code": "Verifizierungs-Code" + }, + "description": "Bitte \u00fcberpr\u00fcfen Sie Ihre {login_method} ({username}) und geben Sie den Best\u00e4tigungscode ein", + "title": "Zwei-Faktor-Authentifizierung" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/august/translations/en.json b/homeassistant/components/august/translations/en.json new file mode 100644 index 0000000000000..b8bf1b1bc03c7 --- /dev/null +++ b/homeassistant/components/august/translations/en.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "Account is already configured" + }, + "error": { + "cannot_connect": "Failed to connect, please try again", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "login_method": "Login Method", + "password": "Password", + "timeout": "Timeout (seconds)", + "username": "Username" + }, + "description": "If the Login Method is 'email', Username is the email address. If the Login Method is 'phone', Username is the phone number in the format '+NNNNNNNNN'.", + "title": "Setup an August account" + }, + "validation": { + "data": { + "code": "Verification code" + }, + "description": "Please check your {login_method} ({username}) and enter the verification code below", + "title": "Two factor authentication" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/august/translations/es-419.json b/homeassistant/components/august/translations/es-419.json new file mode 100644 index 0000000000000..914aea1b80194 --- /dev/null +++ b/homeassistant/components/august/translations/es-419.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "La cuenta ya est\u00e1 configurada" + }, + "error": { + "cannot_connect": "No se pudo conectar, intente nuevamente", + "invalid_auth": "Autenticaci\u00f3n inv\u00e1lida", + "unknown": "Error inesperado" + }, + "step": { + "user": { + "data": { + "login_method": "M\u00e9todo de inicio de sesi\u00f3n", + "password": "Contrase\u00f1a", + "timeout": "Tiempo de espera (segundos)", + "username": "Nombre de usuario" + }, + "description": "Si el M\u00e9todo de inicio de sesi\u00f3n es 'correo electr\u00f3nico', Nombre de usuario es la direcci\u00f3n de correo electr\u00f3nico. Si el M\u00e9todo de inicio de sesi\u00f3n es 'tel\u00e9fono', Nombre de usuario es el n\u00famero de tel\u00e9fono en el formato '+NNNNNNNNN'.", + "title": "Configurar una cuenta de August" + }, + "validation": { + "data": { + "code": "C\u00f3digo de verificaci\u00f3n" + }, + "description": "Verifique su {login_method} ( {username} ) e ingrese el c\u00f3digo de verificaci\u00f3n a continuaci\u00f3n", + "title": "Autenticaci\u00f3n de dos factores" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/august/translations/es.json b/homeassistant/components/august/translations/es.json new file mode 100644 index 0000000000000..28d9743c073a8 --- /dev/null +++ b/homeassistant/components/august/translations/es.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "La cuenta ya est\u00e1 configurada" + }, + "error": { + "cannot_connect": "No se ha podido conectar, por favor, int\u00e9ntalo de nuevo.", + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", + "unknown": "Error inesperado" + }, + "step": { + "user": { + "data": { + "login_method": "M\u00e9todo de inicio de sesi\u00f3n", + "password": "Contrase\u00f1a", + "timeout": "Tiempo de espera (segundos)", + "username": "Usuario" + }, + "description": "Si el M\u00e9todo de Inicio de Sesi\u00f3n es 'correo electr\u00f3nico', Usuario es la direcci\u00f3n de correo electr\u00f3nico. Si el M\u00e9todo de Inicio de Sesi\u00f3n es 'tel\u00e9fono', Usuario es el n\u00famero de tel\u00e9fono en formato '+NNNNNNNNN'.", + "title": "Configurar una cuenta de August" + }, + "validation": { + "data": { + "code": "C\u00f3digo de verificaci\u00f3n" + }, + "description": "Por favor, comprueba tu {login_method} ({username}) e introduce el c\u00f3digo de verificaci\u00f3n a continuaci\u00f3n", + "title": "Autenticaci\u00f3n de dos factores" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/august/translations/fr.json b/homeassistant/components/august/translations/fr.json new file mode 100644 index 0000000000000..da2df2461a1bc --- /dev/null +++ b/homeassistant/components/august/translations/fr.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9" + }, + "error": { + "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer", + "invalid_auth": "Authentification non valide", + "unknown": "Erreur inattendue" + }, + "step": { + "user": { + "data": { + "login_method": "M\u00e9thode de connexion", + "password": "Mot de passe", + "timeout": "D\u00e9lai d'expiration (secondes)", + "username": "Nom d'utilisateur" + }, + "title": "Configurer un compte August" + }, + "validation": { + "data": { + "code": "Code de v\u00e9rification" + }, + "title": "Authentification \u00e0 deux facteurs" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/august/translations/it.json b/homeassistant/components/august/translations/it.json new file mode 100644 index 0000000000000..3a5f2676acdb3 --- /dev/null +++ b/homeassistant/components/august/translations/it.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "L'account \u00e8 gi\u00e0 configurato" + }, + "error": { + "cannot_connect": "Impossibile connettersi, si prega di riprovare.", + "invalid_auth": "Autenticazione non valida", + "unknown": "Errore imprevisto" + }, + "step": { + "user": { + "data": { + "login_method": "Metodo di accesso", + "password": "Password", + "timeout": "Timeout (in secondi)", + "username": "Nome utente" + }, + "description": "Se il metodo di accesso \u00e8 \"e-mail\", il nome utente \u00e8 l'indirizzo e-mail. Se il metodo di accesso \u00e8 \"telefono\", il nome utente \u00e8 il numero di telefono nel formato \"+NNNNNNNNN\".", + "title": "Configura un account di August" + }, + "validation": { + "data": { + "code": "Codice di verifica" + }, + "description": "Controlla il tuo {login_method} ({username}) e inserisci il codice di verifica seguente", + "title": "Autenticazione a due fattori" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/august/translations/ko.json b/homeassistant/components/august/translations/ko.json new file mode 100644 index 0000000000000..dce916bb7887f --- /dev/null +++ b/homeassistant/components/august/translations/ko.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "\uacc4\uc815\uc774 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + }, + "error": { + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.", + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" + }, + "step": { + "user": { + "data": { + "login_method": "\ub85c\uadf8\uc778 \ubc29\ubc95", + "password": "\ube44\ubc00\ubc88\ud638", + "timeout": "\uc81c\ud55c \uc2dc\uac04 (\ucd08)", + "username": "\uc0ac\uc6a9\uc790 \uc774\ub984" + }, + "description": "\ub85c\uadf8\uc778 \ubc29\ubc95\uc774 '\uc774\uba54\uc77c'\uc778 \uacbd\uc6b0, \uc0ac\uc6a9\uc790 \uc774\ub984\uc740 \uc774\uba54\uc77c \uc8fc\uc18c\uc785\ub2c8\ub2e4. \ub85c\uadf8\uc778 \ubc29\ubc95\uc774 'phone'\uc778 \uacbd\uc6b0, \uc0ac\uc6a9\uc790 \uc774\ub984\uc740 '+NNNNNNNNN' \ud615\uc2dd\uc758 \uc804\ud654\ubc88\ud638\uc785\ub2c8\ub2e4.", + "title": "August \uacc4\uc815 \uc124\uc815\ud558\uae30" + }, + "validation": { + "data": { + "code": "\uc778\uc99d \ucf54\ub4dc" + }, + "description": "{login_method} ({username}) \uc744(\ub97c) \ud655\uc778\ud558\uace0 \uc544\ub798\uc5d0 \uc778\uc99d \ucf54\ub4dc\ub97c \uc785\ub825\ud574\uc8fc\uc138\uc694", + "title": "2\ub2e8\uacc4 \uc778\uc99d" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/august/translations/lb.json b/homeassistant/components/august/translations/lb.json new file mode 100644 index 0000000000000..501af05c2dff7 --- /dev/null +++ b/homeassistant/components/august/translations/lb.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "Kont ass scho konfigur\u00e9iert" + }, + "error": { + "cannot_connect": "Feeler beim verbannen, prob\u00e9iert w.e.g. nach emol.", + "invalid_auth": "Ong\u00eblteg Authentifikatioun", + "unknown": "Onerwaarte Feeler" + }, + "step": { + "user": { + "data": { + "login_method": "Login Method", + "password": "Passwuert", + "timeout": "Z\u00e4itiwwerscheidung (sekonnen)", + "username": "Benotzernumm" + }, + "description": "Wann d'Login Method 'E-Mail' ass, dannn ass de Benotzernumm d'E-Mail Adress. Wann d'Login-Method 'Telefon' ass, ass den Benotzernumm d'Telefonsnummer am Format '+ NNNNNNNNN'.", + "title": "August Kont ariichten" + }, + "validation": { + "data": { + "code": "Verifikatiouns Code" + }, + "description": "Pr\u00e9ift w.e.g. \u00c4re {login_method} ({username}) a gitt de Verifikatiounscode hei dr\u00ebnner an", + "title": "2-Faktor-Authentifikatioun" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/august/translations/lv.json b/homeassistant/components/august/translations/lv.json new file mode 100644 index 0000000000000..b2afeaf08745b --- /dev/null +++ b/homeassistant/components/august/translations/lv.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "login_method": "Pieteik\u0161an\u0101s metode", + "password": "Parole" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/august/translations/nl.json b/homeassistant/components/august/translations/nl.json new file mode 100644 index 0000000000000..1697f634d9a82 --- /dev/null +++ b/homeassistant/components/august/translations/nl.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "Account al geconfigureerd" + }, + "error": { + "cannot_connect": "Verbinding mislukt, probeer het opnieuw", + "invalid_auth": "Ongeldige authenticatie", + "unknown": "Onverwachte fout" + }, + "step": { + "user": { + "data": { + "login_method": "Aanmeldmethode", + "password": "Wachtwoord", + "timeout": "Time-out (seconden)", + "username": "Gebruikersnaam" + }, + "description": "Als de aanmeldingsmethode 'e-mail' is, is gebruikersnaam het e-mailadres. Als de aanmeldingsmethode 'telefoon' is, is gebruikersnaam het telefoonnummer in de indeling '+ NNNNNNNNN'.", + "title": "Stel een augustus-account in" + }, + "validation": { + "data": { + "code": "Verificatiecode" + }, + "description": "Controleer je {login_method} ( {username} ) en voer de onderstaande verificatiecode in", + "title": "Tweestapsverificatie" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/august/translations/no.json b/homeassistant/components/august/translations/no.json new file mode 100644 index 0000000000000..2ba841ea139c0 --- /dev/null +++ b/homeassistant/components/august/translations/no.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "Kontoen er allerede konfigurert" + }, + "error": { + "cannot_connect": "Klarte ikke \u00e5 koble til, vennligst pr\u00f8v igjen", + "invalid_auth": "Ugyldig godkjenning", + "unknown": "Uventet feil" + }, + "step": { + "user": { + "data": { + "login_method": "P\u00e5loggingsmetode", + "password": "Passord", + "timeout": "Tidsavbrudd (sekunder)", + "username": "Brukernavn" + }, + "description": "Hvis p\u00e5loggingsmetoden er 'e-post', er brukernavnet e-postadressen. Hvis p\u00e5loggingsmetoden er 'telefon', er brukernavn telefonnummeret i formatet '+ NNNNNNNNN'.", + "title": "Sett opp en August konto" + }, + "validation": { + "data": { + "code": "Bekreftelseskode" + }, + "description": "Kontroller {login_method} ({username}) og skriv inn bekreftelseskoden nedenfor", + "title": "To-faktor autentisering" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/august/translations/pl.json b/homeassistant/components/august/translations/pl.json new file mode 100644 index 0000000000000..2798af40779a6 --- /dev/null +++ b/homeassistant/components/august/translations/pl.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "Konto jest ju\u017c skonfigurowane." + }, + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia, spr\u00f3buj ponownie.", + "invalid_auth": "Niepoprawne uwierzytelnienie.", + "unknown": "Niespodziewany b\u0142\u0105d." + }, + "step": { + "user": { + "data": { + "login_method": "Metoda logowania", + "password": "Has\u0142o", + "timeout": "Limit czasu (sekundy)", + "username": "Nazwa u\u017cytkownika" + }, + "description": "Je\u015bli metod\u0105 logowania jest 'e-mail', nazw\u0105 u\u017cytkownika b\u0119dzie adres e-mail. Je\u015bli metod\u0105 logowania jest 'telefon', nazw\u0105 u\u017cytkownika b\u0119dzie numer telefonu w formacie '+NNNNNNNNN'.", + "title": "Konfiguracja konta August" + }, + "validation": { + "data": { + "code": "Kod weryfikacyjny" + }, + "description": "Sprawd\u017a {login_method} ({username}) i wprowad\u017a kod weryfikacyjny poni\u017cej", + "title": "Uwierzytelnianie dwusk\u0142adnikowe" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/august/translations/pt.json b/homeassistant/components/august/translations/pt.json new file mode 100644 index 0000000000000..b46423599731a --- /dev/null +++ b/homeassistant/components/august/translations/pt.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "Palavra-passe", + "username": "Nome de Utilizador" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/august/translations/ru.json b/homeassistant/components/august/translations/ru.json new file mode 100644 index 0000000000000..5cc039b8e9e3d --- /dev/null +++ b/homeassistant/components/august/translations/ru.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "\u0423\u0447\u0451\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0430." + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f, \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0435\u0449\u0435 \u0440\u0430\u0437.", + "invalid_auth": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "step": { + "user": { + "data": { + "login_method": "\u0421\u043f\u043e\u0441\u043e\u0431 \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u0438", + "password": "\u041f\u0430\u0440\u043e\u043b\u044c", + "timeout": "\u0422\u0430\u0439\u043c-\u0430\u0443\u0442 (\u0432 \u0441\u0435\u043a\u0443\u043d\u0434\u0430\u0445)", + "username": "\u041b\u043e\u0433\u0438\u043d" + }, + "description": "\u0415\u0441\u043b\u0438 \u0432 \u043a\u0430\u0447\u0435\u0441\u0442\u0432\u0435 \u0441\u043f\u043e\u0441\u043e\u0431\u0430 \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u0438 \u0432\u044b\u0431\u0440\u0430\u043d\u043e 'email', \u0442\u043e \u043b\u043e\u0433\u0438\u043d\u043e\u043c \u044f\u0432\u043b\u044f\u0435\u0442\u0441\u044f \u0430\u0434\u0440\u0435\u0441 \u044d\u043b\u0435\u043a\u0442\u0440\u043e\u043d\u043d\u043e\u0439 \u043f\u043e\u0447\u0442\u044b. \u0415\u0441\u043b\u0438 \u0432 \u043a\u0430\u0447\u0435\u0441\u0442\u0432\u0435 \u0441\u043f\u043e\u0441\u043e\u0431\u0430 \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u0438 \u0432\u044b\u0431\u0440\u0430\u043d\u043e 'phone', \u0442\u043e \u043b\u043e\u0433\u0438\u043d\u043e\u043c \u044f\u0432\u043b\u044f\u0435\u0442\u0441\u044f \u043d\u043e\u043c\u0435\u0440 \u0442\u0435\u043b\u0435\u0444\u043e\u043d\u0430 \u0432 \u0444\u043e\u0440\u043c\u0430\u0442\u0435 '+NNNNNNNNN'.", + "title": "August" + }, + "validation": { + "data": { + "code": "\u041a\u043e\u0434 \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d\u0438\u044f" + }, + "description": "\u041f\u0440\u043e\u0432\u0435\u0440\u044c\u0442\u0435 {login_method} ({username}) \u0438 \u0432\u0432\u0435\u0434\u0438\u0442\u0435 \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u043d\u044b\u0439 \u043f\u0440\u043e\u0432\u0435\u0440\u043e\u0447\u043d\u044b\u0439 \u043a\u043e\u0434.", + "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" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/august/translations/sl.json b/homeassistant/components/august/translations/sl.json new file mode 100644 index 0000000000000..5d78dac5ef12b --- /dev/null +++ b/homeassistant/components/august/translations/sl.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "Ra\u010dun je \u017ee nastavljen" + }, + "error": { + "cannot_connect": "Povezava ni uspela, poskusite znova", + "invalid_auth": "Neveljavna avtentikacija", + "unknown": "Nepri\u010dakovana napaka" + }, + "step": { + "user": { + "data": { + "login_method": "Na\u010din prijave", + "password": "Geslo", + "timeout": "\u010casovna omejitev (sekunde)", + "username": "Uporabni\u0161ko ime" + }, + "description": "\u010ce je metoda za prijavo 'e-po\u0161ta', je e-po\u0161tni naslov uporabni\u0161ko ime. V kolikor je na\u010din prijave \"telefon\", je uporabni\u0161ko ime telefonska \u0161tevilka v obliki \" +NNNNNNNNN\".", + "title": "Nastavite ra\u010dun August" + }, + "validation": { + "data": { + "code": "Koda za preverjanje" + }, + "description": "Preverite svoj {login_method} ({username}) in spodaj vnesite verifikacijsko kodo", + "title": "Dvofaktorska avtentikacija" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/august/translations/sv.json b/homeassistant/components/august/translations/sv.json new file mode 100644 index 0000000000000..df72f5daaf33f --- /dev/null +++ b/homeassistant/components/august/translations/sv.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "Kontot har redan konfigurerats" + }, + "error": { + "cannot_connect": "Det gick inte att ansluta, f\u00f6rs\u00f6k igen", + "invalid_auth": "Ogiltig autentisering", + "unknown": "Ov\u00e4ntat fel" + }, + "step": { + "user": { + "data": { + "login_method": "Inloggningsmetod", + "password": "L\u00f6senord", + "username": "Anv\u00e4ndarnamn" + } + }, + "validation": { + "title": "Tv\u00e5faktorsautentisering" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/august/translations/zh-Hant.json b/homeassistant/components/august/translations/zh-Hant.json new file mode 100644 index 0000000000000..6b7e206d4c4f8 --- /dev/null +++ b/homeassistant/components/august/translations/zh-Hant.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "\u5e33\u865f\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557\uff0c\u8acb\u518d\u8a66\u4e00\u6b21", + "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "step": { + "user": { + "data": { + "login_method": "\u767b\u5165\u65b9\u5f0f", + "password": "\u5bc6\u78bc", + "timeout": "\u903e\u6642\uff08\u79d2\uff09", + "username": "\u4f7f\u7528\u8005\u540d\u7a31" + }, + "description": "\u5047\u5982\u767b\u5165\u65b9\u5f0f\u70ba\u90f5\u4ef6\u300cemail\u300d\u3001\u4f7f\u7528\u8005\u540d\u7a31\u70ba\u96fb\u5b50\u90f5\u4ef6\u4f4d\u5740\u3002\u5047\u5982\u767b\u5165\u65b9\u5f0f\u70ba\u96fb\u8a71\u300cphone\u300d\u3001\u5247\u4f7f\u7528\u8005\u540d\u7a31\u70ba\u5305\u542b\u570b\u78bc\u4e4b\u96fb\u8a71\u865f\u78bc\uff0c\u5982\u300c+NNNNNNNNN\u300d\u3002", + "title": "\u8a2d\u5b9a August \u5e33\u865f" + }, + "validation": { + "data": { + "code": "\u9a57\u8b49\u78bc" + }, + "description": "\u8acb\u78ba\u8a8d {login_method} ({username}) \u4e26\u65bc\u4e0b\u65b9\u8f38\u5165\u9a57\u8b49\u78bc", + "title": "\u5169\u6b65\u9a5f\u9a57\u8b49" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aurora/binary_sensor.py b/homeassistant/components/aurora/binary_sensor.py index 58546382a5025..1d5a6e83ec197 100644 --- a/homeassistant/components/aurora/binary_sensor.py +++ b/homeassistant/components/aurora/binary_sensor.py @@ -1,25 +1,24 @@ """Support for aurora forecast data sensor.""" from datetime import timedelta import logging +from math import floor 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 +from homeassistant.components.binary_sensor import PLATFORM_SCHEMA, BinarySensorEntity +from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME 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' +ATTRIBUTION = "Data provided by the National Oceanic and Atmospheric Administration" +CONF_THRESHOLD = "forecast_threshold" -DEFAULT_DEVICE_CLASS = 'visible' -DEFAULT_NAME = 'Aurora Visibility' +DEFAULT_DEVICE_CLASS = "visible" +DEFAULT_NAME = "Aurora Visibility" DEFAULT_THRESHOLD = 75 HA_USER_AGENT = "Home Assistant Aurora Tracker v.0.1.0" @@ -28,10 +27,12 @@ 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, -}) +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): @@ -40,22 +41,20 @@ def setup_platform(hass, config, add_entities, discovery_info=None): _LOGGER.error("Lat. or long. not set in Home Assistant config") return False - name = config.get(CONF_NAME) - threshold = config.get(CONF_THRESHOLD) + name = config[CONF_NAME] + threshold = config[CONF_THRESHOLD] try: - aurora_data = AuroraData( - hass.config.latitude, hass.config.longitude, threshold) + 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) + _LOGGER.error("Connection to aurora forecast service failed: %s", error) return False add_entities([AuroraSensor(aurora_data, name)], True) -class AuroraSensor(BinarySensorDevice): +class AuroraSensor(BinarySensorEntity): """Implementation of an aurora sensor.""" def __init__(self, aurora_data, name): @@ -66,7 +65,7 @@ def __init__(self, aurora_data, name): @property def name(self): """Return the name of the sensor.""" - return '{}'.format(self._name) + return f"{self._name}" @property def is_on(self): @@ -84,8 +83,8 @@ def device_state_attributes(self): attrs = {} if self.aurora_data: - attrs['visibility_level'] = self.aurora_data.visibility_level - attrs['message'] = self.aurora_data.is_visible_text + attrs["visibility_level"] = self.aurora_data.visibility_level + attrs["message"] = self.aurora_data.is_visible_text attrs[ATTR_ATTRIBUTION] = ATTRIBUTION return attrs @@ -101,8 +100,6 @@ 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 @@ -122,23 +119,28 @@ def update(self): self.is_visible_text = "nothing's out" except requests.exceptions.HTTPError as error: - _LOGGER.error( - "Connection to aurora forecast service failed: %s", 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 + # We discard comment rows (#) + # We split the raw text by line (\n) + # For each line we trim leading spaces and split by spaces forecast_table = [ - row.strip(" ").split(" ") + 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) + # Assumes self.latitude belongs to [-90;90[ (South to North) + # Assumes self.longitude belongs to [-180;180[ (West to East) + # No assumptions made regarding the number of rows and columns + converted_latitude = floor((self.latitude + 90) * len(forecast_table) / 180) + converted_longitude = floor( + (self.longitude + 180) * len(forecast_table[converted_latitude]) / 360 + ) return forecast_table[converted_latitude][converted_longitude] diff --git a/homeassistant/components/aurora/manifest.json b/homeassistant/components/aurora/manifest.json index 56ba3fe935608..3e7a93596144d 100644 --- a/homeassistant/components/aurora/manifest.json +++ b/homeassistant/components/aurora/manifest.json @@ -1,8 +1,6 @@ { "domain": "aurora", "name": "Aurora", - "documentation": "https://www.home-assistant.io/components/aurora", - "requirements": [], - "dependencies": [], + "documentation": "https://www.home-assistant.io/integrations/aurora", "codeowners": [] } diff --git a/homeassistant/components/aurora_abb_powerone/__init__.py b/homeassistant/components/aurora_abb_powerone/__init__.py new file mode 100644 index 0000000000000..087172d1bb546 --- /dev/null +++ b/homeassistant/components/aurora_abb_powerone/__init__.py @@ -0,0 +1 @@ +"""The Aurora ABB Powerone PV inverter sensor integration.""" diff --git a/homeassistant/components/aurora_abb_powerone/manifest.json b/homeassistant/components/aurora_abb_powerone/manifest.json new file mode 100644 index 0000000000000..55d700c649629 --- /dev/null +++ b/homeassistant/components/aurora_abb_powerone/manifest.json @@ -0,0 +1,7 @@ +{ + "domain": "aurora_abb_powerone", + "name": "Aurora ABB Solar PV", + "documentation": "https://www.home-assistant.io/integrations/aurora_abb_powerone/", + "codeowners": ["@davet2001"], + "requirements": ["aurorapy==0.2.6"] +} diff --git a/homeassistant/components/aurora_abb_powerone/sensor.py b/homeassistant/components/aurora_abb_powerone/sensor.py new file mode 100644 index 0000000000000..69a513dd8fb5e --- /dev/null +++ b/homeassistant/components/aurora_abb_powerone/sensor.py @@ -0,0 +1,103 @@ +"""Support for Aurora ABB PowerOne Solar Photvoltaic (PV) inverter.""" + +import logging + +from aurorapy.client import AuroraError, AuroraSerialClient +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_ADDRESS, + CONF_DEVICE, + CONF_NAME, + DEVICE_CLASS_POWER, + POWER_WATT, +) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_ADDRESS = 2 +DEFAULT_NAME = "Solar PV" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_DEVICE): cv.string, + vol.Optional(CONF_ADDRESS, default=DEFAULT_ADDRESS): cv.positive_int, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Aurora ABB PowerOne device.""" + devices = [] + comport = config[CONF_DEVICE] + address = config[CONF_ADDRESS] + name = config[CONF_NAME] + + _LOGGER.debug("Intitialising com port=%s address=%s", comport, address) + client = AuroraSerialClient(address, comport, parity="N", timeout=1) + + devices.append(AuroraABBSolarPVMonitorSensor(client, name, "Power")) + add_entities(devices, True) + + +class AuroraABBSolarPVMonitorSensor(Entity): + """Representation of a Sensor.""" + + def __init__(self, client, name, typename): + """Initialize the sensor.""" + self._name = f"{name} {typename}" + self.client = client + 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 POWER_WATT + + @property + def device_class(self): + """Return the device class.""" + return DEVICE_CLASS_POWER + + def update(self): + """Fetch new state data for the sensor. + + This is the only method that should fetch new data for Home Assistant. + """ + try: + self.client.connect() + # read ADC channel 3 (grid power output) + power_watts = self.client.measure(3, True) + self._state = round(power_watts, 1) + # _LOGGER.debug("Got reading %fW" % self._state) + except AuroraError as error: + # aurorapy does not have different exceptions (yet) for dealing + # with timeout vs other comms errors. + # This means the (normal) situation of no response during darkness + # raises an exception. + # aurorapy (gitlab) pull request merged 29/5/2019. When >0.2.6 is + # released, this could be modified to : + # except AuroraTimeoutError as e: + # Workaround: look at the text of the exception + if "No response after" in str(error): + _LOGGER.debug("No response from inverter (could be dark)") + else: + raise error + self._state = None + finally: + if self.client.serline.isOpen(): + self.client.close() diff --git a/homeassistant/components/auth/.translations/bg.json b/homeassistant/components/auth/.translations/bg.json deleted file mode 100644 index 63cf17f0b2282..0000000000000 --- a/homeassistant/components/auth/.translations/bg.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "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/da.json b/homeassistant/components/auth/.translations/da.json deleted file mode 100644 index f461f376d166c..0000000000000 --- a/homeassistant/components/auth/.translations/da.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "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/es-419.json b/homeassistant/components/auth/.translations/es-419.json deleted file mode 100644 index 852965596e073..0000000000000 --- a/homeassistant/components/auth/.translations/es-419.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "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 deleted file mode 100644 index dd1d6f5437760..0000000000000 --- a/homeassistant/components/auth/.translations/es.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "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/it.json b/homeassistant/components/auth/.translations/it.json deleted file mode 100644 index be06f0209c409..0000000000000 --- a/homeassistant/components/auth/.translations/it.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "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 deleted file mode 100644 index 6c2e8988d83c5..0000000000000 --- a/homeassistant/components/auth/.translations/ko.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "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/nl.json b/homeassistant/components/auth/.translations/nl.json deleted file mode 100644 index 9ec8006507b82..0000000000000 --- a/homeassistant/components/auth/.translations/nl.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "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/pl.json b/homeassistant/components/auth/.translations/pl.json deleted file mode 100644 index f0e9f7b71ea44..0000000000000 --- a/homeassistant/components/auth/.translations/pl.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "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 deleted file mode 100644 index faf854153b08f..0000000000000 --- a/homeassistant/components/auth/.translations/pt-BR.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "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/zh-Hant.json b/homeassistant/components/auth/.translations/zh-Hant.json deleted file mode 100644 index b7a26f5079c6c..0000000000000 --- a/homeassistant/components/auth/.translations/zh-Hant.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "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 index f1deaf0cb856f..5b1ca46c41bd6 100644 --- a/homeassistant/components/auth/__init__.py +++ b/homeassistant/components/auth/__init__.py @@ -114,67 +114,71 @@ } """ +from datetime import timedelta 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.auth.models import ( + TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN, + Credentials, + User, +) 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.const import HTTP_BAD_REQUEST, HTTP_FORBIDDEN, HTTP_OK +from homeassistant.core import HomeAssistant, callback +from homeassistant.loader import bind_hass 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' +from . import indieauth, login_flow, 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__) @@ -195,28 +199,23 @@ async def async_setup(hass, config): 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 + 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 + SCHEMA_WS_LONG_LIVED_ACCESS_TOKEN, ) hass.components.websocket_api.async_register_command( - WS_TYPE_REFRESH_TOKENS, - websocket_refresh_tokens, - SCHEMA_WS_REFRESH_TOKENS + 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 + SCHEMA_WS_DELETE_REFRESH_TOKEN, ) hass.components.websocket_api.async_register_command( - WS_TYPE_SIGN_PATH, - websocket_sign_path, - SCHEMA_WS_SIGN_PATH + WS_TYPE_SIGN_PATH, websocket_sign_path, SCHEMA_WS_SIGN_PATH ) await login_flow.async_setup(hass, store_result) @@ -228,8 +227,8 @@ async def async_setup(hass, config): class TokenView(HomeAssistantView): """View to issue or revoke tokens.""" - url = '/auth/token' - name = 'api:auth:token' + url = "/auth/token" + name = "api:auth:token" requires_auth = False cors_allowed = True @@ -240,29 +239,31 @@ def __init__(self, retrieve_user): @log_invalid_auth async def post(self, request): """Grant a token.""" - hass = request.app['hass'] + hass = request.app["hass"] data = await request.post() - grant_type = data.get('grant_type') + 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': + if data.get("action") == "revoke": return await self._async_handle_revoke_token(hass, data) - if grant_type == 'authorization_code': + if grant_type == "authorization_code": return await self._async_handle_auth_code( - hass, data, str(request[KEY_REAL_IP])) + hass, data, str(request[KEY_REAL_IP]) + ) - if grant_type == 'refresh_token': + if grant_type == "refresh_token": return await self._async_handle_refresh_token( - hass, data, str(request[KEY_REAL_IP])) + hass, data, str(request[KEY_REAL_IP]) + ) - return self.json({ - 'error': 'unsupported_grant_type', - }, status_code=400) + return self.json( + {"error": "unsupported_grant_type"}, status_code=HTTP_BAD_REQUEST + ) async def _async_handle_revoke_token(self, hass, data): """Handle revoke token request.""" @@ -270,132 +271,127 @@ async def _async_handle_revoke_token(self, hass, data): # 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') + token = data.get("token") if token is None: - return web.Response(status=200) + return web.Response(status=HTTP_OK) refresh_token = await hass.auth.async_get_refresh_token_by_token(token) if refresh_token is None: - return web.Response(status=200) + return web.Response(status=HTTP_OK) await hass.auth.async_remove_refresh_token(refresh_token) - return web.Response(status=200) + return web.Response(status=HTTP_OK) async def _async_handle_auth_code(self, hass, data, remote_addr): """Handle authorization code request.""" - client_id = data.get('client_id') + 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) + return self.json( + {"error": "invalid_request", "error_description": "Invalid client id"}, + status_code=HTTP_BAD_REQUEST, + ) - code = data.get('code') + code = data.get("code") if code is None: - return self.json({ - 'error': 'invalid_request', - 'error_description': 'Invalid code', - }, status_code=400) + return self.json( + {"error": "invalid_request", "error_description": "Invalid code"}, + status_code=HTTP_BAD_REQUEST, + ) 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) + return self.json( + {"error": "invalid_request", "error_description": "Invalid code"}, + status_code=HTTP_BAD_REQUEST, + ) # 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()), - }) + return self.json( + {"error": "access_denied", "error_description": "User is not active"}, + status_code=HTTP_FORBIDDEN, + ) + + 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') + 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) + return self.json( + {"error": "invalid_request", "error_description": "Invalid client id"}, + status_code=HTTP_BAD_REQUEST, + ) - token = data.get('refresh_token') + token = data.get("refresh_token") if token is None: - return self.json({ - 'error': 'invalid_request', - }, status_code=400) + return self.json({"error": "invalid_request"}, status_code=HTTP_BAD_REQUEST) 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) + return self.json({"error": "invalid_grant"}, status_code=HTTP_BAD_REQUEST) if refresh_token.client_id != client_id: - return self.json({ - 'error': 'invalid_request', - }, status_code=400) + return self.json({"error": "invalid_request"}, status_code=HTTP_BAD_REQUEST) - access_token = hass.auth.async_create_access_token( - refresh_token, remote_addr) + 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()), - }) + 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' + 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, - })) + @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'] + hass = request.app["hass"] + user = request["hass_user"] credentials = self._retrieve_credentials( - data['client_id'], RESULT_TYPE_CREDENTIALS, data['code']) + data["client_id"], RESULT_TYPE_CREDENTIALS, data["code"] + ) if credentials is None: - return self.json_message('Invalid code', status_code=400) + return self.json_message("Invalid code", status_code=HTTP_BAD_REQUEST) await hass.auth.async_link_user(user, credentials) - return self.json_message('User linked') + return self.json_message("User linked") @callback @@ -411,11 +407,14 @@ def store_result(client_id, result): elif isinstance(result, Credentials): result_type = RESULT_TYPE_CREDENTIALS else: - raise ValueError('result has to be either User or Credentials') + 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) + temp_results[(client_id, result_type, code)] = ( + dt_util.utcnow(), + result_type, + result, + ) return code @callback @@ -443,89 +442,121 @@ def retrieve_result(client_id, result_type, code): @websocket_api.ws_require_user() @websocket_api.async_response async def websocket_current_user( - hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg): + 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.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): + 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'), + 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_expiration=timedelta(days=msg["lifespan"]), + ) - access_token = hass.auth.async_create_access_token( - refresh_token) + access_token = hass.auth.async_create_access_token(refresh_token) - connection.send_message( - websocket_api.result_message(msg['id'], access_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): + 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()])) + 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): + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg +): """Handle a delete refresh token request.""" - refresh_token = connection.user.refresh_tokens.get(msg['refresh_token_id']) + 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') + 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'], {})) + 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): + 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'])) - })) + 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 index a56671c9dcd3a..cd8e797876fd7 100644 --- a/homeassistant/components/auth/indieauth.py +++ b/homeassistant/components/auth/indieauth.py @@ -1,9 +1,9 @@ """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 +from ipaddress import ip_address +import logging +from urllib.parse import urljoin, urlparse import aiohttp @@ -23,13 +23,21 @@ async def verify_redirect_uri(hass, client_id, 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 + client_id_parts.scheme == redirect_parts.scheme + and client_id_parts.netloc == redirect_parts.netloc ) if is_valid: return True + # Whitelist the iOS and Android callbacks so that people can link apps + # without being connected to the internet. + if redirect_uri == "homeassistant://auth-callback" and client_id in ( + "https://www.home-assistant.io/android", + "https://www.home-assistant.io/iOS", + ): + 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) @@ -47,13 +55,13 @@ def __init__(self, rel): def handle_starttag(self, tag, attrs): """Handle finding a start tag.""" - if tag != 'link': + if tag != "link": return attrs = dict(attrs) - if attrs.get('rel') == self.rel: - self.found.append(attrs.get('href')) + if attrs.get("rel") == self.rel: + self.found.append(attrs.get("href")) async def fetch_redirect_uris(hass, url): @@ -68,7 +76,7 @@ async def fetch_redirect_uris(hass, url): We do not implement extracting redirect uris from headers. """ - parser = LinkTagParser('redirect_uri') + parser = LinkTagParser("redirect_uri") chunks = 0 try: async with aiohttp.ClientSession() as session: @@ -82,21 +90,16 @@ async def fetch_redirect_uris(hass, url): 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 + _LOGGER.error("OS error while looking up redirect_uri %s: %s", url, ex.strerror) except aiohttp.client_exceptions.ClientConnectionError: - _LOGGER.error(("Low level connection error while looking up " - "redirect_uri %s"), url) - pass + _LOGGER.error( + "Low level connection error while looking up redirect_uri %s", url + ) 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 @@ -125,8 +128,8 @@ def _parse_url(url): # 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='/') + if parts.path == "": + parts = parts._replace(path="/") return parts @@ -140,34 +143,35 @@ def _parse_client_id(client_id): # Client identifier URLs # MUST have either an https or http scheme - if parts.scheme not in ('http', 'https'): + 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('/')): + if any(segment in (".", "..") for segment in parts.path.split("/")): raise ValueError( - 'Client ID cannot contain single-dot or double-dot path segments') + "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') + 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') + raise ValueError("Client ID cannot contain username") if parts.password is not None: - raise ValueError('Client ID cannot contain password') + 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') + raise ValueError("Client ID contains invalid port") # Additionally, hostnames # MUST be domain names or a loopback interface and @@ -183,7 +187,7 @@ def _parse_client_id(client_id): netloc = parts.netloc # Strip the [, ] from ipv6 addresses before parsing - if netloc[0] == '[' and netloc[-1] == ']': + if netloc[0] == "[" and netloc[-1] == "]": netloc = netloc[1:-1] address = ip_address(netloc) @@ -194,4 +198,4 @@ def _parse_client_id(client_id): if address is None or is_local(address): return parts - raise ValueError('Hostname should be a domain name or local IP address') + 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 index 7fd767f4a43ed..c5d824ce617d9 100644 --- a/homeassistant/components/auth/login_flow.py +++ b/homeassistant/components/auth/login_flow.py @@ -68,69 +68,72 @@ """ from aiohttp import web import voluptuous as vol +import voluptuous_serialize 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.ban import ( + log_invalid_auth, + process_success_login, + process_wrong_login, +) from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.components.http.view import HomeAssistantView +from homeassistant.const import HTTP_BAD_REQUEST, HTTP_NOT_FOUND + 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)) + 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' + url = "/auth/providers" + name = "api:auth:providers" requires_auth = False async def get(self, request): """Get available auth providers.""" - hass = request.app['hass'] + 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' + message="Onboarding not finished", + status_code=HTTP_BAD_REQUEST, + message_code="onboarding_required", ) - return self.json([{ - 'name': provider.name, - 'id': provider.id, - 'type': provider.type, - } for provider in hass.auth.auth_providers]) + 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: + if result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY: data = result.copy() - data.pop('result') - data.pop('data') + data.pop("result") + data.pop("data") return data - if result['type'] != data_entry_flow.RESULT_TYPE_FORM: + if result["type"] != data_entry_flow.RESULT_TYPE_FORM: return result - import voluptuous_serialize - data = result.copy() - schema = data['data_schema'] + schema = data["data_schema"] if schema is None: - data['data_schema'] = [] + data["data_schema"] = [] else: - data['data_schema'] = voluptuous_serialize.convert(schema) + data["data_schema"] = voluptuous_serialize.convert(schema) return data @@ -138,8 +141,8 @@ def _prepare_result_json(result): class LoginFlowIndexView(HomeAssistantView): """View to create a config flow.""" - url = '/auth/login_flow' - name = 'api:auth:login_flow' + url = "/auth/login_flow" + name = "api:auth:login_flow" requires_auth = False def __init__(self, flow_mgr, store_result): @@ -151,39 +154,48 @@ 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, - })) + @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) + request.app["hass"], data["client_id"], data["redirect_uri"] + ): + return self.json_message( + "invalid client id or redirect uri", HTTP_BAD_REQUEST + ) - if isinstance(data['handler'], list): - handler = tuple(data['handler']) + if isinstance(data["handler"], list): + handler = tuple(data["handler"]) else: - handler = data['handler'] + 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', - }) + 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) + return self.json_message("Invalid handler specified", HTTP_NOT_FOUND) except data_entry_flow.UnknownStep: - return self.json_message('Handler does not support init', 400) + return self.json_message("Handler does not support init", HTTP_BAD_REQUEST) - if result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY: - result.pop('data') - result['result'] = self._store_result( - data['client_id'], result['result']) + if result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY: + await process_success_login(request) + result.pop("data") + result["result"] = self._store_result(data["client_id"], result["result"]) return self.json(result) return self.json(_prepare_result_json(result)) @@ -192,8 +204,8 @@ async def post(self, request, data): class LoginFlowResourceView(HomeAssistantView): """View to interact with the flow manager.""" - url = '/auth/login_flow/{flow_id}' - name = 'api:auth:login_flow:resource' + url = "/auth/login_flow/{flow_id}" + name = "api:auth:login_flow:resource" requires_auth = False def __init__(self, flow_mgr, store_result): @@ -203,44 +215,43 @@ def __init__(self, flow_mgr, 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) + return self.json_message("Invalid flow specified", HTTP_NOT_FOUND) - @RequestDataValidator(vol.Schema({ - 'client_id': str - }, extra=vol.ALLOW_EXTRA)) + @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') + client_id = data.pop("client_id") if not indieauth.verify_client_id(client_id): - return self.json_message('Invalid client id', 400) + return self.json_message("Invalid client id", HTTP_BAD_REQUEST) 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) + if flow["flow_id"] == flow_id and flow["context"][ + "ip_address" + ] != request.get(KEY_REAL_IP): + return self.json_message("IP address changed", HTTP_BAD_REQUEST) result = await self._flow_mgr.async_configure(flow_id, data) except data_entry_flow.UnknownFlow: - return self.json_message('Invalid flow specified', 404) + return self.json_message("Invalid flow specified", HTTP_NOT_FOUND) except vol.Invalid: - return self.json_message('User input malformed', 400) + return self.json_message("User input malformed", HTTP_BAD_REQUEST) - if result['type'] != data_entry_flow.RESULT_TYPE_CREATE_ENTRY: + 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']): + 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']) + result.pop("data") + result["result"] = self._store_result(client_id, result["result"]) return self.json(result) @@ -249,6 +260,6 @@ async def delete(self, request, flow_id): 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("Invalid flow specified", HTTP_NOT_FOUND) - return self.json_message('Flow aborted') + return self.json_message("Flow aborted") diff --git a/homeassistant/components/auth/manifest.json b/homeassistant/components/auth/manifest.json index 10be545f5e14e..b8c711c1dda99 100644 --- a/homeassistant/components/auth/manifest.json +++ b/homeassistant/components/auth/manifest.json @@ -1,12 +1,9 @@ { "domain": "auth", "name": "Auth", - "documentation": "https://www.home-assistant.io/components/auth", - "requirements": [], - "dependencies": [ - "http" - ], - "codeowners": [ - "@home-assistant/core" - ] + "documentation": "https://www.home-assistant.io/integrations/auth", + "dependencies": ["http"], + "after_dependencies": ["onboarding"], + "codeowners": ["@home-assistant/core"], + "quality_scale": "internal" } diff --git a/homeassistant/components/auth/mfa_setup_flow.py b/homeassistant/components/auth/mfa_setup_flow.py index 121d95aede3ec..1b199551a14ef 100644 --- a/homeassistant/components/auth/mfa_setup_flow.py +++ b/homeassistant/components/auth/mfa_setup_flow.py @@ -2,87 +2,99 @@ import logging import voluptuous as vol +import voluptuous_serialize from homeassistant import data_entry_flow from homeassistant.components import websocket_api -from homeassistant.core import callback, HomeAssistant +from homeassistant.core import HomeAssistant, callback -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_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, -}) +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' +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) +class MfaFlowManager(data_entry_flow.FlowManager): + """Manage multi factor authentication flows.""" + + async def async_create_flow(self, handler_key, *, context, data): + """Create a setup flow. handler is a mfa module.""" + mfa_module = self.hass.auth.get_auth_mfa_module(handler_key) if mfa_module is None: - raise ValueError('Mfa module {} is not found'.format(handler)) + raise ValueError(f"Mfa module {handler_key} is not found") - user_id = data.pop('user_id') + 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 + async def async_finish_flow(self, flow, result): + """Complete an mfs setup flow.""" + _LOGGER.debug("flow_result: %s", result) + return result - hass.data[DATA_SETUP_FLOW_MGR] = data_entry_flow.FlowManager( - hass, _async_create_setup_flow, _async_finish_setup_flow) + +async def async_setup(hass): + """Init mfa setup flow manager.""" + hass.data[DATA_SETUP_FLOW_MGR] = MfaFlowManager(hass) hass.components.websocket_api.async_register_command( - WS_TYPE_SETUP_MFA, websocket_setup_mfa, SCHEMA_WS_SETUP_MFA) + 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) + 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): + 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') + flow_id = msg.get("flow_id") if flow_id is not None: - result = await flow_manager.async_configure( - flow_id, msg.get('user_input')) + 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))) + websocket_api.result_message(msg["id"], _prepare_result_json(result)) + ) return - mfa_module_id = msg.get('mfa_module_id') + 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))) + connection.send_message( + websocket_api.error_message( + msg["id"], "no_module", f"MFA module {mfa_module_id} is not found" + ) + ) return result = await flow_manager.async_init( - mfa_module_id, data={'user_id': connection.user.id}) + mfa_module_id, data={"user_id": connection.user.id} + ) connection.send_message( - websocket_api.result_message( - msg['id'], _prepare_result_json(result))) + websocket_api.result_message(msg["id"], _prepare_result_json(result)) + ) hass.async_create_task(async_setup_flow(msg)) @@ -90,45 +102,47 @@ async def 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): + 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'] + mfa_module_id = msg["mfa_module_id"] try: await hass.auth.async_disable_user_mfa( - connection.user, msg['mfa_module_id']) + 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))) + connection.send_message( + websocket_api.error_message( + msg["id"], + "disable_failed", + f"Cannot disable MFA Module {mfa_module_id}: {err}", + ) + ) return - connection.send_message( - websocket_api.result_message( - msg['id'], 'done')) + 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: + if result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY: data = result.copy() return data - if result['type'] != data_entry_flow.RESULT_TYPE_FORM: + if result["type"] != data_entry_flow.RESULT_TYPE_FORM: return result - import voluptuous_serialize - data = result.copy() - schema = data['data_schema'] + schema = data["data_schema"] if schema is None: - data['data_schema'] = [] + data["data_schema"] = [] else: - data['data_schema'] = voluptuous_serialize.convert(schema) + data["data_schema"] = voluptuous_serialize.convert(schema) return data diff --git a/homeassistant/components/auth/strings.json b/homeassistant/components/auth/strings.json index 57f5ed659b08d..d386bb7a48889 100644 --- a/homeassistant/components/auth/strings.json +++ b/homeassistant/components/auth/strings.json @@ -1,5 +1,5 @@ { - "mfa_setup":{ + "mfa_setup": { "totp": { "title": "TOTP", "step": { diff --git a/homeassistant/components/auth/.translations/ar.json b/homeassistant/components/auth/translations/ar.json similarity index 100% rename from homeassistant/components/auth/.translations/ar.json rename to homeassistant/components/auth/translations/ar.json diff --git a/homeassistant/components/auth/translations/bg.json b/homeassistant/components/auth/translations/bg.json new file mode 100644 index 0000000000000..d07e20a854cc7 --- /dev/null +++ b/homeassistant/components/auth/translations/bg.json @@ -0,0 +1,35 @@ +{ + "mfa_setup": { + "notify": { + "abort": { + "no_available_service": "\u041d\u044f\u043c\u0430 \u043d\u0430\u043b\u0438\u0447\u043d\u0438 \u0443\u0441\u043b\u0443\u0433\u0438 \u0437\u0430 \u0443\u0432\u0435\u0434\u043e\u043c\u044f\u0432\u0430\u043d\u0435." + }, + "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." + }, + "step": { + "init": { + "description": "\u041c\u043e\u043b\u044f, \u0438\u0437\u0431\u0435\u0440\u0435\u0442\u0435 \u0435\u0434\u043d\u0430 \u043e\u0442 \u0443\u0441\u043b\u0443\u0433\u0438\u0442\u0435 \u0437\u0430 \u0443\u0432\u0435\u0434\u043e\u043c\u044f\u0432\u0430\u043d\u0435:", + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0432\u0430\u043d\u0435 \u043d\u0430 \u0435\u0434\u043d\u043e\u043a\u0440\u0430\u0442\u043d\u0430 \u043f\u0430\u0440\u043e\u043b\u0430, \u0434\u043e\u0441\u0442\u0430\u0432\u0435\u043d\u0430 \u0447\u0440\u0435\u0437 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442 \u0437\u0430 \u0443\u0432\u0435\u0434\u043e\u043c\u043b\u0435\u043d\u0438\u0435" + }, + "setup": { + "description": "\u0415\u0434\u043d\u043e\u043a\u0440\u0430\u0442\u043d\u0430 \u043f\u0430\u0440\u043e\u043b\u0430 \u0435 \u0438\u0437\u043f\u0440\u0430\u0442\u0435\u043d\u0430 \u0447\u0440\u0435\u0437 **notify.{notify_service}**. \u041c\u043e\u043b\u044f, \u0432\u044a\u0432\u0435\u0434\u0435\u0442\u0435 \u044f \u043f\u043e-\u0434\u043e\u043b\u0443:", + "title": "\u041f\u0440\u043e\u0432\u0435\u0440\u043a\u0430 \u043d\u0430 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430\u0442\u0430" + } + }, + "title": "\u0423\u0432\u0435\u0434\u043e\u043c\u044f\u0432\u0430\u043d\u0435 \u043d\u0430 \u0435\u0434\u043d\u043e\u043a\u0440\u0430\u0442\u043d\u0430 \u043f\u0430\u0440\u043e\u043b\u0430" + }, + "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 similarity index 100% rename from homeassistant/components/auth/.translations/ca.json rename to homeassistant/components/auth/translations/ca.json diff --git a/homeassistant/components/auth/.translations/cs.json b/homeassistant/components/auth/translations/cs.json similarity index 100% rename from homeassistant/components/auth/.translations/cs.json rename to homeassistant/components/auth/translations/cs.json diff --git a/homeassistant/components/auth/translations/da.json b/homeassistant/components/auth/translations/da.json new file mode 100644 index 0000000000000..7877a813218b5 --- /dev/null +++ b/homeassistant/components/auth/translations/da.json @@ -0,0 +1,35 @@ +{ + "mfa_setup": { + "notify": { + "abort": { + "no_available_service": "Ingen meddelelsestjenester tilg\u00e6ngelige." + }, + "error": { + "invalid_code": "Ugyldig kode, pr\u00f8v venligst igen." + }, + "step": { + "init": { + "description": "V\u00e6lg venligst en af meddelelsestjenesterne:", + "title": "Ops\u00e6t engangsadgangskoder leveret af notify-komponenten" + }, + "setup": { + "description": "En engangsadgangskode er blevet sendt via **notify.{notify_service}**. Indtast den venligst nedenunder:", + "title": "Bekr\u00e6ft ops\u00e6tningen" + } + }, + "title": "Notify-engangsadgangskode" + }, + "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 tofaktorgodkendelse 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 tofaktorgodkendelse 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 similarity index 100% rename from homeassistant/components/auth/.translations/de.json rename to homeassistant/components/auth/translations/de.json diff --git a/homeassistant/components/auth/.translations/en.json b/homeassistant/components/auth/translations/en.json similarity index 100% rename from homeassistant/components/auth/.translations/en.json rename to homeassistant/components/auth/translations/en.json diff --git a/homeassistant/components/auth/translations/es-419.json b/homeassistant/components/auth/translations/es-419.json new file mode 100644 index 0000000000000..4ac9706890556 --- /dev/null +++ b/homeassistant/components/auth/translations/es-419.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": "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" + } + }, + "title": "Notificar contrase\u00f1a de un solo uso" + }, + "totp": { + "error": { + "invalid_code": "C\u00f3digo no v\u00e1lido, por favor vuelva a intentarlo. Si recibe este error constantemente, aseg\u00farese de que el reloj de su sistema Home Assistant sea exacto." + }, + "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..5603c14fe1a77 --- /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 ** notify. {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 similarity index 100% rename from homeassistant/components/auth/.translations/et.json rename to homeassistant/components/auth/translations/et.json diff --git a/homeassistant/components/auth/.translations/fr.json b/homeassistant/components/auth/translations/fr.json similarity index 100% rename from homeassistant/components/auth/.translations/fr.json rename to homeassistant/components/auth/translations/fr.json diff --git a/homeassistant/components/auth/.translations/he.json b/homeassistant/components/auth/translations/he.json similarity index 100% rename from homeassistant/components/auth/.translations/he.json rename to homeassistant/components/auth/translations/he.json diff --git a/homeassistant/components/auth/.translations/hu.json b/homeassistant/components/auth/translations/hu.json similarity index 100% rename from homeassistant/components/auth/.translations/hu.json rename to homeassistant/components/auth/translations/hu.json diff --git a/homeassistant/components/auth/.translations/id.json b/homeassistant/components/auth/translations/id.json similarity index 100% rename from homeassistant/components/auth/.translations/id.json rename to homeassistant/components/auth/translations/id.json diff --git a/homeassistant/components/auth/translations/it.json b/homeassistant/components/auth/translations/it.json new file mode 100644 index 0000000000000..dbfe4acd6156a --- /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 monouso 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 le 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 \nDopo la scansione, 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 il 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..563c141587f54 --- /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\ud558\uae30" + }, + "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\ud558\uae30" + } + }, + "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 \uad6c\uc131\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\ud558\uae30" + } + }, + "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 similarity index 100% rename from homeassistant/components/auth/.translations/lb.json rename to homeassistant/components/auth/translations/lb.json diff --git a/homeassistant/components/auth/translations/nl.json b/homeassistant/components/auth/translations/nl.json new file mode 100644 index 0000000000000..d61613097dd7b --- /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 meldingsservices:", + "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 similarity index 100% rename from homeassistant/components/auth/.translations/nn.json rename to homeassistant/components/auth/translations/nn.json diff --git a/homeassistant/components/auth/.translations/no.json b/homeassistant/components/auth/translations/no.json similarity index 100% rename from homeassistant/components/auth/.translations/no.json rename to homeassistant/components/auth/translations/no.json diff --git a/homeassistant/components/auth/translations/pl.json b/homeassistant/components/auth/translations/pl.json new file mode 100644 index 0000000000000..78610a5324fe3 --- /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}**. Wprowad\u017a 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..e08c27a32e6d1 --- /dev/null +++ b/homeassistant/components/auth/translations/pt-BR.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 a senha de uso \u00fanico entregue pelo componente de notifica\u00e7\u00e3o" + }, + "setup": { + "description": "A senha de uso \u00fanico foi enviada via ** notify. {notify_service} **. Por favor, insira abaixo:", + "title": "Verificar a configura\u00e7\u00e3o" + } + }, + "title": "Notificar a senha de uso \u00fanico" + }, + "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": { + "description": "Para ativar a autentica\u00e7\u00e3o de dois fatores usando senhas de uso \u00fanico com base em tempo, digitalize o c\u00f3digo QR com seu aplicativo de autentica\u00e7\u00e3o. Se voc\u00ea n\u00e3o tiver um, recomendamos o [Google Authenticator] (https://support.google.com/accounts/answer/1066447) ou [Authy] (https://authy.com/). \n\n {qr_code} \n \n Depois de digitalizar o c\u00f3digo, insira o c\u00f3digo de seis d\u00edgitos do aplicativo para verificar a configura\u00e7\u00e3o. Se voc\u00ea tiver problemas para escanear o c\u00f3digo QR, fa\u00e7a uma configura\u00e7\u00e3o manual com o c\u00f3digo ** ` {code} ` **.", + "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 similarity index 100% rename from homeassistant/components/auth/.translations/pt.json rename to homeassistant/components/auth/translations/pt.json diff --git a/homeassistant/components/auth/.translations/ro.json b/homeassistant/components/auth/translations/ro.json similarity index 100% rename from homeassistant/components/auth/.translations/ro.json rename to homeassistant/components/auth/translations/ro.json diff --git a/homeassistant/components/auth/.translations/ru.json b/homeassistant/components/auth/translations/ru.json similarity index 100% rename from homeassistant/components/auth/.translations/ru.json rename to homeassistant/components/auth/translations/ru.json diff --git a/homeassistant/components/auth/.translations/sl.json b/homeassistant/components/auth/translations/sl.json similarity index 100% rename from homeassistant/components/auth/.translations/sl.json rename to homeassistant/components/auth/translations/sl.json diff --git a/homeassistant/components/auth/.translations/sv.json b/homeassistant/components/auth/translations/sv.json similarity index 100% rename from homeassistant/components/auth/.translations/sv.json rename to homeassistant/components/auth/translations/sv.json diff --git a/homeassistant/components/auth/.translations/th.json b/homeassistant/components/auth/translations/th.json similarity index 100% rename from homeassistant/components/auth/.translations/th.json rename to homeassistant/components/auth/translations/th.json diff --git a/homeassistant/components/auth/.translations/uk.json b/homeassistant/components/auth/translations/uk.json similarity index 100% rename from homeassistant/components/auth/.translations/uk.json rename to homeassistant/components/auth/translations/uk.json diff --git a/homeassistant/components/auth/translations/vi.json b/homeassistant/components/auth/translations/vi.json new file mode 100644 index 0000000000000..02ac69bb98369 --- /dev/null +++ b/homeassistant/components/auth/translations/vi.json @@ -0,0 +1,16 @@ +{ + "mfa_setup": { + "totp": { + "error": { + "invalid_code": "M\u00e3 kh\u00f4ng h\u1ee3p l\u1ec7, vui l\u00f2ng th\u1eed l\u1ea1i. N\u1ebfu b\u1ea1n g\u1eb7p l\u1ed7i n\u00e0y m\u1ed9t c\u00e1ch nh\u1ea5t qu\u00e1n, vui l\u00f2ng \u0111\u1ea3m b\u1ea3o \u0111\u1ed3ng h\u1ed3 c\u1ee7a h\u1ec7 th\u1ed1ng Home Assistant l\u00e0 ch\u00ednh x\u00e1c." + }, + "step": { + "init": { + "description": "\u0110\u1ec3 k\u00edch ho\u1ea1t x\u00e1c th\u1ef1c hai y\u1ebfu t\u1ed1 b\u1eb1ng m\u1eadt kh\u1ea9u m\u1ed9t l\u1ea7n d\u1ef1a tr\u00ean th\u1eddi gian, h\u00e3y qu\u00e9t m\u00e3 QR b\u1eb1ng \u1ee9ng d\u1ee5ng x\u00e1c th\u1ef1c c\u1ee7a b\u1ea1n. N\u1ebfu b\u1ea1n kh\u00f4ng c\u00f3, ch\u00fang t\u00f4i khuy\u00ean b\u1ea1n n\u00ean d\u00f9ng [Google Authenticator] (https://support.google.com/accounts/answer/1066447) ho\u1eb7c [Authy] (https://authy.com/). \n\n {qr_code} \n \n Sau khi qu\u00e9t m\u00e3, nh\u1eadp m\u00e3 s\u00e1u ch\u1eef s\u1ed1 t\u1eeb \u1ee9ng d\u1ee5ng c\u1ee7a b\u1ea1n \u0111\u1ec3 x\u00e1c minh thi\u1ebft l\u1eadp. N\u1ebfu b\u1ea1n g\u1eb7p v\u1ea5n \u0111\u1ec1 khi qu\u00e9t m\u00e3 QR, h\u00e3y th\u1ef1c hi\u1ec7n c\u00e0i \u0111\u1eb7t th\u1ee7 c\u00f4ng v\u1edbi m\u00e3 ** ` {code} ` **.", + "title": "Thi\u1ebft l\u1eadp x\u00e1c th\u1ef1c hai y\u1ebfu t\u1ed1 b\u1eb1ng TOTP" + } + }, + "title": "TOTP" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/auth/.translations/zh-Hans.json b/homeassistant/components/auth/translations/zh-Hans.json similarity index 100% rename from homeassistant/components/auth/.translations/zh-Hans.json rename to homeassistant/components/auth/translations/zh-Hans.json diff --git a/homeassistant/components/auth/translations/zh-Hant.json b/homeassistant/components/auth/translations/zh-Hant.json new file mode 100644 index 0000000000000..96e7f21ac998c --- /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\u96d9\u91cd\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\u96d9\u91cd\u9a57\u8b49" + } + }, + "title": "TOTP" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/automatic/device_tracker.py b/homeassistant/components/automatic/device_tracker.py index 04e069a04f97b..0f48ef6376db8 100644 --- a/homeassistant/components/automatic/device_tracker.py +++ b/homeassistant/components/automatic/device_tracker.py @@ -5,12 +5,19 @@ import logging import os +import aioautomatic 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) + 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 @@ -20,28 +27,29 @@ _LOGGER = logging.getLogger(__name__) -ATTR_FUEL_LEVEL = 'fuel_level' -AUTOMATIC_CONFIG_FILE = '.automatic/session-{}.json' +ATTR_FUEL_LEVEL = "fuel_level" -CONF_CLIENT_ID = 'client_id' -CONF_CURRENT_LOCATION = 'current_location' -CONF_DEVICES = 'devices' -CONF_SECRET = 'secret' +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'] +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' +EVENT_AUTOMATIC_UPDATE = "automatic_update" -FULL_SCOPE = DEFAULT_SCOPE + ['current_location'] +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]), -}) +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): @@ -67,16 +75,13 @@ def _write_refresh_token_to_file(hass, filename, refresh_token): 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) + 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()) @@ -86,20 +91,21 @@ def async_setup_scanner(hass, config, async_see, discovery_info=None): client_id=config[CONF_CLIENT_ID], client_secret=config[CONF_SECRET], client_session=async_get_clientsession(hass), - request_kwargs={'timeout': DEFAULT_TIMEOUT}) + request_kwargs={"timeout": DEFAULT_TIMEOUT}, + ) - filename = AUTOMATIC_CONFIG_FILE.format(config[CONF_CLIENT_ID]) + filename = f".automatic/session-{config[CONF_CLIENT_ID]}.json" refresh_token = yield from hass.async_add_job( - _get_refresh_token_from_file, hass, filename) + _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) + _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() @@ -112,8 +118,7 @@ def initialize_data(session): if refresh_token is not None: try: - session = yield from client.create_session_from_refresh_token( - refresh_token) + session = yield from client.create_session_from_refresh_token(refresh_token) yield from initialize_data(session) return True except aioautomatic.exceptions.AutomaticError as err: @@ -121,8 +126,8 @@ def initialize_data(session): configurator = hass.components.configurator request_id = configurator.async_request_config( - "Automatic", description=( - "Authorization required for Automatic device tracker."), + "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", @@ -132,8 +137,7 @@ def initialize_data(session): def initialize_callback(code, state): """Call after OAuth2 response is returned.""" try: - session = yield from client.create_session_from_oauth_code( - code, state) + 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: @@ -152,32 +156,32 @@ class AutomaticAuthCallbackView(HomeAssistantView): """Handle OAuth finish callback requests.""" requires_auth = False - url = '/api/automatic/callback' - name = 'api:automatic:callback' + 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'] + hass = request.app["hass"] params = request.query - response = web.HTTPFound('/states') + response = web.HTTPFound("/lovelace") - if 'state' not in params or 'code' not in params: - if 'error' in params: - _LOGGER.error( - "Error authorizing Automatic: %s", params['error']) + 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") + _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]: + 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'] + code = params["code"] + state = params["state"] initialize_callback = hass.data[DATA_CONFIGURING][state] hass.async_create_task(initialize_callback(code, state)) @@ -201,14 +205,15 @@ def __init__(self, hass, client, session, devices, async_see): self.client.on_app_event( lambda name, event: self.hass.async_create_task( - self.handle_event(name, event))) + 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) @@ -225,9 +230,11 @@ def handle_event(self, name, event): 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]) + _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 @@ -252,7 +259,7 @@ def handle_event(self, name, event): @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: @@ -260,16 +267,19 @@ def ws_connect(self, now=None): 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") + _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)) + self.hass, self.ws_connect, timedelta(minutes=5) + ) return if self.ws_reconnect_handle is not None: @@ -308,12 +318,12 @@ def load_vehicle(self, vehicle): @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))) + 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 @@ -323,12 +333,9 @@ def get_vehicle_info(self, vehicle): ATTR_DEV_ID: vehicle.id, ATTR_HOST_NAME: name, ATTR_MAC: vehicle.id, - ATTR_ATTRIBUTES: { - ATTR_FUEL_LEVEL: vehicle.fuel_level_percent, - } + ATTR_ATTRIBUTES: {ATTR_FUEL_LEVEL: vehicle.fuel_level_percent}, } - self.vehicle_seen[vehicle.id] = \ - vehicle.updated_at or vehicle.created_at + self.vehicle_seen[vehicle.id] = vehicle.updated_at or vehicle.created_at if vehicle.latest_location is not None: location = vehicle.latest_location @@ -339,8 +346,7 @@ def get_vehicle_info(self, vehicle): trips = [] try: # Get the most recent trip for this vehicle - trips = yield from self.session.get_trips( - vehicle=vehicle.id, limit=1) + trips = yield from self.session.get_trips(vehicle=vehicle.id, limit=1) except aioautomatic.exceptions.AutomaticError as err: _LOGGER.error(str(err)) diff --git a/homeassistant/components/automatic/manifest.json b/homeassistant/components/automatic/manifest.json index 9743835af20ab..e0d06ff0f1f0a 100644 --- a/homeassistant/components/automatic/manifest.json +++ b/homeassistant/components/automatic/manifest.json @@ -1,15 +1,8 @@ { "domain": "automatic", "name": "Automatic", - "documentation": "https://www.home-assistant.io/components/automatic", - "requirements": [ - "aioautomatic==0.6.5" - ], - "dependencies": [ - "configurator", - "http" - ], - "codeowners": [ - "@armills" - ] + "documentation": "https://www.home-assistant.io/integrations/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 fa8b77da768dc..e5b66594d2f71 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -1,61 +1,81 @@ """Allow to set up simple automation rules via the config file.""" import asyncio -from functools import partial import importlib import logging +from typing import Any, Awaitable, Callable, List, Optional, Set import voluptuous as vol from homeassistant.const import ( - 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 + ATTR_ENTITY_ID, + ATTR_NAME, + CONF_DEVICE_ID, + CONF_ENTITY_ID, + CONF_ID, + CONF_PLATFORM, + CONF_ZONE, + EVENT_AUTOMATION_TRIGGERED, + EVENT_HOMEASSISTANT_STARTED, + SERVICE_RELOAD, + SERVICE_TOGGLE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_ON, +) +from homeassistant.core import Context, CoreState, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError 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.helpers.restore_state import RestoreEntity +from homeassistant.helpers.service import async_register_admin_service +from homeassistant.helpers.typing import TemplateVarsType from homeassistant.loader import bind_hass -from homeassistant.util.dt import utcnow +from homeassistant.util.dt import parse_datetime, utcnow + +# mypy: allow-untyped-calls, allow-untyped-defs +# mypy: no-check-untyped-defs, no-warn-return-any -DOMAIN = 'automation' -ENTITY_ID_FORMAT = DOMAIN + '.{}' +DOMAIN = "automation" +ENTITY_ID_FORMAT = DOMAIN + ".{}" -GROUP_NAME_ALL_AUTOMATIONS = 'all automations' +GROUP_NAME_ALL_AUTOMATIONS = "all automations" -CONF_ALIAS = 'alias' -CONF_HIDE_ENTITY = 'hide_entity' +CONF_ALIAS = "alias" +CONF_DESCRIPTION = "description" +CONF_HIDE_ENTITY = "hide_entity" -CONF_CONDITION = 'condition' -CONF_ACTION = 'action' -CONF_TRIGGER = 'trigger' -CONF_CONDITION_TYPE = 'condition_type' -CONF_INITIAL_STATE = 'initial_state' +CONF_CONDITION = "condition" +CONF_ACTION = "action" +CONF_TRIGGER = "trigger" +CONF_CONDITION_TYPE = "condition_type" +CONF_INITIAL_STATE = "initial_state" +CONF_SKIP_CONDITION = "skip_condition" -CONDITION_USE_TRIGGER_VALUES = 'use_trigger_values' -CONDITION_TYPE_AND = 'and' -CONDITION_TYPE_OR = 'or' +CONDITION_USE_TRIGGER_VALUES = "use_trigger_values" +CONDITION_TYPE_AND = "and" +CONDITION_TYPE_NOT = "not" +CONDITION_TYPE_OR = "or" DEFAULT_CONDITION_TYPE = CONDITION_TYPE_AND -DEFAULT_HIDE_ENTITY = False DEFAULT_INITIAL_STATE = True -ATTR_LAST_TRIGGERED = 'last_triggered' -ATTR_VARIABLES = 'variables' -SERVICE_TRIGGER = 'trigger' +ATTR_LAST_TRIGGERED = "last_triggered" +ATTR_VARIABLES = "variables" +SERVICE_TRIGGER = "trigger" _LOGGER = logging.getLogger(__name__) +AutomationActionType = Callable[[HomeAssistant, TemplateVarsType], Awaitable[None]] + def _platform_validator(config): - """Validate it is a valid platform.""" + """Validate it is a valid platform.""" try: - platform = importlib.import_module('.{}'.format(config[CONF_PLATFORM]), - __name__) + platform = importlib.import_module(f".{config[CONF_PLATFORM]}", __name__) except ImportError: - raise vol.Invalid('Invalid platform specified') from None + raise vol.Invalid("Invalid platform specified") from None return platform.TRIGGER_SCHEMA(config) @@ -64,37 +84,30 @@ def _platform_validator(config): cv.ensure_list, [ vol.All( - vol.Schema({ - vol.Required(CONF_PLATFORM): str - }, extra=vol.ALLOW_EXTRA), - _platform_validator - ), - ] + vol.Schema({vol.Required(CONF_PLATFORM): str}, extra=vol.ALLOW_EXTRA), + _platform_validator, + ) + ], ) _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): 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, - vol.Required(CONF_ACTION): cv.SCRIPT_SCHEMA, -}) - -SERVICE_SCHEMA = vol.Schema({ - vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids, -}) - -TRIGGER_SERVICE_SCHEMA = vol.Schema({ - vol.Required(ATTR_ENTITY_ID): cv.comp_entity_ids, - vol.Optional(ATTR_VARIABLES, default={}): dict, -}) - -RELOAD_SERVICE_SCHEMA = vol.Schema({}) +PLATFORM_SCHEMA = vol.All( + cv.deprecated(CONF_HIDE_ENTITY, invalidation_version="0.110"), + vol.Schema( + { + # str on purpose + CONF_ID: str, + CONF_ALIAS: cv.string, + vol.Optional(CONF_DESCRIPTION): cv.string, + vol.Optional(CONF_INITIAL_STATE): cv.boolean, + vol.Optional(CONF_HIDE_ENTITY): cv.boolean, + vol.Required(CONF_TRIGGER): _TRIGGER_SCHEMA, + vol.Optional(CONF_CONDITION): _CONDITION_SCHEMA, + vol.Required(CONF_ACTION): cv.SCRIPT_SCHEMA, + } + ), +) @bind_hass @@ -107,46 +120,93 @@ def is_on(hass, entity_id): return hass.states.is_state(entity_id, STATE_ON) +@callback +def automations_with_entity(hass: HomeAssistant, entity_id: str) -> List[str]: + """Return all automations that reference the entity.""" + if DOMAIN not in hass.data: + return [] + + component = hass.data[DOMAIN] + + return [ + automation_entity.entity_id + for automation_entity in component.entities + if entity_id in automation_entity.referenced_entities + ] + + +@callback +def entities_in_automation(hass: HomeAssistant, entity_id: str) -> List[str]: + """Return all entities in a scene.""" + if DOMAIN not in hass.data: + return [] + + component = hass.data[DOMAIN] + + automation_entity = component.get_entity(entity_id) + + if automation_entity is None: + return [] + + return list(automation_entity.referenced_entities) + + +@callback +def automations_with_device(hass: HomeAssistant, device_id: str) -> List[str]: + """Return all automations that reference the device.""" + if DOMAIN not in hass.data: + return [] + + component = hass.data[DOMAIN] + + return [ + automation_entity.entity_id + for automation_entity in component.entities + if device_id in automation_entity.referenced_devices + ] + + +@callback +def devices_in_automation(hass: HomeAssistant, entity_id: str) -> List[str]: + """Return all devices in a scene.""" + if DOMAIN not in hass.data: + return [] + + component = hass.data[DOMAIN] + + automation_entity = component.get_entity(entity_id) + + if automation_entity is None: + return [] + + return list(automation_entity.referenced_devices) + + async def async_setup(hass, config): """Set up the automation.""" - component = EntityComponent(_LOGGER, DOMAIN, hass, - group_name=GROUP_NAME_ALL_AUTOMATIONS) + hass.data[DOMAIN] = component = EntityComponent(_LOGGER, DOMAIN, hass) await _async_process_config(hass, config, component) - async def trigger_service_handler(service_call): + async def trigger_service_handler(entity, service_call): """Handle automation triggers.""" - tasks = [] - for entity in await component.async_extract_from_service(service_call): - tasks.append(entity.async_trigger( - service_call.data.get(ATTR_VARIABLES), - skip_condition=True, - context=service_call.context)) - - if tasks: - await asyncio.wait(tasks, loop=hass.loop) - - 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 await component.async_extract_from_service(service_call): - tasks.append(getattr(entity, method)()) - - if tasks: - await asyncio.wait(tasks, loop=hass.loop) - - async def toggle_service_handler(service_call): - """Handle automation toggle service calls.""" - tasks = [] - 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()) - - if tasks: - await asyncio.wait(tasks, loop=hass.loop) + await entity.async_trigger( + service_call.data[ATTR_VARIABLES], + skip_condition=service_call.data[CONF_SKIP_CONDITION], + context=service_call.context, + ) + + component.async_register_entity_service( + SERVICE_TRIGGER, + { + vol.Optional(ATTR_VARIABLES, default={}): dict, + vol.Optional(CONF_SKIP_CONDITION, default=True): bool, + }, + trigger_service_handler, + ) + component.async_register_entity_service(SERVICE_TOGGLE, {}, "async_toggle") + component.async_register_entity_service(SERVICE_TURN_ON, {}, "async_turn_on") + component.async_register_entity_service(SERVICE_TURN_OFF, {}, "async_turn_off") async def reload_service_handler(service_call): """Remove all automations and load new ones from config.""" @@ -155,22 +215,9 @@ async def reload_service_handler(service_call): return await _async_process_config(hass, conf, component) - hass.services.async_register( - DOMAIN, SERVICE_TRIGGER, trigger_service_handler, - schema=TRIGGER_SERVICE_SCHEMA) - - hass.services.async_register( - DOMAIN, SERVICE_RELOAD, reload_service_handler, - schema=RELOAD_SERVICE_SCHEMA) - - hass.services.async_register( - DOMAIN, SERVICE_TOGGLE, toggle_service_handler, - schema=SERVICE_SCHEMA) - - for service in (SERVICE_TURN_ON, SERVICE_TURN_OFF): - hass.services.async_register( - DOMAIN, service, turn_onoff_service_handler, - schema=SERVICE_SCHEMA) + async_register_admin_service( + hass, DOMAIN, SERVICE_RELOAD, reload_service_handler, schema=vol.Schema({}) + ) return True @@ -178,24 +225,38 @@ async def reload_service_handler(service_call): class AutomationEntity(ToggleEntity, RestoreEntity): """Entity to show status of entity.""" - def __init__(self, automation_id, name, async_attach_triggers, cond_func, - async_action, hidden, initial_state): + def __init__( + self, + automation_id, + name, + trigger_config, + cond_func, + action_script, + initial_state, + ): """Initialize an automation entity.""" self._id = automation_id self._name = name - self._async_attach_triggers = async_attach_triggers + self._trigger_config = trigger_config self._async_detach_triggers = None self._cond_func = cond_func - self._async_action = async_action + self.action_script = action_script self._last_triggered = None - self._hidden = hidden self._initial_state = initial_state + self._is_enabled = False + self._referenced_entities: Optional[Set[str]] = None + self._referenced_devices: Optional[Set[str]] = None @property def name(self): """Name of the automation.""" return self._name + @property + def unique_id(self): + """Return unique ID.""" + return self._id + @property def should_poll(self): """No polling needed for automation entities.""" @@ -204,80 +265,107 @@ def should_poll(self): @property def state_attributes(self): """Return the entity state attributes.""" - return { - ATTR_LAST_TRIGGERED: self._last_triggered - } - - @property - def hidden(self) -> bool: - """Return True if the automation entity should be hidden from UIs.""" - return self._hidden + return {ATTR_LAST_TRIGGERED: self._last_triggered} @property def is_on(self) -> bool: """Return True if entity is on.""" - return self._async_detach_triggers is not None + return self._async_detach_triggers is not None or self._is_enabled + + @property + def referenced_devices(self): + """Return a set of referenced devices.""" + if self._referenced_devices is not None: + return self._referenced_devices + + referenced = self.action_script.referenced_devices + + if self._cond_func is not None: + for conf in self._cond_func.config: + referenced |= condition.async_extract_devices(conf) + + for conf in self._trigger_config: + device = _trigger_extract_device(conf) + if device is not None: + referenced.add(device) + + self._referenced_devices = referenced + return referenced + + @property + def referenced_entities(self): + """Return a set of referenced entities.""" + if self._referenced_entities is not None: + return self._referenced_entities + + referenced = self.action_script.referenced_entities + + if self._cond_func is not None: + for conf in self._cond_func.config: + referenced |= condition.async_extract_entities(conf) + + for conf in self._trigger_config: + for entity_id in _trigger_extract_entities(conf): + referenced.add(entity_id) + + self._referenced_entities = referenced + return referenced async def async_added_to_hass(self) -> None: """Startup with initial state or previous state.""" await super().async_added_to_hass() - if self._initial_state is not None: - enable_automation = self._initial_state - _LOGGER.debug("Automation %s initial state %s from config " - "initial_state", self.entity_id, enable_automation) - else: - 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("Automation %s initial state %s from recorder " - "last state %s", self.entity_id, - enable_automation, state) - else: - enable_automation = DEFAULT_INITIAL_STATE - _LOGGER.debug("Automation %s initial state %s from default " - "initial state", self.entity_id, - enable_automation) - if not enable_automation: - return - - # HomeAssistant is starting up - if self.hass.state == CoreState.not_running: - async def async_enable_automation(event): - """Start automation on startup.""" - await self.async_enable() + state = await self.async_get_last_state() + if state: + enable_automation = state.state == STATE_ON + last_triggered = state.attributes.get("last_triggered") + if last_triggered is not None: + self._last_triggered = parse_datetime(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, + ) - self.hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_START, async_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, + ) - # HomeAssistant is running - else: + if enable_automation: await self.async_enable() - async def async_turn_on(self, **kwargs) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on and update the state.""" - if self.is_on: - return - await self.async_enable() - async def async_turn_off(self, **kwargs) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" - if not self.is_on: - return - - self._async_detach_triggers() - self._async_detach_triggers = None - await self.async_update_ha_state() + await self.async_disable() - async def async_trigger(self, variables, skip_condition=False, - context=None): + async def async_trigger(self, variables, skip_condition=False, context=None): """Trigger automation. This method is a coroutine. """ - if not skip_condition and not self._cond_func(variables): + if ( + not skip_condition + and self._cond_func is not None + and not self._cond_func(variables) + ): return # Create a new context referring to the old context. @@ -285,30 +373,103 @@ async def async_trigger(self, variables, skip_condition=False, 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.hass.bus.async_fire( + EVENT_AUTOMATION_TRIGGERED, + {ATTR_NAME: self._name, ATTR_ENTITY_ID: self.entity_id}, + context=trigger_context, + ) + + _LOGGER.info("Executing %s", self._name) + + try: + await self.action_script.async_run(variables, trigger_context) + except Exception: # pylint: disable=broad-except + pass + self._last_triggered = utcnow() - await self.async_update_ha_state() + self.async_write_ha_state() async def async_will_remove_from_hass(self): - """Remove listeners when removing automation from HASS.""" + """Remove listeners when removing automation from Home Assistant.""" await super().async_will_remove_from_hass() - await self.async_turn_off() + await self.async_disable() async def async_enable(self): """Enable this automation entity. This method is a coroutine. """ - if self.is_on: + if self._is_enabled: + return + + self._is_enabled = True + + # HomeAssistant is starting up + if self.hass.state != CoreState.not_running: + self._async_detach_triggers = await self._async_attach_triggers(False) + self.async_write_ha_state() return - self._async_detach_triggers = await self._async_attach_triggers( - self.async_trigger) - await self.async_update_ha_state() + 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(True) + + self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STARTED, async_enable_automation + ) + self.async_write_ha_state() + + 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() + + async def _async_attach_triggers( + self, home_assistant_start: bool + ) -> Optional[Callable[[], None]]: + """Set up the triggers.""" + info = {"name": self._name, "home_assistant_start": home_assistant_start} + + triggers = [] + for conf in self._trigger_config: + platform = importlib.import_module(f".{conf[CONF_PLATFORM]}", __name__) + + triggers.append( + platform.async_attach_trigger( # type: ignore + self.hass, conf, self.async_trigger, info + ) + ) + + results = await asyncio.gather(*triggers) + + if None in results: + _LOGGER.error("Error setting up trigger %s", self._name) + + removes = [remove for remove in results if remove is not None] + if not removes: + return None + + _LOGGER.info("Initialized trigger %s", self._name) + + @callback + def remove_triggers(): + """Remove attached triggers.""" + for remove in removes: + remove() + + return remove_triggers @property def device_state_attributes(self): @@ -316,9 +477,7 @@ def device_state_attributes(self): if self._id is None: return None - return { - CONF_ID: self._id - } + return {CONF_ID: self._id} async def _async_process_config(hass, config, component): @@ -333,32 +492,30 @@ async def _async_process_config(hass, config, component): 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) + name = config_block.get(CONF_ALIAS) or f"{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) + action_script = script.Script( + hass, config_block.get(CONF_ACTION, {}), name, logger=_LOGGER + ) if CONF_CONDITION in config_block: - cond_func = _async_process_if(hass, config, config_block) + cond_func = await _async_process_if(hass, config, config_block) if cond_func is None: continue else: - def cond_func(variables): - """Condition will always pass.""" - return True + cond_func = None - async_attach_triggers = partial( - _async_process_trigger, hass, config, - config_block.get(CONF_TRIGGER, []), name - ) entity = AutomationEntity( - automation_id, name, async_attach_triggers, cond_func, action, - hidden, initial_state) + automation_id, + name, + config_block[CONF_TRIGGER], + cond_func, + action_script, + initial_state, + ) entities.append(entity) @@ -366,72 +523,49 @@ def cond_func(variables): 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) - - async def action(entity_id, variables, context): - """Execute an action.""" - _LOGGER.info('Executing %s', name) - - 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 - - -def _async_process_if(hass, config, p_config): +async def _async_process_if(hass, config, p_config): """Process if checks.""" - if_configs = p_config.get(CONF_CONDITION) + if_configs = p_config[CONF_CONDITION] checks = [] for if_config in if_configs: try: - checks.append(condition.async_from_config(if_config, False)) + checks.append(await condition.async_from_config(hass, if_config, False)) except HomeAssistantError as ex: - _LOGGER.warning('Invalid condition: %s', ex) + _LOGGER.warning("Invalid condition: %s", ex) return None def if_action(variables=None): """AND all conditions.""" return all(check(hass, variables) for check in checks) - return if_action + if_action.config = if_configs + return if_action -async def _async_process_trigger(hass, config, trigger_configs, name, action): - """Set up the triggers. - This method is a coroutine. - """ - removes = [] - info = { - 'name': name - } +@callback +def _trigger_extract_device(trigger_conf: dict) -> Optional[str]: + """Extract devices from a trigger config.""" + if trigger_conf[CONF_PLATFORM] != "device": + return None - for conf in trigger_configs: - platform = importlib.import_module('.{}'.format(conf[CONF_PLATFORM]), - __name__) + return trigger_conf[CONF_DEVICE_ID] - remove = await platform.async_trigger(hass, conf, action, info) - if not remove: - _LOGGER.error("Error setting up trigger %s", name) - continue +@callback +def _trigger_extract_entities(trigger_conf: dict) -> List[str]: + """Extract entities from a trigger config.""" + if trigger_conf[CONF_PLATFORM] in ("state", "numeric_state"): + return trigger_conf[CONF_ENTITY_ID] - _LOGGER.info("Initialized trigger %s", name) - removes.append(remove) + if trigger_conf[CONF_PLATFORM] == "zone": + return trigger_conf[CONF_ENTITY_ID] + [trigger_conf[CONF_ZONE]] - if not removes: - return None + if trigger_conf[CONF_PLATFORM] == "geo_location": + return [trigger_conf[CONF_ZONE]] - def remove_triggers(): - """Remove attached triggers.""" - for remove in removes: - remove() + if trigger_conf[CONF_PLATFORM] == "sun": + return ["sun.sun"] - return remove_triggers + return [] diff --git a/homeassistant/components/automation/config.py b/homeassistant/components/automation/config.py new file mode 100644 index 0000000000000..c2cd00fd68396 --- /dev/null +++ b/homeassistant/components/automation/config.py @@ -0,0 +1,92 @@ +"""Config validation helper for the automation integration.""" +import asyncio +import importlib + +import voluptuous as vol + +from homeassistant.components.device_automation.exceptions import ( + InvalidDeviceAutomationConfig, +) +from homeassistant.config import async_log_exception, config_without_domain +from homeassistant.const import CONF_PLATFORM +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import condition, config_per_platform, script +from homeassistant.loader import IntegrationNotFound + +from . import CONF_ACTION, CONF_CONDITION, CONF_TRIGGER, DOMAIN, PLATFORM_SCHEMA + +# mypy: allow-untyped-calls, allow-untyped-defs +# mypy: no-check-untyped-defs, no-warn-return-any + + +async def async_validate_config_item(hass, config, full_config=None): + """Validate config item.""" + config = PLATFORM_SCHEMA(config) + + triggers = [] + for trigger in config[CONF_TRIGGER]: + trigger_platform = importlib.import_module( + f"..{trigger[CONF_PLATFORM]}", __name__ + ) + if hasattr(trigger_platform, "async_validate_trigger_config"): + trigger = await trigger_platform.async_validate_trigger_config( + hass, trigger + ) + triggers.append(trigger) + config[CONF_TRIGGER] = triggers + + if CONF_CONDITION in config: + config[CONF_CONDITION] = await asyncio.gather( + *[ + condition.async_validate_condition_config(hass, cond) + for cond in config[CONF_CONDITION] + ] + ) + + config[CONF_ACTION] = await asyncio.gather( + *[ + script.async_validate_action_config(hass, action) + for action in config[CONF_ACTION] + ] + ) + + return config + + +async def _try_async_validate_config_item(hass, config, full_config=None): + """Validate config item.""" + try: + config = await async_validate_config_item(hass, config, full_config) + except ( + vol.Invalid, + HomeAssistantError, + IntegrationNotFound, + InvalidDeviceAutomationConfig, + ) as ex: + async_log_exception(ex, DOMAIN, full_config or config, hass) + return None + + return config + + +async def async_validate_config(hass, config): + """Validate config.""" + validated_automations = await asyncio.gather( + *( + _try_async_validate_config_item(hass, p_config, config) + for _, p_config in config_per_platform(config, DOMAIN) + ) + ) + + automations = [ + validated_automation + for validated_automation in validated_automations + if validated_automation is not None + ] + + # Create a copy of the configuration with all config for current + # component removed and add validated config back in. + config = config_without_domain(config, DOMAIN) + config[DOMAIN] = automations + + return config diff --git a/homeassistant/components/automation/device.py b/homeassistant/components/automation/device.py new file mode 100644 index 0000000000000..b2892d1abaa83 --- /dev/null +++ b/homeassistant/components/automation/device.py @@ -0,0 +1,31 @@ +"""Offer device oriented automation.""" +import voluptuous as vol + +from homeassistant.components.device_automation import ( + TRIGGER_BASE_SCHEMA, + async_get_device_automation_platform, +) +from homeassistant.const import CONF_DOMAIN + +# mypy: allow-untyped-defs, no-check-untyped-defs + +TRIGGER_SCHEMA = TRIGGER_BASE_SCHEMA.extend({}, extra=vol.ALLOW_EXTRA) + + +async def async_validate_trigger_config(hass, config): + """Validate config.""" + platform = await async_get_device_automation_platform( + hass, config[CONF_DOMAIN], "trigger" + ) + if hasattr(platform, "async_validate_trigger_config"): + return await getattr(platform, "async_validate_trigger_config")(hass, config) + + return platform.TRIGGER_SCHEMA(config) + + +async def async_attach_trigger(hass, config, action, automation_info): + """Listen for trigger.""" + platform = await async_get_device_automation_platform( + hass, config[CONF_DOMAIN], "trigger" + ) + return await platform.async_attach_trigger(hass, config, action, automation_info) diff --git a/homeassistant/components/automation/event.py b/homeassistant/components/automation/event.py index 6cc7e3dae7df3..9fc78746a7c82 100644 --- a/homeassistant/components/automation/event.py +++ b/homeassistant/components/automation/event.py @@ -3,28 +3,36 @@ import voluptuous as vol -from homeassistant.core import callback from homeassistant.const import CONF_PLATFORM +from homeassistant.core import callback from homeassistant.helpers import config_validation as cv -CONF_EVENT_TYPE = 'event_type' -CONF_EVENT_DATA = 'event_data' +# mypy: allow-untyped-defs + +CONF_EVENT_TYPE = "event_type" +CONF_EVENT_DATA = "event_data" _LOGGER = logging.getLogger(__name__) -TRIGGER_SCHEMA = vol.Schema({ - vol.Required(CONF_PLATFORM): 'event', - vol.Required(CONF_EVENT_TYPE): cv.string, - vol.Optional(CONF_EVENT_DATA): dict, -}) +TRIGGER_SCHEMA = vol.Schema( + { + vol.Required(CONF_PLATFORM): "event", + vol.Required(CONF_EVENT_TYPE): cv.string, + vol.Optional(CONF_EVENT_DATA): dict, + } +) -async def async_trigger(hass, config, action, automation_info): +async def async_attach_trigger( + hass, config, action, automation_info, *, platform_type="event" +): """Listen for events based on configuration.""" event_type = config.get(CONF_EVENT_TYPE) - event_data_schema = vol.Schema( - config.get(CONF_EVENT_DATA), - extra=vol.ALLOW_EXTRA) if config.get(CONF_EVENT_DATA) else None + 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): @@ -38,11 +46,11 @@ def handle_event(event): # If event data doesn't match requested schema, skip event return - hass.async_run_job(action({ - 'trigger': { - 'platform': 'event', - 'event': event, - }, - }, context=event.context)) + hass.async_run_job( + action( + {"trigger": {"platform": platform_type, "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 index 8f838ea6d6b6c..92094a751a092 100644 --- a/homeassistant/components/automation/geo_location.py +++ b/homeassistant/components/automation/geo_location.py @@ -2,32 +2,41 @@ 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) + CONF_EVENT, + CONF_PLATFORM, + CONF_SOURCE, + CONF_ZONE, + EVENT_STATE_CHANGED, +) +from homeassistant.core import callback +from homeassistant.helpers import condition, config_validation as cv from homeassistant.helpers.config_validation import entity_domain -EVENT_ENTER = 'enter' -EVENT_LEAVE = 'leave' +# mypy: allow-untyped-defs, no-check-untyped-defs + +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), -}) +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 + return state and state.attributes.get("source") == source -async def async_trigger(hass, config, action, automation_info): +async def async_attach_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) @@ -37,32 +46,41 @@ async def async_trigger(hass, config, action, automation_info): 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): + 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): + 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)) + 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 index 1b022316676fb..91b67e28c7c94 100644 --- a/homeassistant/components/automation/homeassistant.py +++ b/homeassistant/components/automation/homeassistant.py @@ -3,46 +3,46 @@ import voluptuous as vol -from homeassistant.core import callback, CoreState -from homeassistant.const import ( - CONF_PLATFORM, CONF_EVENT, EVENT_HOMEASSISTANT_STOP) +from homeassistant.const import CONF_EVENT, CONF_PLATFORM, EVENT_HOMEASSISTANT_STOP +from homeassistant.core import callback -EVENT_START = 'start' -EVENT_SHUTDOWN = 'shutdown' +# mypy: allow-untyped-defs + +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), -}) +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): +async def async_attach_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)) + hass.async_run_job( + action( + {"trigger": {"platform": "homeassistant", "event": event}}, + context=event.context, + ) + ) - return hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, - hass_shutdown) + 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, - }, - })) + if automation_info["home_assistant_start"]: + 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 51ec5baccfd4a..5924cf3b80966 100644 --- a/homeassistant/components/automation/litejet.py +++ b/homeassistant/components/automation/litejet.py @@ -3,29 +3,35 @@ import voluptuous as vol -from homeassistant.core import callback from homeassistant.const import CONF_PLATFORM +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv -import homeassistant.util.dt as dt_util from homeassistant.helpers.event import track_point_in_utc_time +import homeassistant.util.dt as dt_util + +# mypy: allow-untyped-defs, no-check-untyped-defs _LOGGER = logging.getLogger(__name__) -CONF_NUMBER = 'number' -CONF_HELD_MORE_THAN = 'held_more_than' -CONF_HELD_LESS_THAN = 'held_less_than' +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.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) -}) +TRIGGER_SCHEMA = vol.Schema( + { + vol.Required(CONF_PLATFORM): "litejet", + 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 + ), + } +) -async def async_trigger(hass, config, action, automation_info): +async def async_attach_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) @@ -36,14 +42,17 @@ async def async_trigger(hass, config, action, automation_info): @callback def call_action(): """Call action with right context.""" - hass.async_run_job(action, { - 'trigger': { - CONF_PLATFORM: 'litejet', - CONF_NUMBER: number, - CONF_HELD_MORE_THAN: held_more_than, - CONF_HELD_LESS_THAN: held_less_than + hass.async_run_job( + action, + { + "trigger": { + CONF_PLATFORM: "litejet", + CONF_NUMBER: number, + CONF_HELD_MORE_THAN: held_more_than, + CONF_HELD_LESS_THAN: held_less_than, + } }, - }) + ) # held_more_than and held_less_than: trigger on released (if in time range) # held_more_than: trigger after pressed with calculation @@ -64,9 +73,8 @@ def pressed(): 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) + hass, pressed_more_than_satisfied, dt_util.utcnow() + held_more_than + ) def released(): """Handle the release of the LiteJet switch's button.""" @@ -77,13 +85,18 @@ def released(): 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) + if ( + held_less_than is not None + and held_time < held_less_than + and (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) + @callback def async_remove(): """Remove all subscriptions used for this trigger.""" return diff --git a/homeassistant/components/automation/manifest.json b/homeassistant/components/automation/manifest.json index ea63d4ff98a31..1b5fad1b58887 100644 --- a/homeassistant/components/automation/manifest.json +++ b/homeassistant/components/automation/manifest.json @@ -1,13 +1,8 @@ { "domain": "automation", "name": "Automation", - "documentation": "https://www.home-assistant.io/components/automation", - "requirements": [], - "dependencies": [ - "group", - "webhook" - ], - "codeowners": [ - "@home-assistant/core" - ] + "documentation": "https://www.home-assistant.io/integrations/automation", + "after_dependencies": ["device_automation", "webhook"], + "codeowners": ["@home-assistant/core"], + "quality_scale": "internal" } diff --git a/homeassistant/components/automation/mqtt.py b/homeassistant/components/automation/mqtt.py index 837a22362b51d..046cbba2873ea 100644 --- a/homeassistant/components/automation/mqtt.py +++ b/homeassistant/components/automation/mqtt.py @@ -3,49 +3,58 @@ import voluptuous as vol -from homeassistant.core import callback from homeassistant.components import mqtt -from homeassistant.const import (CONF_PLATFORM, CONF_PAYLOAD) +from homeassistant.const import CONF_PAYLOAD, CONF_PLATFORM +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv -CONF_ENCODING = 'encoding' -CONF_TOPIC = 'topic' -DEFAULT_ENCODING = 'utf-8' +# mypy: allow-untyped-defs + +CONF_ENCODING = "encoding" +CONF_QOS = "qos" +CONF_TOPIC = "topic" +DEFAULT_ENCODING = "utf-8" +DEFAULT_QOS = 0 -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, -}) +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, + vol.Optional(CONF_QOS, default=DEFAULT_QOS): vol.All( + vol.Coerce(int), vol.In([0, 1, 2]) + ), + } +) -async def async_trigger(hass, config, action, automation_info): +async def async_attach_trigger(hass, config, action, automation_info): """Listen for state changes based on configuration.""" topic = config[CONF_TOPIC] payload = config.get(CONF_PAYLOAD) encoding = config[CONF_ENCODING] or None + qos = config[CONF_QOS] @callback def mqtt_automation_listener(mqttmsg): """Listen for MQTT messages.""" if payload is None or payload == mqttmsg.payload: data = { - 'platform': 'mqtt', - 'topic': mqttmsg.topic, - 'payload': mqttmsg.payload, - 'qos': mqttmsg.qos, + "platform": "mqtt", + "topic": mqttmsg.topic, + "payload": mqttmsg.payload, + "qos": mqttmsg.qos, } try: - data['payload_json'] = json.loads(mqttmsg.payload) + data["payload_json"] = json.loads(mqttmsg.payload) except ValueError: pass - hass.async_run_job(action, { - 'trigger': data - }) + hass.async_run_job(action, {"trigger": data}) remove = await mqtt.async_subscribe( - hass, topic, mqtt_automation_listener, encoding=encoding) + hass, topic, mqtt_automation_listener, encoding=encoding, qos=qos + ) return remove diff --git a/homeassistant/components/automation/numeric_state.py b/homeassistant/components/automation/numeric_state.py index bf45abb88f0e0..d8f71f5bdf301 100644 --- a/homeassistant/components/automation/numeric_state.py +++ b/homeassistant/components/automation/numeric_state.py @@ -3,35 +3,74 @@ import voluptuous as vol -from homeassistant.core import callback +from homeassistant import exceptions from homeassistant.const import ( - CONF_VALUE_TEMPLATE, CONF_PLATFORM, CONF_ENTITY_ID, - 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, - 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)) + CONF_ABOVE, + CONF_BELOW, + CONF_ENTITY_ID, + CONF_FOR, + CONF_PLATFORM, + CONF_VALUE_TEMPLATE, +) +from homeassistant.core import CALLBACK_TYPE, callback +from homeassistant.helpers import condition, config_validation as cv, template +from homeassistant.helpers.event import async_track_same_state, async_track_state_change + +# mypy: allow-incomplete-defs, allow-untyped-calls, allow-untyped-defs +# mypy: no-check-untyped-defs + + +def validate_above_below(value): + """Validate that above and below can co-exist.""" + above = value.get(CONF_ABOVE) + below = value.get(CONF_BELOW) + + if above is None or below is None: + return value + + if above > below: + raise vol.Invalid( + f"A value can never be above {above} and below {below} at the same time. You probably want two different triggers.", + ) + + return value + + +TRIGGER_SCHEMA = vol.All( + vol.Schema( + { + vol.Required(CONF_PLATFORM): "numeric_state", + vol.Required(CONF_ENTITY_ID): cv.entity_ids, + 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.Any( + vol.All(cv.time_period, cv.positive_timedelta), + cv.template, + cv.template_complex, + ), + } + ), + cv.has_at_least_one_key(CONF_BELOW, CONF_ABOVE), + validate_above_below, +) _LOGGER = logging.getLogger(__name__) -async def async_trigger(hass, config, action, automation_info): +async def async_attach_trigger( + hass, config, action, automation_info, *, platform_type="numeric_state" +) -> CALLBACK_TYPE: """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) + template.attach(hass, time_delta) value_template = config.get(CONF_VALUE_TEMPLATE) unsub_track_same = {} entities_triggered = set() + period: dict = {} if value_template is not None: value_template.hass = hass @@ -43,32 +82,40 @@ def check_numeric_state(entity, from_s, to_s): return False variables = { - 'trigger': { - 'platform': 'numeric_state', - 'entity_id': entity, - 'below': below, - 'above': above, + "trigger": { + "platform": "numeric_state", + "entity_id": entity, + "below": below, + "above": above, } } return condition.async_numeric_state( - hass, to_s, below, above, value_template, variables) + 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)) + hass.async_run_job( + action( + { + "trigger": { + "platform": platform_type, + "entity_id": entity, + "below": below, + "above": above, + "from_state": from_s, + "to_state": to_s, + "for": time_delta if not time_delta else period[entity], + } + }, + context=to_s.context, + ) + ) matching = check_numeric_state(entity, from_s, to_s) @@ -78,14 +125,50 @@ def call_action(): entities_triggered.add(entity) if time_delta: + variables = { + "trigger": { + "platform": "numeric_state", + "entity_id": entity, + "below": below, + "above": above, + } + } + + try: + if isinstance(time_delta, template.Template): + period[entity] = vol.All(cv.time_period, cv.positive_timedelta)( + time_delta.async_render(variables) + ) + elif isinstance(time_delta, dict): + time_delta_data = {} + time_delta_data.update( + template.render_complex(time_delta, variables) + ) + period[entity] = vol.All(cv.time_period, cv.positive_timedelta)( + time_delta_data + ) + else: + period[entity] = time_delta + except (exceptions.TemplateError, vol.Invalid) as ex: + _LOGGER.error( + "Error rendering '%s' for template: %s", + automation_info["name"], + ex, + ) + entities_triggered.discard(entity) + return + unsub_track_same[entity] = async_track_same_state( - hass, time_delta, call_action, entity_ids=entity_id, - async_check_same_func=check_numeric_state) + hass, + period[entity], + call_action, + entity_ids=entity, + async_check_same_func=check_numeric_state, + ) else: call_action() - unsub = async_track_state_change( - hass, entity_id, state_automation_listener) + unsub = async_track_state_change(hass, entity_id, state_automation_listener) @callback def async_remove(): diff --git a/homeassistant/components/automation/reproduce_state.py b/homeassistant/components/automation/reproduce_state.py new file mode 100644 index 0000000000000..bcd0cc4e58570 --- /dev/null +++ b/homeassistant/components/automation/reproduce_state.py @@ -0,0 +1,74 @@ +"""Reproduce an Automation state.""" +import asyncio +import logging +from typing import Any, Dict, Iterable, Optional + +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, +) +from homeassistant.core import Context, State +from homeassistant.helpers.typing import HomeAssistantType + +from . import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +VALID_STATES = {STATE_ON, STATE_OFF} + + +async def _async_reproduce_state( + hass: HomeAssistantType, + state: State, + *, + context: Optional[Context] = None, + reproduce_options: Optional[Dict[str, Any]] = None, +) -> None: + """Reproduce a single state.""" + cur_state = hass.states.get(state.entity_id) + + if cur_state is None: + _LOGGER.warning("Unable to find entity %s", state.entity_id) + return + + if state.state not in VALID_STATES: + _LOGGER.warning( + "Invalid state specified for %s: %s", state.entity_id, state.state + ) + return + + # Return if we are already at the right state. + if cur_state.state == state.state: + return + + service_data = {ATTR_ENTITY_ID: state.entity_id} + + if state.state == STATE_ON: + service = SERVICE_TURN_ON + elif state.state == STATE_OFF: + service = SERVICE_TURN_OFF + + await hass.services.async_call( + DOMAIN, service, service_data, context=context, blocking=True + ) + + +async def async_reproduce_states( + hass: HomeAssistantType, + states: Iterable[State], + *, + context: Optional[Context] = None, + reproduce_options: Optional[Dict[str, Any]] = None, +) -> None: + """Reproduce Automation states.""" + await asyncio.gather( + *( + _async_reproduce_state( + hass, state, context=context, reproduce_options=reproduce_options + ) + for state in states + ) + ) diff --git a/homeassistant/components/automation/services.yaml b/homeassistant/components/automation/services.yaml index 90f660367069c..867dc8e89cdb4 100644 --- a/homeassistant/components/automation/services.yaml +++ b/homeassistant/components/automation/services.yaml @@ -1,32 +1,34 @@ # Describes the format for available automation services - turn_on: description: Enable an automation. fields: entity_id: description: Name of the automation to turn on. - example: 'automation.notify_home' + example: "automation.notify_home" turn_off: description: Disable an automation. fields: entity_id: description: Name of the automation to turn off. - example: 'automation.notify_home' + example: "automation.notify_home" toggle: description: Toggle an automation. fields: entity_id: description: Name of the automation to toggle on/off. - example: 'automation.notify_home' + example: "automation.notify_home" trigger: description: Trigger the action of an automation. fields: entity_id: description: Name of the automation to trigger. - example: 'automation.notify_home' + example: "automation.notify_home" + skip_condition: + description: Whether or not the condition will be skipped (defaults to True). + example: true reload: description: Reload the automation configuration. diff --git a/homeassistant/components/automation/state.py b/homeassistant/components/automation/state.py index f4d7f69c07a73..29aea64c9c53d 100644 --- a/homeassistant/components/automation/state.py +++ b/homeassistant/components/automation/state.py @@ -1,67 +1,159 @@ """Offer state listening automation rules.""" +from datetime import timedelta +import logging +from typing import Dict + import voluptuous as vol -from homeassistant.core import callback -from homeassistant.const import MATCH_ALL, CONF_PLATFORM, CONF_FOR +from homeassistant import exceptions +from homeassistant.const import CONF_FOR, CONF_PLATFORM, EVENT_STATE_CHANGED, MATCH_ALL +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.event import ( - async_track_state_change, async_track_same_state) -import homeassistant.helpers.config_validation as cv + Event, + async_track_same_state, + process_state_match, +) + +# mypy: allow-incomplete-defs, allow-untyped-calls, allow-untyped-defs +# mypy: no-check-untyped-defs + +_LOGGER = logging.getLogger(__name__) -CONF_ENTITY_ID = 'entity_id' -CONF_FROM = 'from' -CONF_TO = 'to' +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 - 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)) +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): vol.Any(str, [str]), + vol.Optional(CONF_TO): vol.Any(str, [str]), + vol.Optional(CONF_FOR): vol.Any( + vol.All(cv.time_period, cv.positive_timedelta), + cv.template, + cv.template_complex, + ), + } + ), + cv.key_dependency(CONF_FOR, CONF_TO), +) -async def async_trigger(hass, config, action, automation_info): +async def async_attach_trigger( + hass: HomeAssistant, + config, + action, + automation_info, + *, + platform_type: str = "state", +) -> CALLBACK_TYPE: """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, MATCH_ALL) time_delta = config.get(CONF_FOR) - match_all = (from_state == MATCH_ALL and to_state == MATCH_ALL) + template.attach(hass, time_delta) + match_all = from_state == MATCH_ALL and to_state == MATCH_ALL unsub_track_same = {} + period: Dict[str, timedelta] = {} + match_from_state = process_state_match(from_state) + match_to_state = process_state_match(to_state) @callback - def state_automation_listener(entity, from_s, to_s): + def state_automation_listener(event: Event): """Listen for state changes and calls action.""" + entity: str = event.data["entity_id"] + if entity not in entity_id: + return + + from_s = event.data.get("old_state") + to_s = event.data.get("new_state") + + if ( + (from_s is not None and not match_from_state(from_s.state)) + or (to_s is not None and not match_to_state(to_s.state)) + or ( + 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 call_action(): """Call action with right context.""" - hass.async_run_job(action({ - 'trigger': { - 'platform': 'state', - 'entity_id': entity, - 'from_state': from_s, - 'to_state': to_s, - 'for': time_delta, - } - }, context=to_s.context)) + hass.async_run_job( + action( + { + "trigger": { + "platform": platform_type, + "entity_id": entity, + "from_state": from_s, + "to_state": to_s, + "for": time_delta if not time_delta else period[entity], + } + }, + context=event.context, + ) + ) # 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): + if ( + not match_all + and from_s is not None + and to_s is not None + and from_s.state == to_s.state + ): return if not time_delta: call_action() return + variables = { + "trigger": { + "platform": "state", + "entity_id": entity, + "from_state": from_s, + "to_state": to_s, + } + } + + try: + if isinstance(time_delta, template.Template): + period[entity] = vol.All(cv.time_period, cv.positive_timedelta)( + time_delta.async_render(variables) + ) + elif isinstance(time_delta, dict): + time_delta_data = {} + time_delta_data.update(template.render_complex(time_delta, variables)) + period[entity] = vol.All(cv.time_period, cv.positive_timedelta)( + time_delta_data + ) + else: + period[entity] = time_delta + except (exceptions.TemplateError, vol.Invalid) as ex: + _LOGGER.error( + "Error rendering '%s' for template: %s", automation_info["name"], ex + ) + return + + def _check_same_state(_, _2, new_st): + if new_st is None: + return False + return new_st.state == to_s.state + 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) + hass, period[entity], call_action, _check_same_state, entity_ids=entity, + ) - unsub = async_track_state_change( - hass, entity_id, state_automation_listener, from_state, to_state) + unsub = hass.bus.async_listen(EVENT_STATE_CHANGED, state_automation_listener) @callback def async_remove(): diff --git a/homeassistant/components/automation/strings.json b/homeassistant/components/automation/strings.json new file mode 100644 index 0000000000000..adcc505b145c1 --- /dev/null +++ b/homeassistant/components/automation/strings.json @@ -0,0 +1,9 @@ +{ + "title": "Automation", + "state": { + "_": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" + } + } +} diff --git a/homeassistant/components/automation/sun.py b/homeassistant/components/automation/sun.py index 07fbf716e1c2d..c416742f397c7 100644 --- a/homeassistant/components/automation/sun.py +++ b/homeassistant/components/automation/sun.py @@ -4,22 +4,30 @@ import voluptuous as vol -from homeassistant.core import callback from homeassistant.const import ( - CONF_EVENT, CONF_OFFSET, CONF_PLATFORM, SUN_EVENT_SUNRISE) -from homeassistant.helpers.event import async_track_sunrise, async_track_sunset + CONF_EVENT, + CONF_OFFSET, + CONF_PLATFORM, + SUN_EVENT_SUNRISE, +) +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.event import async_track_sunrise, async_track_sunset + +# mypy: allow-untyped-defs, no-check-untyped-defs _LOGGER = logging.getLogger(__name__) -TRIGGER_SCHEMA = vol.Schema({ - vol.Required(CONF_PLATFORM): 'sun', - vol.Required(CONF_EVENT): cv.sun_event, - vol.Required(CONF_OFFSET, default=timedelta(0)): cv.time_period, -}) +TRIGGER_SCHEMA = vol.Schema( + { + vol.Required(CONF_PLATFORM): "sun", + vol.Required(CONF_EVENT): cv.sun_event, + vol.Required(CONF_OFFSET, default=timedelta(0)): cv.time_period, + } +) -async def async_trigger(hass, config, action, automation_info): +async def async_attach_trigger(hass, config, action, automation_info): """Listen for events based on configuration.""" event = config.get(CONF_EVENT) offset = config.get(CONF_OFFSET) @@ -27,13 +35,9 @@ async def async_trigger(hass, config, action, automation_info): @callback def call_action(): """Call action with right context.""" - hass.async_run_job(action, { - 'trigger': { - 'platform': 'sun', - 'event': event, - 'offset': offset, - }, - }) + hass.async_run_job( + action, {"trigger": {"platform": "sun", "event": event, "offset": offset}} + ) if event == SUN_EVENT_SUNRISE: return async_track_sunrise(hass, call_action, offset) diff --git a/homeassistant/components/automation/template.py b/homeassistant/components/automation/template.py index 6371be2802102..ee4484410cd4c 100644 --- a/homeassistant/components/automation/template.py +++ b/homeassistant/components/automation/template.py @@ -3,34 +3,108 @@ import voluptuous as vol +from homeassistant import exceptions +from homeassistant.const import CONF_FOR, CONF_PLATFORM, CONF_VALUE_TEMPLATE from homeassistant.core import callback -from homeassistant.const import CONF_VALUE_TEMPLATE, CONF_PLATFORM -from homeassistant.helpers.event import async_track_template -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import condition, config_validation as cv, template +from homeassistant.helpers.event import async_track_same_state, async_track_template + +# mypy: allow-untyped-defs, no-check-untyped-defs _LOGGER = logging.getLogger(__name__) -TRIGGER_SCHEMA = IF_ACTION_SCHEMA = vol.Schema({ - vol.Required(CONF_PLATFORM): 'template', - vol.Required(CONF_VALUE_TEMPLATE): cv.template, -}) +TRIGGER_SCHEMA = IF_ACTION_SCHEMA = vol.Schema( + { + vol.Required(CONF_PLATFORM): "template", + vol.Required(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_FOR): vol.Any( + vol.All(cv.time_period, cv.positive_timedelta), + cv.template, + cv.template_complex, + ), + } +) -async def async_trigger(hass, config, action, automation_info): +async def async_attach_trigger( + hass, config, action, automation_info, *, platform_type="numeric_state" +): """Listen for state changes based on configuration.""" value_template = config.get(CONF_VALUE_TEMPLATE) value_template.hass = hass + time_delta = config.get(CONF_FOR) + template.attach(hass, time_delta) + unsub_track_same = None @callback def template_listener(entity_id, from_s, to_s): """Listen for state changes and calls action.""" - hass.async_run_job(action({ - 'trigger': { - 'platform': 'template', - 'entity_id': entity_id, - 'from_state': from_s, - 'to_state': to_s, - }, - }, context=(to_s.context if to_s else None))) - - return async_track_template(hass, value_template, template_listener) + nonlocal unsub_track_same + + @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, + "for": time_delta if not time_delta else period, + } + }, + context=(to_s.context if to_s else None), + ) + ) + + if not time_delta: + call_action() + return + + variables = { + "trigger": { + "platform": platform_type, + "entity_id": entity_id, + "from_state": from_s, + "to_state": to_s, + } + } + + try: + if isinstance(time_delta, template.Template): + period = vol.All(cv.time_period, cv.positive_timedelta)( + time_delta.async_render(variables) + ) + elif isinstance(time_delta, dict): + time_delta_data = {} + time_delta_data.update(template.render_complex(time_delta, variables)) + period = vol.All(cv.time_period, cv.positive_timedelta)(time_delta_data) + else: + period = time_delta + except (exceptions.TemplateError, vol.Invalid) as ex: + _LOGGER.error( + "Error rendering '%s' for template: %s", automation_info["name"], ex + ) + return + + unsub_track_same = async_track_same_state( + hass, + period, + 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_remove diff --git a/homeassistant/components/automation/time.py b/homeassistant/components/automation/time.py index ce6d6eb444689..5f46195296022 100644 --- a/homeassistant/components/automation/time.py +++ b/homeassistant/components/automation/time.py @@ -3,20 +3,21 @@ import voluptuous as vol -from homeassistant.core import callback from homeassistant.const import CONF_AT, CONF_PLATFORM +from homeassistant.core import callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.event import async_track_time_change +# mypy: allow-untyped-defs, no-check-untyped-defs + _LOGGER = logging.getLogger(__name__) -TRIGGER_SCHEMA = vol.Schema({ - vol.Required(CONF_PLATFORM): 'time', - vol.Required(CONF_AT): cv.time, -}) +TRIGGER_SCHEMA = vol.Schema( + {vol.Required(CONF_PLATFORM): "time", vol.Required(CONF_AT): cv.time} +) -async def async_trigger(hass, config, action, automation_info): +async def async_attach_trigger(hass, config, action, automation_info): """Listen for state changes based on configuration.""" at_time = config.get(CONF_AT) hours, minutes, seconds = at_time.hour, at_time.minute, at_time.second @@ -24,12 +25,8 @@ async def async_trigger(hass, config, action, automation_info): @callback def time_automation_listener(now): """Listen for time changes and calls action.""" - hass.async_run_job(action, { - 'trigger': { - 'platform': 'time', - 'now': now, - }, - }) - - return async_track_time_change(hass, time_automation_listener, - hour=hours, minute=minutes, second=seconds) + hass.async_run_job(action, {"trigger": {"platform": "time", "now": now}}) + + return async_track_time_change( + hass, time_automation_listener, hour=hours, minute=minutes, second=seconds + ) diff --git a/homeassistant/components/automation/time_pattern.py b/homeassistant/components/automation/time_pattern.py index da8bc9f8629ce..65d44f5b1caf4 100644 --- a/homeassistant/components/automation/time_pattern.py +++ b/homeassistant/components/automation/time_pattern.py @@ -3,26 +3,33 @@ import voluptuous as vol -from homeassistant.core import callback from homeassistant.const import CONF_PLATFORM +from homeassistant.core import callback 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' +# mypy: allow-untyped-defs, no-check-untyped-defs + +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)) +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): +async def async_attach_trigger(hass, config, action, automation_info): """Listen for state changes based on configuration.""" hours = config.get(CONF_HOURS) minutes = config.get(CONF_MINUTES) @@ -37,12 +44,10 @@ async def async_trigger(hass, config, action, automation_info): @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) + 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/translations/af.json b/homeassistant/components/automation/translations/af.json new file mode 100644 index 0000000000000..c821073c2ed73 --- /dev/null +++ b/homeassistant/components/automation/translations/af.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "Af", + "on": "Aan" + } + }, + "title": "Outomatisering" +} \ No newline at end of file diff --git a/homeassistant/components/automation/translations/ar.json b/homeassistant/components/automation/translations/ar.json new file mode 100644 index 0000000000000..392afb2946f7d --- /dev/null +++ b/homeassistant/components/automation/translations/ar.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "\u0625\u064a\u0642\u0627\u0641", + "on": "\u062a\u0634\u063a\u064a\u0644" + } + }, + "title": "\u0627\u0644\u062a\u0634\u063a\u064a\u0644 \u0627\u0644\u062a\u0644\u0642\u0627\u0626\u064a" +} \ No newline at end of file diff --git a/homeassistant/components/automation/translations/bg.json b/homeassistant/components/automation/translations/bg.json new file mode 100644 index 0000000000000..1e294bff9a7aa --- /dev/null +++ b/homeassistant/components/automation/translations/bg.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "\u0418\u0437\u043a\u043b\u044e\u0447\u0435\u043d", + "on": "\u0412\u043a\u043b\u044e\u0447\u0435\u043d" + } + }, + "title": "\u0410\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0437\u0430\u0446\u0438\u044f" +} \ No newline at end of file diff --git a/homeassistant/components/automation/translations/bs.json b/homeassistant/components/automation/translations/bs.json new file mode 100644 index 0000000000000..c40d856e4bb4c --- /dev/null +++ b/homeassistant/components/automation/translations/bs.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "Isklju\u010den", + "on": "Uklju\u010den" + } + }, + "title": "Automatizacija" +} \ No newline at end of file diff --git a/homeassistant/components/automation/translations/ca.json b/homeassistant/components/automation/translations/ca.json new file mode 100644 index 0000000000000..d138d6da6e5c8 --- /dev/null +++ b/homeassistant/components/automation/translations/ca.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "Desactivat", + "on": "Activat" + } + }, + "title": "Automatitzaci\u00f3" +} \ No newline at end of file diff --git a/homeassistant/components/automation/translations/cs.json b/homeassistant/components/automation/translations/cs.json new file mode 100644 index 0000000000000..5a8f3819c9dda --- /dev/null +++ b/homeassistant/components/automation/translations/cs.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "Neaktivn\u00ed", + "on": "Aktivn\u00ed" + } + }, + "title": "Automatizace" +} \ No newline at end of file diff --git a/homeassistant/components/automation/translations/cy.json b/homeassistant/components/automation/translations/cy.json new file mode 100644 index 0000000000000..8239d527af3a4 --- /dev/null +++ b/homeassistant/components/automation/translations/cy.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "I ffwrdd", + "on": "Ar" + } + }, + "title": "Awtomeiddio" +} \ No newline at end of file diff --git a/homeassistant/components/automation/translations/da.json b/homeassistant/components/automation/translations/da.json new file mode 100644 index 0000000000000..755c3719ee83d --- /dev/null +++ b/homeassistant/components/automation/translations/da.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "Fra", + "on": "Til" + } + }, + "title": "Automatisering" +} \ No newline at end of file diff --git a/homeassistant/components/automation/translations/de.json b/homeassistant/components/automation/translations/de.json new file mode 100644 index 0000000000000..9920c73d4479f --- /dev/null +++ b/homeassistant/components/automation/translations/de.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "Aus", + "on": "An" + } + }, + "title": "Automatisierung" +} \ No newline at end of file diff --git a/homeassistant/components/automation/translations/el.json b/homeassistant/components/automation/translations/el.json new file mode 100644 index 0000000000000..14f4174883083 --- /dev/null +++ b/homeassistant/components/automation/translations/el.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "\u039a\u03bb\u03b5\u03b9\u03c3\u03c4\u03cc", + "on": "\u0395\u03bd\u03b5\u03c1\u03b3\u03cc\u03c2 " + } + }, + "title": "\u0391\u03c5\u03c4\u03bf\u03bc\u03b1\u03c4\u03b9\u03c3\u03bc\u03cc\u03c2" +} \ No newline at end of file diff --git a/homeassistant/components/automation/translations/en.json b/homeassistant/components/automation/translations/en.json new file mode 100644 index 0000000000000..e5dabcf3bce55 --- /dev/null +++ b/homeassistant/components/automation/translations/en.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "Off", + "on": "On" + } + }, + "title": "Automation" +} \ No newline at end of file diff --git a/homeassistant/components/automation/translations/es-419.json b/homeassistant/components/automation/translations/es-419.json new file mode 100644 index 0000000000000..30b83fcacaf0b --- /dev/null +++ b/homeassistant/components/automation/translations/es-419.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "Desactivado", + "on": "Encendido" + } + }, + "title": "Automatizaci\u00f3n" +} \ No newline at end of file diff --git a/homeassistant/components/automation/translations/es.json b/homeassistant/components/automation/translations/es.json new file mode 100644 index 0000000000000..c20f1be7d1d14 --- /dev/null +++ b/homeassistant/components/automation/translations/es.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "Apagado", + "on": "Encendida" + } + }, + "title": "Automatizaci\u00f3n" +} \ No newline at end of file diff --git a/homeassistant/components/automation/translations/et.json b/homeassistant/components/automation/translations/et.json new file mode 100644 index 0000000000000..71df51e9147c0 --- /dev/null +++ b/homeassistant/components/automation/translations/et.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "V\u00e4ljas", + "on": "Sees" + } + }, + "title": "Automatiseerimine" +} \ No newline at end of file diff --git a/homeassistant/components/automation/translations/eu.json b/homeassistant/components/automation/translations/eu.json new file mode 100644 index 0000000000000..e0c3e625dfd7a --- /dev/null +++ b/homeassistant/components/automation/translations/eu.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "Itzalita", + "on": "Piztuta" + } + }, + "title": "Automatizazioa" +} \ No newline at end of file diff --git a/homeassistant/components/automation/translations/fa.json b/homeassistant/components/automation/translations/fa.json new file mode 100644 index 0000000000000..78b9a05540acd --- /dev/null +++ b/homeassistant/components/automation/translations/fa.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "\u062e\u0627\u0645\u0648\u0634", + "on": "\u0641\u0639\u0627\u0644" + } + }, + "title": "\u0627\u062a\u0648\u0645\u0627\u0633\u06cc\u0648\u0646" +} \ No newline at end of file diff --git a/homeassistant/components/automation/translations/fi.json b/homeassistant/components/automation/translations/fi.json new file mode 100644 index 0000000000000..b55e959d0c5a1 --- /dev/null +++ b/homeassistant/components/automation/translations/fi.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "Pois", + "on": "P\u00e4\u00e4ll\u00e4" + } + }, + "title": "Automaatio" +} \ No newline at end of file diff --git a/homeassistant/components/automation/translations/fr.json b/homeassistant/components/automation/translations/fr.json new file mode 100644 index 0000000000000..548c30fd0dec1 --- /dev/null +++ b/homeassistant/components/automation/translations/fr.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "Inactif", + "on": "Actif" + } + }, + "title": "Automation" +} \ No newline at end of file diff --git a/homeassistant/components/automation/translations/gsw.json b/homeassistant/components/automation/translations/gsw.json new file mode 100644 index 0000000000000..4cdd801926ae4 --- /dev/null +++ b/homeassistant/components/automation/translations/gsw.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "Us", + "on": "Ah" + } + }, + "title": "Automation" +} \ No newline at end of file diff --git a/homeassistant/components/automation/translations/he.json b/homeassistant/components/automation/translations/he.json new file mode 100644 index 0000000000000..6e4decfce9a04 --- /dev/null +++ b/homeassistant/components/automation/translations/he.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "\u05db\u05d1\u05d5\u05d9", + "on": "\u05d3\u05dc\u05d5\u05e7" + } + }, + "title": "\u05d0\u05d5\u05d8\u05d5\u05de\u05e6\u05d9\u05d4" +} \ No newline at end of file diff --git a/homeassistant/components/automation/translations/hi.json b/homeassistant/components/automation/translations/hi.json new file mode 100644 index 0000000000000..d68188a80102d --- /dev/null +++ b/homeassistant/components/automation/translations/hi.json @@ -0,0 +1,8 @@ +{ + "state": { + "_": { + "off": "\u092c\u0902\u0926" + } + }, + "title": "\u0938\u094d\u0935\u091a\u093e\u0932\u0928" +} \ No newline at end of file diff --git a/homeassistant/components/automation/translations/hr.json b/homeassistant/components/automation/translations/hr.json new file mode 100644 index 0000000000000..c40d856e4bb4c --- /dev/null +++ b/homeassistant/components/automation/translations/hr.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "Isklju\u010den", + "on": "Uklju\u010den" + } + }, + "title": "Automatizacija" +} \ No newline at end of file diff --git a/homeassistant/components/automation/translations/hu.json b/homeassistant/components/automation/translations/hu.json new file mode 100644 index 0000000000000..85640af23ba31 --- /dev/null +++ b/homeassistant/components/automation/translations/hu.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "Ki", + "on": "Be" + } + }, + "title": "Automatiz\u00e1l\u00e1s" +} \ No newline at end of file diff --git a/homeassistant/components/automation/translations/hy.json b/homeassistant/components/automation/translations/hy.json new file mode 100644 index 0000000000000..a421380748b74 --- /dev/null +++ b/homeassistant/components/automation/translations/hy.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "\u0531\u0576\u057b\u0561\u057f\u057e\u0561\u056e", + "on": "\u0544\u056b\u0561\u0581\u0561\u056e" + } + }, + "title": "\u0531\u057e\u057f\u0578\u0574\u0561\u057f\u0561\u0581\u0578\u0582\u0574" +} \ No newline at end of file diff --git a/homeassistant/components/automation/translations/id.json b/homeassistant/components/automation/translations/id.json new file mode 100644 index 0000000000000..eabfe0b64aac4 --- /dev/null +++ b/homeassistant/components/automation/translations/id.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "Off", + "on": "On" + } + }, + "title": "Otomasi" +} \ No newline at end of file diff --git a/homeassistant/components/automation/translations/is.json b/homeassistant/components/automation/translations/is.json new file mode 100644 index 0000000000000..7585e03c3b556 --- /dev/null +++ b/homeassistant/components/automation/translations/is.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "\u00d3virk", + "on": "Virk" + } + }, + "title": "Sj\u00e1lfvirkni" +} \ No newline at end of file diff --git a/homeassistant/components/automation/translations/it.json b/homeassistant/components/automation/translations/it.json new file mode 100644 index 0000000000000..c913ae7de4d31 --- /dev/null +++ b/homeassistant/components/automation/translations/it.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "Spento", + "on": "Acceso" + } + }, + "title": "Automazione" +} \ No newline at end of file diff --git a/homeassistant/components/automation/translations/ja.json b/homeassistant/components/automation/translations/ja.json new file mode 100644 index 0000000000000..ffd515979a230 --- /dev/null +++ b/homeassistant/components/automation/translations/ja.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "\u30aa\u30d5", + "on": "\u30aa\u30f3" + } + }, + "title": "\u30aa\u30fc\u30c8\u30e1\u30fc\u30b7\u30e7\u30f3" +} \ No newline at end of file diff --git a/homeassistant/components/automation/translations/ko.json b/homeassistant/components/automation/translations/ko.json new file mode 100644 index 0000000000000..18be137be1b44 --- /dev/null +++ b/homeassistant/components/automation/translations/ko.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "\uaebc\uc9d0", + "on": "\ucf1c\uc9d0" + } + }, + "title": "\uc790\ub3d9\ud654" +} \ No newline at end of file diff --git a/homeassistant/components/automation/translations/lb.json b/homeassistant/components/automation/translations/lb.json new file mode 100644 index 0000000000000..8a4ef4d9bf1ad --- /dev/null +++ b/homeassistant/components/automation/translations/lb.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "Aus", + "on": "Un" + } + }, + "title": "Automatismen" +} \ No newline at end of file diff --git a/homeassistant/components/automation/translations/lt.json b/homeassistant/components/automation/translations/lt.json new file mode 100644 index 0000000000000..3cf0e9b442d9f --- /dev/null +++ b/homeassistant/components/automation/translations/lt.json @@ -0,0 +1,8 @@ +{ + "state": { + "_": { + "off": "I\u0161jungta", + "on": "\u012ejungta" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/automation/translations/lv.json b/homeassistant/components/automation/translations/lv.json new file mode 100644 index 0000000000000..48407ed6ab854 --- /dev/null +++ b/homeassistant/components/automation/translations/lv.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "Izsl\u0113gts", + "on": "Iesl\u0113gts" + } + }, + "title": "Automatiz\u0101cija" +} \ No newline at end of file diff --git a/homeassistant/components/automation/translations/nb.json b/homeassistant/components/automation/translations/nb.json new file mode 100644 index 0000000000000..64e00db42caf8 --- /dev/null +++ b/homeassistant/components/automation/translations/nb.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "Av", + "on": "P\u00e5" + } + }, + "title": "Automasjon" +} \ No newline at end of file diff --git a/homeassistant/components/automation/translations/nl.json b/homeassistant/components/automation/translations/nl.json new file mode 100644 index 0000000000000..7ef3acc9f2c99 --- /dev/null +++ b/homeassistant/components/automation/translations/nl.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "Uit", + "on": "Aan" + } + }, + "title": "Automatisering" +} \ No newline at end of file diff --git a/homeassistant/components/automation/translations/nn.json b/homeassistant/components/automation/translations/nn.json new file mode 100644 index 0000000000000..7c18b2e2ce2c3 --- /dev/null +++ b/homeassistant/components/automation/translations/nn.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "Av", + "on": "P\u00e5" + } + }, + "title": "Automasjonar" +} \ No newline at end of file diff --git a/homeassistant/components/automation/translations/no.json b/homeassistant/components/automation/translations/no.json new file mode 100644 index 0000000000000..2e6c49d899383 --- /dev/null +++ b/homeassistant/components/automation/translations/no.json @@ -0,0 +1,3 @@ +{ + "title": "Automatisering" +} \ No newline at end of file diff --git a/homeassistant/components/automation/translations/pl.json b/homeassistant/components/automation/translations/pl.json new file mode 100644 index 0000000000000..f8ed21a204dd7 --- /dev/null +++ b/homeassistant/components/automation/translations/pl.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "wy\u0142\u0105czony", + "on": "w\u0142\u0105czony" + } + }, + "title": "Automatyzacja" +} \ No newline at end of file diff --git a/homeassistant/components/automation/translations/pt-BR.json b/homeassistant/components/automation/translations/pt-BR.json new file mode 100644 index 0000000000000..30c78d0a187b6 --- /dev/null +++ b/homeassistant/components/automation/translations/pt-BR.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "Desligado", + "on": "Ativa" + } + }, + "title": "Automa\u00e7\u00e3o" +} \ No newline at end of file diff --git a/homeassistant/components/automation/translations/pt.json b/homeassistant/components/automation/translations/pt.json new file mode 100644 index 0000000000000..447658433e5c7 --- /dev/null +++ b/homeassistant/components/automation/translations/pt.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "Desligado", + "on": "Ligado" + } + }, + "title": "Automa\u00e7\u00e3o" +} \ No newline at end of file diff --git a/homeassistant/components/automation/translations/ro.json b/homeassistant/components/automation/translations/ro.json new file mode 100644 index 0000000000000..f21db43282c1c --- /dev/null +++ b/homeassistant/components/automation/translations/ro.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "Oprit", + "on": "Pornit" + } + }, + "title": "Automatiz\u0103ri" +} \ No newline at end of file diff --git a/homeassistant/components/automation/translations/ru.json b/homeassistant/components/automation/translations/ru.json new file mode 100644 index 0000000000000..79732bea38539 --- /dev/null +++ b/homeassistant/components/automation/translations/ru.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "\u0412\u044b\u043a\u043b", + "on": "\u0412\u043a\u043b" + } + }, + "title": "\u0410\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0437\u0430\u0446\u0438\u044f" +} \ No newline at end of file diff --git a/homeassistant/components/automation/translations/sk.json b/homeassistant/components/automation/translations/sk.json new file mode 100644 index 0000000000000..a300acd23dae4 --- /dev/null +++ b/homeassistant/components/automation/translations/sk.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "Neakt\u00edvny", + "on": "Akt\u00edvna" + } + }, + "title": "Automatiz\u00e1cia" +} \ No newline at end of file diff --git a/homeassistant/components/automation/translations/sl.json b/homeassistant/components/automation/translations/sl.json new file mode 100644 index 0000000000000..9045a3f3d3635 --- /dev/null +++ b/homeassistant/components/automation/translations/sl.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "Izklju\u010den", + "on": "Vklopljen" + } + }, + "title": "Avtomatizacija" +} \ No newline at end of file diff --git a/homeassistant/components/automation/translations/sv.json b/homeassistant/components/automation/translations/sv.json new file mode 100644 index 0000000000000..8a5e2e58a9c1f --- /dev/null +++ b/homeassistant/components/automation/translations/sv.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "Av", + "on": "P\u00e5" + } + }, + "title": "Automation" +} \ No newline at end of file diff --git a/homeassistant/components/automation/translations/ta.json b/homeassistant/components/automation/translations/ta.json new file mode 100644 index 0000000000000..27ed507378f35 --- /dev/null +++ b/homeassistant/components/automation/translations/ta.json @@ -0,0 +1,8 @@ +{ + "state": { + "_": { + "off": "\u0b86\u0b83\u0baa\u0bcd", + "on": "\u0b86\u0ba9\u0bcd " + } + } +} \ No newline at end of file diff --git a/homeassistant/components/automation/translations/te.json b/homeassistant/components/automation/translations/te.json new file mode 100644 index 0000000000000..9577cca49cc97 --- /dev/null +++ b/homeassistant/components/automation/translations/te.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "\u0c06\u0c2b\u0c4d", + "on": "\u0c06\u0c28\u0c4d" + } + }, + "title": "\u0c06\u0c1f\u0c4b\u0c2e\u0c47\u0c37\u0c28\u0c4d" +} \ No newline at end of file diff --git a/homeassistant/components/automation/translations/th.json b/homeassistant/components/automation/translations/th.json new file mode 100644 index 0000000000000..0754717d6abfd --- /dev/null +++ b/homeassistant/components/automation/translations/th.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "\u0e1b\u0e34\u0e14", + "on": "\u0e40\u0e1b\u0e34\u0e14" + } + }, + "title": "\u0e01\u0e32\u0e23\u0e17\u0e33\u0e07\u0e32\u0e19\u0e2d\u0e31\u0e15\u0e42\u0e19\u0e21\u0e31\u0e15\u0e34" +} \ No newline at end of file diff --git a/homeassistant/components/automation/translations/tr.json b/homeassistant/components/automation/translations/tr.json new file mode 100644 index 0000000000000..804b616bfaeac --- /dev/null +++ b/homeassistant/components/automation/translations/tr.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "Kapal\u0131", + "on": "A\u00e7\u0131k" + } + }, + "title": "Otomasyon" +} \ No newline at end of file diff --git a/homeassistant/components/automation/translations/uk.json b/homeassistant/components/automation/translations/uk.json new file mode 100644 index 0000000000000..aa6eebb40c93c --- /dev/null +++ b/homeassistant/components/automation/translations/uk.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "\u0412\u0438\u043c\u043a\u043d\u0435\u043d\u043e", + "on": "\u0423\u0432\u0456\u043c\u043a\u043d\u0435\u043d\u043e" + } + }, + "title": "\u0410\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0437\u0430\u0446\u0456\u044f" +} \ No newline at end of file diff --git a/homeassistant/components/automation/translations/vi.json b/homeassistant/components/automation/translations/vi.json new file mode 100644 index 0000000000000..8b466688be97a --- /dev/null +++ b/homeassistant/components/automation/translations/vi.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "T\u1eaft", + "on": "B\u1eadt" + } + }, + "title": "T\u1ef1 \u0111\u1ed9ng h\u00f3a" +} \ No newline at end of file diff --git a/homeassistant/components/automation/translations/zh-Hans.json b/homeassistant/components/automation/translations/zh-Hans.json new file mode 100644 index 0000000000000..8a6cdbc5db8f8 --- /dev/null +++ b/homeassistant/components/automation/translations/zh-Hans.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "\u5173\u95ed", + "on": "\u5f00\u542f" + } + }, + "title": "\u81ea\u52a8\u5316" +} \ No newline at end of file diff --git a/homeassistant/components/automation/translations/zh-Hant.json b/homeassistant/components/automation/translations/zh-Hant.json new file mode 100644 index 0000000000000..3fd099ef8d82d --- /dev/null +++ b/homeassistant/components/automation/translations/zh-Hant.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "\u95dc\u9589", + "on": "\u958b\u555f" + } + }, + "title": "\u81ea\u52d5\u5316" +} \ No newline at end of file diff --git a/homeassistant/components/automation/webhook.py b/homeassistant/components/automation/webhook.py index 37cab3cb8c030..5d01c6454a80a 100644 --- a/homeassistant/components/automation/webhook.py +++ b/homeassistant/components/automation/webhook.py @@ -5,44 +5,45 @@ from aiohttp import hdrs import voluptuous as vol -from homeassistant.core import callback from homeassistant.const import CONF_PLATFORM, CONF_WEBHOOK_ID +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from . import DOMAIN as AUTOMATION_DOMAIN -DEPENDENCIES = ('webhook',) +# mypy: allow-untyped-defs + +DEPENDENCIES = ("webhook",) _LOGGER = logging.getLogger(__name__) -TRIGGER_SCHEMA = vol.Schema({ - vol.Required(CONF_PLATFORM): 'webhook', - vol.Required(CONF_WEBHOOK_ID): cv.string, -}) +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, - } + result = {"platform": "webhook", "webhook_id": webhook_id} - if 'json' in request.headers.get(hdrs.CONTENT_TYPE, ''): - result['json'] = await request.json() + if "json" in request.headers.get(hdrs.CONTENT_TYPE, ""): + result["json"] = await request.json() else: - result['data'] = await request.post() + result["data"] = await request.post() - result['query'] = request.query - hass.async_run_job(action, {'trigger': result}) + result["query"] = request.query + hass.async_run_job(action, {"trigger": result}) -async def async_trigger(hass, config, action, automation_info): +async def async_attach_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)) + AUTOMATION_DOMAIN, + automation_info["name"], + webhook_id, + partial(_handle_webhook, action), + ) @callback def unregister(): diff --git a/homeassistant/components/automation/zone.py b/homeassistant/components/automation/zone.py index e2d79eede8d72..cae2a76dd03ae 100644 --- a/homeassistant/components/automation/zone.py +++ b/homeassistant/components/automation/zone.py @@ -1,27 +1,36 @@ """Offer zone automation rules.""" import voluptuous as vol -from homeassistant.core import callback from homeassistant.const import ( - CONF_EVENT, CONF_ENTITY_ID, CONF_ZONE, MATCH_ALL, CONF_PLATFORM) + CONF_ENTITY_ID, + CONF_EVENT, + CONF_PLATFORM, + CONF_ZONE, + MATCH_ALL, +) +from homeassistant.core import callback +from homeassistant.helpers import condition, config_validation as cv, location from homeassistant.helpers.event import async_track_state_change -from homeassistant.helpers import ( - condition, config_validation as cv, location) -EVENT_ENTER = 'enter' -EVENT_LEAVE = 'leave' +# mypy: allow-untyped-defs, no-check-untyped-defs + +EVENT_ENTER = "enter" +EVENT_LEAVE = "leave" DEFAULT_EVENT = EVENT_ENTER -TRIGGER_SCHEMA = vol.Schema({ - vol.Required(CONF_PLATFORM): 'zone', - vol.Required(CONF_ENTITY_ID): cv.entity_ids, - vol.Required(CONF_ZONE): cv.entity_id, - vol.Required(CONF_EVENT, default=DEFAULT_EVENT): - vol.Any(EVENT_ENTER, EVENT_LEAVE), -}) +TRIGGER_SCHEMA = vol.Schema( + { + vol.Required(CONF_PLATFORM): "zone", + vol.Required(CONF_ENTITY_ID): cv.entity_ids, + vol.Required(CONF_ZONE): cv.entity_id, + vol.Required(CONF_EVENT, default=DEFAULT_EVENT): vol.Any( + EVENT_ENTER, EVENT_LEAVE + ), + } +) -async def async_trigger(hass, config, action, automation_info): +async def async_attach_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) @@ -30,30 +39,41 @@ async def async_trigger(hass, config, action, automation_info): @callback def zone_automation_listener(entity, from_s, to_s): """Listen for state changes and calls action.""" - if from_s and not location.has_location(from_s) or \ - not location.has_location(to_s): + if ( + from_s + and not location.has_location(from_s) + or not location.has_location(to_s) + ): return zone_state = hass.states.get(zone_entity_id) - if from_s: - from_match = condition.zone(hass, zone_state, from_s) - else: - from_match = False + from_match = condition.zone(hass, zone_state, from_s) if from_s else False to_match = condition.zone(hass, zone_state, 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({ - 'trigger': { - 'platform': 'zone', - 'entity_id': entity, - 'from_state': from_s, - 'to_state': 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) + 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( + { + "trigger": { + "platform": "zone", + "entity_id": entity, + "from_state": from_s, + "to_state": 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/avea/__init__.py b/homeassistant/components/avea/__init__.py new file mode 100644 index 0000000000000..861c4f655a10e --- /dev/null +++ b/homeassistant/components/avea/__init__.py @@ -0,0 +1 @@ +"""The avea component.""" diff --git a/homeassistant/components/avea/light.py b/homeassistant/components/avea/light.py new file mode 100644 index 0000000000000..8f57fb08e968d --- /dev/null +++ b/homeassistant/components/avea/light.py @@ -0,0 +1,91 @@ +"""Support for the Elgato Avea lights.""" +import logging + +import avea + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_HS_COLOR, + SUPPORT_BRIGHTNESS, + SUPPORT_COLOR, + LightEntity, +) +from homeassistant.exceptions import PlatformNotReady +import homeassistant.util.color as color_util + +_LOGGER = logging.getLogger(__name__) + +SUPPORT_AVEA = SUPPORT_BRIGHTNESS | SUPPORT_COLOR + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Avea platform.""" + try: + nearby_bulbs = avea.discover_avea_bulbs() + for bulb in nearby_bulbs: + bulb.get_name() + bulb.get_brightness() + except OSError as err: + raise PlatformNotReady from err + + add_entities(AveaLight(bulb) for bulb in nearby_bulbs) + + +class AveaLight(LightEntity): + """Representation of an Avea.""" + + def __init__(self, light): + """Initialize an AveaLight.""" + self._light = light + self._name = light.name + self._state = None + self._brightness = light.brightness + + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_AVEA + + @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.""" + if not kwargs: + self._light.set_brightness(4095) + else: + if ATTR_BRIGHTNESS in kwargs: + bright = round((kwargs[ATTR_BRIGHTNESS] / 255) * 4095) + self._light.set_brightness(bright) + if ATTR_HS_COLOR in kwargs: + rgb = color_util.color_hs_to_RGB(*kwargs[ATTR_HS_COLOR]) + self._light.set_rgb(rgb[0], rgb[1], rgb[2]) + + def turn_off(self, **kwargs): + """Instruct the light to turn off.""" + self._light.set_brightness(0) + + def update(self): + """Fetch new state data for this light. + + This is the only method that should fetch new data for Home Assistant. + """ + brightness = self._light.get_brightness() + if brightness is not None: + if brightness == 0: + self._state = False + else: + self._state = True + self._brightness = round(255 * (brightness / 4095)) diff --git a/homeassistant/components/avea/manifest.json b/homeassistant/components/avea/manifest.json new file mode 100644 index 0000000000000..729219d8f1de1 --- /dev/null +++ b/homeassistant/components/avea/manifest.json @@ -0,0 +1,7 @@ +{ + "domain": "avea", + "name": "Elgato Avea", + "documentation": "https://www.home-assistant.io/integrations/avea", + "codeowners": ["@pattyland"], + "requirements": ["avea==1.4"] +} diff --git a/homeassistant/components/avion/light.py b/homeassistant/components/avion/light.py index b138b8bf61f4b..e5281c136547d 100644 --- a/homeassistant/components/avion/light.py +++ b/homeassistant/components/avion/light.py @@ -6,38 +6,50 @@ import voluptuous as vol from homeassistant.components.light import ( - ATTR_BRIGHTNESS, PLATFORM_SCHEMA, SUPPORT_BRIGHTNESS, Light) + ATTR_BRIGHTNESS, + PLATFORM_SCHEMA, + SUPPORT_BRIGHTNESS, + LightEntity, +) from homeassistant.const import ( - CONF_API_KEY, CONF_DEVICES, CONF_ID, CONF_NAME, CONF_PASSWORD, - CONF_USERNAME) + 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, -}) +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, -}) +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') + 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]) + devices = avion.get_devices(config[CONF_USERNAME], config[CONF_PASSWORD]) for device in devices: lights.append(AvionLight(device)) @@ -47,13 +59,14 @@ def setup_platform(hass, config, add_entities, discovery_info=None): passphrase=device_config[CONF_API_KEY], name=device_config.get(CONF_NAME), object_id=device_config.get(CONF_ID), - connect=False) + connect=False, + ) lights.append(AvionLight(device)) add_entities(lights) -class AvionLight(Light): +class AvionLight(LightEntity): """Representation of an Avion light.""" def __init__(self, device): @@ -102,7 +115,7 @@ def assumed_state(self): def set_state(self, brightness): """Set the state of this lamp to the provided brightness.""" # pylint: disable=no-member - avion = importlib.import_module('avion') + 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. diff --git a/homeassistant/components/avion/manifest.json b/homeassistant/components/avion/manifest.json index e7d97f1331308..bd72cb8c06c66 100644 --- a/homeassistant/components/avion/manifest.json +++ b/homeassistant/components/avion/manifest.json @@ -1,10 +1,7 @@ { "domain": "avion", - "name": "Avion", - "documentation": "https://www.home-assistant.io/components/avion", - "requirements": [ - "avion==0.10" - ], - "dependencies": [], + "name": "Avi-on", + "documentation": "https://www.home-assistant.io/integrations/avion", + "requirements": ["avion==0.10"], "codeowners": [] } diff --git a/homeassistant/components/avri/__init__.py b/homeassistant/components/avri/__init__.py new file mode 100644 index 0000000000000..4d99b2ed0e446 --- /dev/null +++ b/homeassistant/components/avri/__init__.py @@ -0,0 +1 @@ +"""The avri component.""" diff --git a/homeassistant/components/avri/manifest.json b/homeassistant/components/avri/manifest.json new file mode 100644 index 0000000000000..41be3251b1001 --- /dev/null +++ b/homeassistant/components/avri/manifest.json @@ -0,0 +1,7 @@ +{ + "domain": "avri", + "name": "Avri", + "documentation": "https://www.home-assistant.io/integrations/avri", + "requirements": ["avri-api==0.1.7"], + "codeowners": ["@timvancann"] +} diff --git a/homeassistant/components/avri/sensor.py b/homeassistant/components/avri/sensor.py new file mode 100644 index 0000000000000..a221147f06580 --- /dev/null +++ b/homeassistant/components/avri/sensor.py @@ -0,0 +1,116 @@ +"""Support for Avri waste curbside collection pickup.""" +from datetime import timedelta +import logging + +from avri.api import Avri, AvriException +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_NAME +from homeassistant.exceptions import PlatformNotReady +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) +CONF_COUNTRY_CODE = "country_code" +CONF_ZIP_CODE = "zip_code" +CONF_HOUSE_NUMBER = "house_number" +CONF_HOUSE_NUMBER_EXTENSION = "house_number_extension" +DEFAULT_NAME = "avri" +ICON = "mdi:trash-can-outline" +SCAN_INTERVAL = timedelta(hours=4) +DEFAULT_COUNTRY_CODE = "NL" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_ZIP_CODE): cv.string, + vol.Required(CONF_HOUSE_NUMBER): cv.positive_int, + vol.Optional(CONF_HOUSE_NUMBER_EXTENSION): cv.string, + vol.Optional(CONF_COUNTRY_CODE, default=DEFAULT_COUNTRY_CODE): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Avri Waste platform.""" + client = Avri( + postal_code=config[CONF_ZIP_CODE], + house_nr=config[CONF_HOUSE_NUMBER], + house_nr_extension=config.get(CONF_HOUSE_NUMBER_EXTENSION), + country_code=config[CONF_COUNTRY_CODE], + ) + + try: + each_upcoming = client.upcoming_of_each() + except AvriException as ex: + raise PlatformNotReady from ex + else: + entities = [ + AvriWasteUpcoming(config[CONF_NAME], client, upcoming.name) + for upcoming in each_upcoming + ] + add_entities(entities, True) + + +class AvriWasteUpcoming(Entity): + """Avri Waste Sensor.""" + + def __init__(self, name: str, client: Avri, waste_type: str): + """Initialize the sensor.""" + self._waste_type = waste_type + self._name = f"{name}_{self._waste_type}" + self._state = None + self._client = client + self._state_available = False + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return ( + f"{self._waste_type}" + f"-{self._client.country_code}" + f"-{self._client.postal_code}" + f"-{self._client.house_nr}" + f"-{self._client.house_nr_extension}" + ) + + @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._state_available + + @property + def icon(self): + """Icon to use in the frontend.""" + return ICON + + def update(self): + """Update device state.""" + try: + pickup_events = self._client.upcoming_of_each() + except AvriException as ex: + _LOGGER.error( + "There was an error retrieving upcoming garbage pickups: %s", ex + ) + self._state_available = False + self._state = None + else: + self._state_available = True + matched_events = list( + filter(lambda event: event.name == self._waste_type, pickup_events) + ) + if not matched_events: + self._state = None + else: + self._state = matched_events[0].day.date() diff --git a/homeassistant/components/awair/manifest.json b/homeassistant/components/awair/manifest.json index cba11e8be1ca0..2ead58c0fe809 100644 --- a/homeassistant/components/awair/manifest.json +++ b/homeassistant/components/awair/manifest.json @@ -1,10 +1,7 @@ { "domain": "awair", "name": "Awair", - "documentation": "https://www.home-assistant.io/components/awair", - "requirements": [ - "python_awair==0.0.4" - ], - "dependencies": [], - "codeowners": [] + "documentation": "https://www.home-assistant.io/integrations/awair", + "requirements": ["python_awair==0.0.4"], + "codeowners": ["@danielsjf"] } diff --git a/homeassistant/components/awair/sensor.py b/homeassistant/components/awair/sensor.py index 85f18e87d13f8..301055c7e6194 100644 --- a/homeassistant/components/awair/sensor.py +++ b/homeassistant/components/awair/sensor.py @@ -4,11 +4,20 @@ import logging import math +from python_awair import AwairClient import voluptuous as vol from homeassistant.const import ( - CONF_ACCESS_TOKEN, CONF_DEVICES, DEVICE_CLASS_HUMIDITY, - DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS) + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_PARTS_PER_BILLION, + CONCENTRATION_PARTS_PER_MILLION, + CONF_ACCESS_TOKEN, + CONF_DEVICES, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_TEMPERATURE, + TEMP_CELSIUS, + UNIT_PERCENTAGE, +) from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv @@ -17,48 +26,64 @@ _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' +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' +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' +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'}, + "TEMP": { + "device_class": DEVICE_CLASS_TEMPERATURE, + "unit_of_measurement": TEMP_CELSIUS, + "icon": "mdi:thermometer", + }, + "HUMID": { + "device_class": DEVICE_CLASS_HUMIDITY, + "unit_of_measurement": UNIT_PERCENTAGE, + "icon": "mdi:water-percent", + }, + "CO2": { + "device_class": DEVICE_CLASS_CARBON_DIOXIDE, + "unit_of_measurement": CONCENTRATION_PARTS_PER_MILLION, + "icon": "mdi:periodic-table-co2", + }, + "VOC": { + "device_class": DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, + "unit_of_measurement": CONCENTRATION_PARTS_PER_BILLION, + "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'}, + "DUST": { + "device_class": DEVICE_CLASS_PM2_5, + "unit_of_measurement": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + "icon": "mdi:cloud", + }, + "PM25": { + "device_class": DEVICE_CLASS_PM2_5, + "unit_of_measurement": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + "icon": "mdi:cloud", + }, + "PM10": { + "device_class": DEVICE_CLASS_PM10, + "unit_of_measurement": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + "icon": "mdi:cloud", + }, + "score": { + "device_class": DEVICE_CLASS_SCORE, + "unit_of_measurement": UNIT_PERCENTAGE, + "icon": "mdi:percent", + }, } AWAIR_QUOTA = 300 @@ -67,15 +92,14 @@ # 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, -}) +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]), -}) +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, @@ -84,10 +108,8 @@ # 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): +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)) @@ -106,8 +128,7 @@ async def async_setup_platform(hass, config, async_add_entities, await awair_data.async_update() for sensor in SENSOR_TYPES: if sensor in awair_data.data: - awair_sensor = AwairSensor(awair_data, device, - sensor, throttle) + awair_sensor = AwairSensor(awair_data, device, sensor, throttle) all_devices.append(awair_sensor) async_add_entities(all_devices, True) @@ -116,8 +137,11 @@ async def async_setup_platform(hass, config, async_add_entities, _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: + except ( + AwairClient.QueryError, + AwairClient.NotFoundError, + AwairClient.GenericError, + ) as error: _LOGGER.error("Unexpected Awair API error: %s", error) raise PlatformNotReady @@ -129,9 +153,9 @@ class AwairSensor(Entity): 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._device_class = SENSOR_TYPES[sensor_type]["device_class"] + self._name = f"Awair {self._device_class}" + unit = SENSOR_TYPES[sensor_type]["unit_of_measurement"] self._unit_of_measurement = unit self._data = data self._type = sensor_type @@ -150,7 +174,7 @@ def device_class(self): @property def icon(self): """Icon to use in the frontend.""" - return SENSOR_TYPES[self._type]['icon'] + return SENSOR_TYPES[self._type]["icon"] @property def state(self): @@ -182,7 +206,7 @@ def available(self): @property def unique_id(self): """Return the unique id of this entity.""" - return "{}_{}".format(self._uuid, self._type) + return f"{self._uuid}_{self._type}" @property def unit_of_measurement(self): @@ -219,6 +243,6 @@ async def _async_update(self): # 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]] = sensor[ATTR_VALUE] + 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 index e25af68d550e4..600874b0d25d3 100644 --- a/homeassistant/components/aws/__init__.py +++ b/homeassistant/components/aws/__init__.py @@ -1,8 +1,9 @@ """Support for Amazon Web Services (AWS).""" import asyncio -import logging from collections import OrderedDict +import logging +import aiobotocore import voluptuous as vol from homeassistant import config_entries @@ -10,7 +11,7 @@ from homeassistant.helpers import config_validation as cv, discovery # Loading the config flow file will register the flow -from . import config_flow # noqa +from . import config_flow # noqa: F401 from .const import ( CONF_ACCESS_KEY_ID, CONF_CONTEXT, @@ -39,11 +40,9 @@ } ) -DEFAULT_CREDENTIAL = [{ - CONF_NAME: "default", - CONF_PROFILE_NAME: "default", - CONF_VALIDATE: False, -}] +DEFAULT_CREDENTIAL = [ + {CONF_NAME: "default", CONF_PROFILE_NAME: "default", CONF_VALIDATE: False} +] SUPPORTED_SERVICES = ["lambda", "sns", "sqs"] @@ -66,9 +65,9 @@ { DOMAIN: vol.Schema( { - vol.Optional( - CONF_CREDENTIALS, default=DEFAULT_CREDENTIAL - ): vol.All(cv.ensure_list, [AWS_CREDENTIAL_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] ), @@ -111,9 +110,7 @@ async def async_setup_entry(hass, entry): 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) - ) + hass.async_create_task(hass.config_entries.async_remove(entry.entry_id)) return False if conf != entry.data: @@ -147,9 +144,7 @@ async def async_setup_entry(hass, entry): # 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 - ) + discovery.async_load_platform(hass, "notify", DOMAIN, notify_config, config) ) return validation @@ -157,7 +152,6 @@ async def async_setup_entry(hass, entry): async def _validate_aws_credentials(hass, credential): """Validate AWS credential config.""" - import aiobotocore aws_config = credential.copy() del aws_config[CONF_NAME] @@ -166,14 +160,14 @@ async def _validate_aws_credentials(hass, credential): profile = aws_config.get(CONF_PROFILE_NAME) if profile is not None: - session = aiobotocore.AioSession(profile=profile, loop=hass.loop) + 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(loop=hass.loop) + session = aiobotocore.AioSession() if credential[CONF_VALIDATE]: async with session.create_client("iam", **aws_config) as client: diff --git a/homeassistant/components/aws/config_flow.py b/homeassistant/components/aws/config_flow.py index c21f2a94137f6..6ac332b251c7e 100644 --- a/homeassistant/components/aws/config_flow.py +++ b/homeassistant/components/aws/config_flow.py @@ -17,6 +17,4 @@ async def async_step_import(self, user_input): if self._async_current_entries(): return self.async_abort(reason="single_instance_allowed") - return self.async_create_entry( - title="configuration.yaml", data=user_input - ) + 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 index 4738547bdec37..499f4413596d3 100644 --- a/homeassistant/components/aws/const.py +++ b/homeassistant/components/aws/const.py @@ -8,7 +8,7 @@ CONF_ACCESS_KEY_ID = "aws_access_key_id" CONF_CONTEXT = "context" CONF_CREDENTIAL_NAME = "credential_name" -CONF_CREDENTIALS = 'credentials' +CONF_CREDENTIALS = "credentials" CONF_NOTIFY = "notify" CONF_PROFILE_NAME = "profile_name" CONF_REGION = "region_name" diff --git a/homeassistant/components/aws/manifest.json b/homeassistant/components/aws/manifest.json index a473a23f917ab..f6e88ce2899cd 100644 --- a/homeassistant/components/aws/manifest.json +++ b/homeassistant/components/aws/manifest.json @@ -1,13 +1,7 @@ { "domain": "aws", - "name": "Aws", - "documentation": "https://www.home-assistant.io/components/aws", - "requirements": [ - "aiobotocore==0.10.2" - ], - "dependencies": [], - "codeowners": [ - "@awarecan", - "@robbiet480" - ] + "name": "Amazon Web Services (AWS)", + "documentation": "https://www.home-assistant.io/integrations/aws", + "requirements": ["aiobotocore==0.11.1"], + "codeowners": ["@awarecan", "@robbiet480"] } diff --git a/homeassistant/components/aws/notify.py b/homeassistant/components/aws/notify.py index 3a6193f403d93..13fa189a31823 100644 --- a/homeassistant/components/aws/notify.py +++ b/homeassistant/components/aws/notify.py @@ -4,14 +4,17 @@ import json import logging +import aiobotocore + from homeassistant.components.notify import ( ATTR_TARGET, ATTR_TITLE, ATTR_TITLE_DEFAULT, BaseNotificationService, ) -from homeassistant.const import CONF_PLATFORM, CONF_NAME +from homeassistant.const import CONF_NAME, CONF_PLATFORM from homeassistant.helpers.json import JSONEncoder + from .const import ( CONF_CONTEXT, CONF_CREDENTIAL_NAME, @@ -26,25 +29,20 @@ 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 - ) + 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') + _LOGGER.error("Please config aws notify platform in aws component") return None - import aiobotocore - session = None conf = discovery_info @@ -56,7 +54,9 @@ async def async_get_service(hass, config, discovery_info=None): 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 + region_name, + service, + available_regions, ) return None @@ -76,9 +76,7 @@ async def async_get_service(hass, config, discovery_info=None): if hass.data[DATA_SESSIONS]: session = next(iter(hass.data[DATA_SESSIONS].values())) else: - _LOGGER.error( - "Missing aws credential for %s", config[CONF_NAME] - ) + _LOGGER.error("Missing aws credential for %s", config[CONF_NAME]) return None if session is None: @@ -86,18 +84,16 @@ async def async_get_service(hass, config, discovery_info=None): 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 - ) + _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, loop=hass.loop) + session = aiobotocore.AioSession(profile=profile) del aws_config[CONF_PROFILE_NAME] else: - session = aiobotocore.AioSession(loop=hass.loop) + session = aiobotocore.AioSession() aws_config[CONF_REGION] = region_name @@ -150,7 +146,7 @@ async def async_send_message(self, message="", **kwargs): json_payload = json.dumps(payload) async with self.session.create_client( - self.service, **self.aws_config + self.service, **self.aws_config ) as client: tasks = [] for target in kwargs.get(ATTR_TARGET, []): @@ -185,7 +181,7 @@ async def async_send_message(self, message="", **kwargs): subject = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) async with self.session.create_client( - self.service, **self.aws_config + self.service, **self.aws_config ) as client: tasks = [] for target in kwargs.get(ATTR_TARGET, []): @@ -225,7 +221,7 @@ async def async_send_message(self, message="", **kwargs): } async with self.session.create_client( - self.service, **self.aws_config + self.service, **self.aws_config ) as client: tasks = [] for target in kwargs.get(ATTR_TARGET, []): diff --git a/homeassistant/components/axis/.translations/ca.json b/homeassistant/components/axis/.translations/ca.json deleted file mode 100644 index 5e98dbf34189d..0000000000000 --- a/homeassistant/components/axis/.translations/ca.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "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", - "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 deleted file mode 100644 index 4657d2fb35532..0000000000000 --- a/homeassistant/components/axis/.translations/da.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "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 deleted file mode 100644 index c979068b92222..0000000000000 --- a/homeassistant/components/axis/.translations/de.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "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", - "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 deleted file mode 100644 index 6c5933dfd9726..0000000000000 --- a/homeassistant/components/axis/.translations/en.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "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", - "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 deleted file mode 100644 index 1e9301a19da68..0000000000000 --- a/homeassistant/components/axis/.translations/es-419.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "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 deleted file mode 100644 index 9229b90866fd3..0000000000000 --- a/homeassistant/components/axis/.translations/es.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "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 deleted file mode 100644 index 020cd8f5946ed..0000000000000 --- a/homeassistant/components/axis/.translations/fr.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "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/it.json b/homeassistant/components/axis/.translations/it.json deleted file mode 100644 index 2141bf34942bc..0000000000000 --- a/homeassistant/components/axis/.translations/it.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "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 deleted file mode 100644 index aafa4fc18962e..0000000000000 --- a/homeassistant/components/axis/.translations/ko.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "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", - "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 deleted file mode 100644 index 6b0728f4030d8..0000000000000 --- a/homeassistant/components/axis/.translations/lb.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "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/nn.json b/homeassistant/components/axis/.translations/nn.json deleted file mode 100644 index 3364446935953..0000000000000 --- a/homeassistant/components/axis/.translations/nn.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "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 deleted file mode 100644 index 94b5a1680b715..0000000000000 --- a/homeassistant/components/axis/.translations/no.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "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", - "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 deleted file mode 100644 index 7903dc63bf8bc..0000000000000 --- a/homeassistant/components/axis/.translations/pl.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "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", - "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/ru.json b/homeassistant/components/axis/.translations/ru.json deleted file mode 100644 index f303aa947ea8b..0000000000000 --- a/homeassistant/components/axis/.translations/ru.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "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", - "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 deleted file mode 100644 index 41d2994987333..0000000000000 --- a/homeassistant/components/axis/.translations/sl.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "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 deleted file mode 100644 index 435a56632e82e..0000000000000 --- a/homeassistant/components/axis/.translations/sv.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Enheten \u00e4r redan konfigurerad", - "bad_config_file": "Felaktig data fr\u00e5n config fil" - }, - "error": { - "already_configured": "Enheten \u00e4r redan konfigurerad", - "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/zh-Hant.json b/homeassistant/components/axis/.translations/zh-Hant.json deleted file mode 100644 index ac9f3ceb2b696..0000000000000 --- a/homeassistant/components/axis/.translations/zh-Hant.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "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", - "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 index e9e8a158a3be3..5294e30ed6f54 100644 --- a/homeassistant/components/axis/__init__.py +++ b/homeassistant/components/axis/__init__.py @@ -1,36 +1,26 @@ """Support for Axis devices.""" -import voluptuous as vol +import logging -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 + CONF_DEVICE, + CONF_HOST, + CONF_MAC, + CONF_PASSWORD, + CONF_PORT, + CONF_TRIGGER_TIME, + CONF_USERNAME, + EVENT_HOMEASSISTANT_STOP, +) -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) +LOGGER = logging.getLogger(__name__) 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 - )) - + """Old way to set up Axis devices.""" return True @@ -47,7 +37,13 @@ async def async_setup_entry(hass, config_entry): if not await device.async_setup(): return False - hass.data[DOMAIN][device.serial] = device + # 0.104 introduced config entry unique id, this makes upgrading possible + if config_entry.unique_id is None: + hass.config_entries.async_update_entry( + config_entry, unique_id=device.api.vapix.params.system_serialnumber + ) + + hass.data[DOMAIN][config_entry.unique_id] = device await device.async_update_device_registry() @@ -64,7 +60,13 @@ async def async_unload_entry(hass, config_entry): async def async_populate_options(hass, config_entry): """Populate default options for device.""" - device = await get_device(hass, config_entry.data[CONF_DEVICE]) + device = await get_device( + hass, + host=config_entry.data[CONF_HOST], + port=config_entry.data[CONF_PORT], + username=config_entry.data[CONF_USERNAME], + password=config_entry.data[CONF_PASSWORD], + ) supported_formats = device.vapix.params.image_format camera = bool(supported_formats) @@ -72,7 +74,22 @@ async def async_populate_options(hass, config_entry): options = { CONF_CAMERA: camera, CONF_EVENTS: True, - CONF_TRIGGER_TIME: DEFAULT_TRIGGER_TIME + CONF_TRIGGER_TIME: DEFAULT_TRIGGER_TIME, } hass.config_entries.async_update_entry(config_entry, options=options) + + +async def async_migrate_entry(hass, config_entry): + """Migrate old entry.""" + LOGGER.debug("Migrating from version %s", config_entry.version) + + # Flatten configuration but keep old data if user rollbacks HASS + if config_entry.version == 1: + config_entry.data = {**config_entry.data, **config_entry.data[CONF_DEVICE]} + + config_entry.version = 2 + + LOGGER.info("Migration to version %s successful", config_entry.version) + + return True diff --git a/homeassistant/components/axis/axis_base.py b/homeassistant/components/axis/axis_base.py new file mode 100644 index 0000000000000..2e848168b4929 --- /dev/null +++ b/homeassistant/components/axis/axis_base.py @@ -0,0 +1,79 @@ +"""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 + + async def async_added_to_hass(self): + """Subscribe device events.""" + self.async_on_remove( + async_dispatcher_connect( + self.hass, self.device.event_reachable, self.update_callback + ) + ) + + @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_write_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 f"{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 f"{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 index e9ef9f6371062..4709d706ad076 100644 --- a/homeassistant/components/axis/binary_sensor.py +++ b/homeassistant/components/axis/binary_sensor.py @@ -2,51 +2,43 @@ from datetime import timedelta -from homeassistant.components.binary_sensor import BinarySensorDevice -from homeassistant.const import CONF_MAC, CONF_TRIGGER_TIME +from axis.event_stream import CLASS_INPUT, CLASS_OUTPUT + +from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.const import 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 .const import DOMAIN as AXIS_DOMAIN, LOGGER +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] + device = hass.data[AXIS_DOMAIN][config_entry.unique_id] @callback def async_add_sensor(event_id): """Add binary sensor from Axis device.""" event = device.api.event.events[event_id] - async_add_entities([AxisBinarySensor(event, device)], True) - device.listeners.append(async_dispatcher_connect( - hass, device.event_new_sensor, async_add_sensor)) + 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(BinarySensorDevice): +class AxisBinarySensor(AxisEventBase, BinarySensorEntity): """Representation of a binary Axis event.""" def __init__(self, event, device): """Initialize the Axis binary sensor.""" - self.event = event - self.device = device + super().__init__(event, device) self.remove_timer = None - self.unsub_dispatcher = None - - async def async_added_to_hass(self): - """Subscribe sensors events.""" - self.event.register_callback(self.update_callback) - self.unsub_dispatcher = async_dispatcher_connect( - self.hass, self.device.event_reachable, self.update_callback) - - async def async_will_remove_from_hass(self) -> None: - """Disconnect device object when removed.""" - self.event.remove_callback(self.update_callback) - self.unsub_dispatcher() @callback def update_callback(self, no_delay=False): @@ -61,19 +53,18 @@ def update_callback(self, no_delay=False): self.remove_timer = None if self.is_on or delay == 0 or no_delay: - self.async_schedule_update_ha_state() + self.async_write_ha_state() return @callback def _delay_update(now): """Timer callback for sensor update.""" - LOGGER.debug("%s called delayed (%s sec) update", self.name, delay) - self.async_schedule_update_ha_state() + self.async_write_ha_state() self.remove_timer = None self.remove_timer = async_track_point_in_utc_time( - self.hass, _delay_update, - utcnow() + timedelta(seconds=delay)) + self.hass, _delay_update, utcnow() + timedelta(seconds=delay) + ) @property def is_on(self): @@ -83,32 +74,13 @@ def is_on(self): @property def name(self): """Return the name of the event.""" - return '{} {} {}'.format( - self.device.name, self.event.TYPE, self.event.id) - - @property - def device_class(self): - """Return the class of the event.""" - return self.event.CLASS - - @property - def unique_id(self): - """Return a unique identifier for this device.""" - return '{}-{}-{}'.format( - self.device.serial, self.event.topic, self.event.id) - - def available(self): - """Return True if device is available.""" - return self.device.available - - @property - def should_poll(self): - """No polling needed.""" - return False - - @property - def device_info(self): - """Return a device description for device registry.""" - return { - 'identifiers': {(AXIS_DOMAIN, self.device.serial)} - } + if ( + self.event.CLASS == CLASS_INPUT + and self.event.id + and self.device.api.vapix.ports[self.event.id].name + ): + return ( + f"{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 index 457cc23e73ddf..ca76552a4ccdc 100644 --- a/homeassistant/components/axis/camera.py +++ b/homeassistant/components/axis/camera.py @@ -2,101 +2,89 @@ from homeassistant.components.camera import SUPPORT_STREAM from homeassistant.components.mjpeg.camera import ( - CONF_MJPEG_URL, CONF_STILL_IMAGE_URL, MjpegCamera, filter_urllib3_logging) + 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.core import callback + CONF_AUTHENTICATION, + CONF_HOST, + 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' +AXIS_IMAGE = "http://{host}:{port}/axis-cgi/jpg/image.cgi" +AXIS_VIDEO = "http://{host}:{port}/axis-cgi/mjpg/video.cgi" +AXIS_STREAM = "rtsp://{user}:{password}@{host}/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] + device = hass.data[AXIS_DOMAIN][config_entry.unique_id] 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_USERNAME: config_entry.data[CONF_USERNAME], + CONF_PASSWORD: config_entry.data[CONF_PASSWORD], CONF_MJPEG_URL: AXIS_VIDEO.format( - config_entry.data[CONF_DEVICE][CONF_HOST], - config_entry.data[CONF_DEVICE][CONF_PORT]), + host=config_entry.data[CONF_HOST], port=config_entry.data[CONF_PORT], + ), CONF_STILL_IMAGE_URL: AXIS_IMAGE.format( - config_entry.data[CONF_DEVICE][CONF_HOST], - config_entry.data[CONF_DEVICE][CONF_PORT]), + host=config_entry.data[CONF_HOST], port=config_entry.data[CONF_PORT], + ), CONF_AUTHENTICATION: HTTP_DIGEST_AUTHENTICATION, } async_add_entities([AxisCamera(config, device)]) -class AxisCamera(MjpegCamera): +class AxisCamera(AxisEntityBase, MjpegCamera): """Representation of a Axis camera.""" def __init__(self, config, device): """Initialize Axis Communications camera component.""" - super().__init__(config) - self.device_config = config - self.device = device - self.port = device.config_entry.data[CONF_DEVICE][CONF_PORT] - self.unsub_dispatcher = [] + 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)) - self.unsub_dispatcher.append(async_dispatcher_connect( - self.hass, self.device.event_reachable, self.update_callback)) + self.async_on_remove( + async_dispatcher_connect( + self.hass, self.device.event_new_address, self._new_address + ) + ) - async def async_will_remove_from_hass(self) -> None: - """Disconnect device object when removed.""" - for unsub_dispatcher in self.unsub_dispatcher: - unsub_dispatcher() + await super().async_added_to_hass() @property def supported_features(self): """Return supported features.""" return SUPPORT_STREAM - @property - def stream_source(self): + 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) - - @callback - def update_callback(self, no_delay=None): - """Update the cameras state.""" - self.async_schedule_update_ha_state() - - @property - def available(self): - """Return True if device is available.""" - return self.device.available + user=self.device.config_entry.data[CONF_USERNAME], + password=self.device.config_entry.data[CONF_PASSWORD], + host=self.device.host, + ) def _new_address(self): """Set new device address for video stream.""" - self._mjpeg_url = AXIS_VIDEO.format(self.device.host, self.port) - self._still_image_url = AXIS_IMAGE.format(self.device.host, self.port) + port = self.device.config_entry.data[CONF_PORT] + self._mjpeg_url = AXIS_VIDEO.format(host=self.device.host, port=port) + self._still_image_url = AXIS_IMAGE.format(host=self.device.host, port=port) @property def unique_id(self): """Return a unique identifier for this device.""" - return '{}-camera'.format(self.device.serial) - - @property - def device_info(self): - """Return a device description for device registry.""" - return { - 'identifiers': {(AXIS_DOMAIN, self.device.serial)} - } + return f"{self.device.serial}-camera" diff --git a/homeassistant/components/axis/config_flow.py b/homeassistant/components/axis/config_flow.py index 0c175de20c76b..37141d6017ae9 100644 --- a/homeassistant/components/axis/config_flow.py +++ b/homeassistant/components/axis/config_flow.py @@ -1,63 +1,46 @@ """Config flow to configure Axis devices.""" +from ipaddress import ip_address + 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 + CONF_HOST, + CONF_MAC, + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, +) +from homeassistant.util.network import is_link_local from .const import CONF_MODEL, DOMAIN from .device import get_device -from .errors import AlreadyConfigured, AuthenticationRequired, CannotConnect +from .errors import AuthenticationRequired, CannotConnect + +AXIS_OUI = {"00408C", "ACCC8E", "B8A44F"} -CONFIG_FILE = 'axis.conf' +CONFIG_FILE = "axis.conf" -EVENT_TYPES = ['motion', 'vmd3', 'pir', 'sound', - 'daynight', 'tampering', 'input'] +EVENT_TYPES = ["motion", "vmd3", "pir", "sound", "daynight", "tampering", "input"] -PLATFORMS = ['camera'] +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): +class AxisFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle a Axis config flow.""" - VERSION = 1 + VERSION = 2 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 = {} @@ -70,44 +53,53 @@ async def async_step_user(self, user_input=None): if user_input is not None: try: + device = await get_device( + self.hass, + host=user_input[CONF_HOST], + port=user_input[CONF_PORT], + username=user_input[CONF_USERNAME], + password=user_input[CONF_PASSWORD], + ) + + serial_number = device.vapix.params.system_serialnumber + await self.async_set_unique_id(serial_number) + + self._abort_if_unique_id_configured( + updates={ + CONF_HOST: user_input[CONF_HOST], + CONF_PORT: user_input[CONF_PORT], + } + ) + 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] + CONF_PASSWORD: user_input[CONF_PASSWORD], + CONF_MAC: serial_number, + CONF_MODEL: device.vapix.params.prodnbr, } - 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' + errors["base"] = "faulty_credentials" except CannotConnect: - errors['base'] = 'device_unavailable' + errors["base"] = "device_unavailable" - data = self.import_schema or self.discovery_schema or { + data = 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 + vol.Required(CONF_PORT, default=DEFAULT_PORT): int, } return self.async_show_form( - step_id='user', + step_id="user", description_placeholders=self.device_config, data_schema=vol.Schema(data), - errors=errors + errors=errors, ) async def _create_entry(self): @@ -115,95 +107,54 @@ async def _create_entry(self): 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, - } + model = self.device_config[CONF_MODEL] + same_model = [ + entry.data[CONF_NAME] + for entry in self.hass.config_entries.async_entries(DOMAIN) + if entry.data[CONF_MODEL] == 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) + name = model + for idx in range(len(same_model) + 1): + name = f"{model} {idx}" + if name not in same_model: + break - async def async_step_discovery(self, discovery_info): - """Prepare configuration for a discovered Axis device. + self.device_config[CONF_NAME] = name - This flow is triggered by the discovery component. - """ - if discovery_info[CONF_HOST].startswith('169.254'): - return self.async_abort(reason='link_local_address') - - serialnumber = discovery_info['properties']['macaddress'] - 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() + title = f"{model} - {self.device_config[CONF_MAC]}" + return self.async_create_entry(title=title, data=self.device_config) - try: - device_config = DEVICE_SCHEMA(config_file[serialnumber]) - device_config[CONF_HOST] = discovery_info[CONF_HOST] + async def async_step_zeroconf(self, discovery_info): + """Prepare configuration for a discovered Axis device.""" + serial_number = discovery_info["properties"]["macaddress"] - if CONF_NAME not in device_config: - device_config[CONF_NAME] = discovery_info['hostname'] + if serial_number[:6] not in AXIS_OUI: + return self.async_abort(reason="not_axis_device") - except vol.Invalid: - return self.async_abort(reason='bad_config_file') + if is_link_local(ip_address(discovery_info[CONF_HOST])): + return self.async_abort(reason="link_local_address") - return await self.async_step_import(device_config) + await self.async_set_unique_id(serial_number) - async def async_step_import(self, import_config): - """Import a Axis device as a config entry. + self._abort_if_unique_id_configured( + updates={ + CONF_HOST: discovery_info[CONF_HOST], + CONF_PORT: discovery_info[CONF_PORT], + } + ) - This flow is triggered by `async_setup` for configured devices. - This flow is also triggered by `async_step_discovery`. + # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 + self.context["title_placeholders"] = { + CONF_NAME: discovery_info["hostname"][:-7], + CONF_HOST: discovery_info[CONF_HOST], + } - 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 + 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(user_input=import_config) + + return await self.async_step_user() diff --git a/homeassistant/components/axis/const.py b/homeassistant/components/axis/const.py index 5e7307085911b..7f0fd9c8947e7 100644 --- a/homeassistant/components/axis/const.py +++ b/homeassistant/components/axis/const.py @@ -3,10 +3,10 @@ LOGGER = logging.getLogger(__package__) -DOMAIN = 'axis' +DOMAIN = "axis" -CONF_CAMERA = 'camera' -CONF_EVENTS = 'events' -CONF_MODEL = 'model' +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 index 1595dde4cba9a..a204136e018c6 100644 --- a/homeassistant/components/axis/device.py +++ b/homeassistant/components/axis/device.py @@ -1,18 +1,24 @@ """Axis network device abstraction.""" import asyncio + import async_timeout +import axis +from axis.streammanager import SIGNAL_PLAYING from homeassistant.const import ( - CONF_DEVICE, CONF_HOST, CONF_MAC, CONF_NAME, CONF_PASSWORD, CONF_PORT, - CONF_USERNAME) + CONF_HOST, + 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 @@ -34,7 +40,7 @@ def __init__(self, hass, config_entry): @property def host(self): """Return the host of this device.""" - return self.config_entry.data[CONF_DEVICE][CONF_HOST] + return self.config_entry.data[CONF_HOST] @property def model(self): @@ -48,54 +54,65 @@ def name(self): @property def serial(self): - """Return the mac of this device.""" - return self.config_entry.data[CONF_MAC] + """Return the serial number of this device.""" + return self.config_entry.unique_id async def async_update_device_registry(self): """Update device registry.""" - device_registry = await \ - self.hass.helpers.device_registry.async_get_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), + manufacturer="Axis Communications AB", + model=f"{self.model} {self.product_type}", name=self.name, - sw_version=self.fw_version + 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]) + self.hass, + host=self.config_entry.data[CONF_HOST], + port=self.config_entry.data[CONF_PORT], + username=self.config_entry.data[CONF_USERNAME], + password=self.config_entry.data[CONF_PASSWORD], + ) except CannotConnect: raise ConfigEntryNotReady except Exception: # pylint: disable=broad-except - LOGGER.error( - 'Unknown error connecting with Axis device on %s', self.host) + 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')) + self.config_entry, "camera" + ) + ) if self.config_entry.options[CONF_EVENTS]: - task = self.hass.async_create_task( - self.hass.config_entries.async_forward_entry_setup( - self.config_entry, 'binary_sensor')) - self.api.stream.connection_status_callback = \ + self.api.stream.connection_status_callback = ( self.async_connection_status_callback + ) self.api.enable_events(event_callback=self.async_event_callback) - task.add_done_callback(self.start) + + 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) @@ -104,7 +121,7 @@ async def async_setup(self): @property def event_new_address(self): """Device specific event to signal new device address.""" - return 'axis_new_address_{}'.format(self.serial) + return f"axis_new_address_{self.serial}" @staticmethod async def async_new_address_callback(hass, entry): @@ -113,14 +130,14 @@ async def async_new_address_callback(hass, entry): 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 = hass.data[DOMAIN][entry.unique_id] 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) + return f"axis_reachable_{self.serial}" @callback def async_connection_status_callback(self, status): @@ -129,7 +146,7 @@ def async_connection_status_callback(self, 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) @@ -137,17 +154,17 @@ def async_connection_status_callback(self, status): @property def event_new_sensor(self): """Device specific event to signal new sensor available.""" - return 'axis_add_sensor_{}'.format(self.serial) + return f"axis_add_sensor_{self.serial}" @callback def async_event_callback(self, action, event_id): """Call to configure events when initialized on event stream.""" - if action == 'add': + if action == "add": async_dispatcher_send(self.hass, self.event_new_sensor, event_id) - @callback - def start(self, fut): - """Start the event stream.""" + async def start(self, platform_tasks): + """Start the event stream when all platforms are loaded.""" + await asyncio.gather(*platform_tasks) self.api.start() @callback @@ -157,15 +174,25 @@ def shutdown(self, event): async def async_reset(self): """Reset this device to default state.""" - self.api.stop() + platform_tasks = [] if self.config_entry.options[CONF_CAMERA]: - await self.hass.config_entries.async_forward_entry_unload( - self.config_entry, 'camera') + platform_tasks.append( + self.hass.config_entries.async_forward_entry_unload( + self.config_entry, "camera" + ) + ) if self.config_entry.options[CONF_EVENTS]: - await self.hass.config_entries.async_forward_entry_unload( - self.config_entry, 'binary_sensor') + 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() @@ -174,36 +201,40 @@ async def async_reset(self): return True -async def get_device(hass, config): +async def get_device(hass, host, port, username, password): """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') + loop=hass.loop, + host=host, + port=port, + username=username, + password=password, + web_proto="http", + ) device.vapix.initialize_params(preload_data=False) + device.vapix.initialize_ports() try: with async_timeout.timeout(15): - await hass.async_add_executor_job( - device.vapix.params.update_brand) - await hass.async_add_executor_job( - device.vapix.params.update_properties) + + 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]) + LOGGER.warning("Connected to device at %s but not registered.", host) raise AuthenticationRequired except (asyncio.TimeoutError, axis.RequestError): - LOGGER.error("Error connecting to the Axis device at %s", - config[CONF_HOST]) + LOGGER.error("Error connecting to the Axis device at %s", host) raise CannotConnect except axis.AxisException: - LOGGER.exception('Unknown Axis communication error occurred') + LOGGER.exception("Unknown Axis communication error occurred") raise AuthenticationRequired diff --git a/homeassistant/components/axis/manifest.json b/homeassistant/components/axis/manifest.json index f87718bfddda8..6e8899c79d65b 100644 --- a/homeassistant/components/axis/manifest.json +++ b/homeassistant/components/axis/manifest.json @@ -1,8 +1,9 @@ { "domain": "axis", "name": "Axis", - "documentation": "https://www.home-assistant.io/components/axis", - "requirements": ["axis==22"], - "dependencies": [], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/axis", + "requirements": ["axis==25"], + "zeroconf": ["_axis-video._tcp.local."], "codeowners": ["@kane610"] } diff --git a/homeassistant/components/axis/strings.json b/homeassistant/components/axis/strings.json index 3c528dfbb1611..67a3bb0a49ea2 100644 --- a/homeassistant/components/axis/strings.json +++ b/homeassistant/components/axis/strings.json @@ -1,26 +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", - "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" + "config": { + "flow_title": "Axis device: {name} ({host})", + "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 configuration file", + "link_local_address": "Link local addresses are not supported", + "not_axis_device": "Discovered device not an Axis device" } -} \ No newline at end of file + } +} diff --git a/homeassistant/components/axis/switch.py b/homeassistant/components/axis/switch.py new file mode 100644 index 0000000000000..be048a510edda --- /dev/null +++ b/homeassistant/components/axis/switch.py @@ -0,0 +1,60 @@ +"""Support for Axis switches.""" + +from axis.event_stream import CLASS_OUTPUT + +from homeassistant.components.switch import SwitchEntity +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.""" + device = hass.data[AXIS_DOMAIN][config_entry.unique_id] + + @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, SwitchEntity): + """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 ( + f"{self.device.name} {self.device.api.vapix.ports[self.event.id].name}" + ) + + return super().name diff --git a/homeassistant/components/axis/translations/bg.json b/homeassistant/components/axis/translations/bg.json new file mode 100644 index 0000000000000..83fbfa0118c2d --- /dev/null +++ b/homeassistant/components/axis/translations/bg.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e", + "bad_config_file": "\u041b\u043e\u0448\u0438 \u0434\u0430\u043d\u043d\u0438 \u043e\u0442 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u043e\u043d\u043d\u0438\u044f \u0444\u0430\u0439\u043b", + "link_local_address": "\u041b\u043e\u043a\u0430\u043b\u043d\u0438 \u0430\u0434\u0440\u0435\u0441\u0438 \u043d\u0435 \u0441\u0435 \u043f\u043e\u0434\u0434\u044a\u0440\u0436\u0430\u0442", + "not_axis_device": "\u041e\u0442\u043a\u0440\u0438\u0442\u043e\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0435 \u0435 Axis" + }, + "error": { + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e", + "already_in_progress": "\u0412 \u043c\u043e\u043c\u0435\u043d\u0442\u0430 \u0442\u0435\u0447\u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u0435 \u043d\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e.", + "device_unavailable": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u043d\u0435 \u0435 \u043d\u0430\u043b\u0438\u0447\u043d\u043e", + "faulty_credentials": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u0438 \u0438\u0434\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u043e\u043d\u043d\u0438 \u0434\u0430\u043d\u043d\u0438" + }, + "flow_title": "Axis \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e: {name} ({host})", + "step": { + "user": { + "data": { + "host": "\u0410\u0434\u0440\u0435\u0441", + "password": "\u041f\u0430\u0440\u043e\u043b\u0430", + "port": "\u041f\u043e\u0440\u0442", + "username": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435" + }, + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0432\u0430\u043d\u0435 \u043d\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043e\u0442 Axis" + } + } + }, + "title": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e Axis" +} \ No newline at end of file diff --git a/homeassistant/components/axis/translations/ca.json b/homeassistant/components/axis/translations/ca.json new file mode 100644 index 0000000000000..5ca4c474bc9ca --- /dev/null +++ b/homeassistant/components/axis/translations/ca.json @@ -0,0 +1,29 @@ +{ + "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", + "not_axis_device": "El dispositiu descobert no \u00e9s un dispositiu Axis" + }, + "error": { + "already_configured": "El dispositiu ja est\u00e0 configurat", + "already_in_progress": "El flux de dades de configuraci\u00f3 pel dispositiu ja est\u00e0 en curs.", + "device_unavailable": "El dispositiu no est\u00e0 disponible", + "faulty_credentials": "Credencials d'usuari incorrectes" + }, + "flow_title": "Dispositiu d'eix: {name} ({host})", + "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/cs.json b/homeassistant/components/axis/translations/cs.json new file mode 100644 index 0000000000000..258f301e43290 --- /dev/null +++ b/homeassistant/components/axis/translations/cs.json @@ -0,0 +1,5 @@ +{ + "config": { + "flow_title": "Za\u0159\u00edzen\u00ed Axis: {name} ({host})" + } +} \ 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..78e4e200082ce --- /dev/null +++ b/homeassistant/components/axis/translations/da.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "Enheden er allerede konfigureret", + "bad_config_file": "Forkerte data fra konfigurationsfilen", + "link_local_address": "Link lokale adresser underst\u00f8ttes ikke", + "not_axis_device": "Fundet enhed ikke en Axis enhed" + }, + "error": { + "already_configured": "Enheden er allerede konfigureret", + "already_in_progress": "Enhedskonfiguration er allerede i gang.", + "device_unavailable": "Enheden er ikke tilg\u00e6ngelig", + "faulty_credentials": "Ugyldige legitimationsoplysninger" + }, + "flow_title": "Axis-enhed: {name} ({host})", + "step": { + "user": { + "data": { + "host": "V\u00e6rt", + "password": "Adgangskode", + "port": "Port", + "username": "Brugernavn" + }, + "title": "Indstil 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..7410a79bdc90d --- /dev/null +++ b/homeassistant/components/axis/translations/de.json @@ -0,0 +1,29 @@ +{ + "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", + "not_axis_device": "Erkanntes Ger\u00e4t ist kein Axis-Ger\u00e4t" + }, + "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" + }, + "flow_title": "Achsenger\u00e4t: {name} ({host})", + "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..cc76571b01b04 --- /dev/null +++ b/homeassistant/components/axis/translations/en.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured", + "bad_config_file": "Bad data from configuration file", + "link_local_address": "Link local addresses are not supported", + "not_axis_device": "Discovered device not an Axis device" + }, + "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" + }, + "flow_title": "Axis device: {name} ({host})", + "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..a86131723e3ae --- /dev/null +++ b/homeassistant/components/axis/translations/es-419.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositivo ya est\u00e1 configurado", + "bad_config_file": "Datos err\u00f3neos del archivo de configuraci\u00f3n", + "link_local_address": "Las direcciones locales de enlace no son compatibles", + "not_axis_device": "El dispositivo descubierto no es un dispositivo de Axis" + }, + "error": { + "already_configured": "El dispositivo ya est\u00e1 configurado", + "already_in_progress": "El flujo de configuraci\u00f3n para el dispositivo ya est\u00e1 en progreso.", + "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" + }, + "title": "Configurar dispositivo Axis" + } + } + }, + "title": "Dispositivo Axis" +} \ 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..19a774fe17060 --- /dev/null +++ b/homeassistant/components/axis/translations/es.json @@ -0,0 +1,29 @@ +{ + "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", + "not_axis_device": "El dispositivo descubierto no es un dispositivo de Axis" + }, + "error": { + "already_configured": "El dispositivo ya est\u00e1 configurado", + "already_in_progress": "El flujo de configuraci\u00f3n del dispositivo ya est\u00e1 en curso.", + "device_unavailable": "El dispositivo no est\u00e1 disponible", + "faulty_credentials": "Credenciales de usuario incorrectas" + }, + "flow_title": "Dispositivo Axis: {name} ({host})", + "step": { + "user": { + "data": { + "host": "Host", + "password": "Contrase\u00f1a", + "port": "Puerto", + "username": "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..4fb4f92876861 --- /dev/null +++ b/homeassistant/components/axis/translations/fr.json @@ -0,0 +1,29 @@ +{ + "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", + "not_axis_device": "L'appareil d\u00e9couvert n'est pas un appareil Axis" + }, + "error": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "already_in_progress": "Le flux de configuration de l'appareil est d\u00e9j\u00e0 en cours.", + "device_unavailable": "L'appareil n'est pas disponible", + "faulty_credentials": "Mauvaises informations d'identification de l'utilisateur" + }, + "flow_title": "Appareil Axis: {name} ( {host} )", + "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..ac8538c694378 --- /dev/null +++ b/homeassistant/components/axis/translations/hu.json @@ -0,0 +1,21 @@ +{ + "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" + }, + "flow_title": "Axis eszk\u00f6z: {name} ({host})", + "step": { + "user": { + "data": { + "host": "Hoszt", + "password": "Jelsz\u00f3", + "port": "Port", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + } + } + } + }, + "title": "Axis eszk\u00f6z" +} \ 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..e3083687b0791 --- /dev/null +++ b/homeassistant/components/axis/translations/it.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", + "bad_config_file": "Dati errati dal file di configurazione", + "link_local_address": "Gli indirizzi locali di collegamento non sono supportati", + "not_axis_device": "Il dispositivo rilevato non \u00e8 un dispositivo Axis" + }, + "error": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", + "already_in_progress": "Il flusso di configurazione per il dispositivo \u00e8 gi\u00e0 in corso.", + "device_unavailable": "Il dispositivo non \u00e8 disponibile", + "faulty_credentials": "Credenziali utente non valide" + }, + "flow_title": "Dispositivo Axis: {name} ({host})", + "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..b336a290f9e27 --- /dev/null +++ b/homeassistant/components/axis/translations/ko.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "bad_config_file": "\uad6c\uc131 \ud30c\uc77c\uc5d0 \uc798\ubabb\ub41c \ub370\uc774\ud130\uac00 \uc788\uc2b5\ub2c8\ub2e4", + "link_local_address": "\ub85c\uceec \uc8fc\uc18c \uc5f0\uacb0\uc740 \uc9c0\uc6d0\ub418\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4", + "not_axis_device": "\ubc1c\uacac\ub41c \uae30\uae30\ub294 Axis \uae30\uae30\uac00 \uc544\ub2d9\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" + }, + "flow_title": "Axis \uae30\uae30: {name} ({host})", + "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\ud558\uae30" + } + } + }, + "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..c19c50381fc11 --- /dev/null +++ b/homeassistant/components/axis/translations/lb.json @@ -0,0 +1,29 @@ +{ + "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", + "not_axis_device": "Entdeckten Apparat ass keen Axis Apparat" + }, + "error": { + "already_configured": "Apparat ass scho konfigur\u00e9iert", + "already_in_progress": "Konfiguratioun fir d\u00ebsen Apparat ass schonn am gaang.", + "device_unavailable": "Apparat ass net erreechbar", + "faulty_credentials": "Ong\u00eblteg Login Informatioune" + }, + "flow_title": "Axis Apparat: {name} ({host})", + "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..852933c6204e0 --- /dev/null +++ b/homeassistant/components/axis/translations/nl.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd", + "bad_config_file": "Slechte gegevens van het configuratiebestand", + "link_local_address": "Link-lokale adressen worden niet ondersteund", + "not_axis_device": "Ontdekte apparaat, is geen Axis-apparaat" + }, + "error": { + "already_configured": "Apparaat is al geconfigureerd", + "already_in_progress": "De configuratiestroom voor het apparaat is al in volle gang.", + "device_unavailable": "Apparaat is niet beschikbaar", + "faulty_credentials": "Ongeldige gebruikersreferenties" + }, + "flow_title": "Axis apparaat: {naam} ({host})", + "step": { + "user": { + "data": { + "host": "Host", + "password": "Wachtwoord", + "port": "Poort", + "username": "Gebruikersnaam" + }, + "title": "Stel het Axis-apparaat in" + } + } + }, + "title": "Axis-apparaat" +} \ 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..b6296d1acab70 --- /dev/null +++ b/homeassistant/components/axis/translations/nn.json @@ -0,0 +1,14 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "Vert", + "password": "Passord", + "port": "Port", + "username": "Brukarnamn" + } + } + } + } +} \ 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..fb04b498d70f8 --- /dev/null +++ b/homeassistant/components/axis/translations/no.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert", + "bad_config_file": "D\u00e5rlige data fra konfigurasjonsfilen", + "link_local_address": "Linking av lokale adresser st\u00f8ttes ikke", + "not_axis_device": "Oppdaget enhet ikke en Axis enhet" + }, + "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" + }, + "flow_title": "Akse-enhet: {Name} ({Host})", + "step": { + "user": { + "data": { + "host": "Vert", + "password": "Passord", + "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..7473c62f66859 --- /dev/null +++ b/homeassistant/components/axis/translations/pl.json @@ -0,0 +1,29 @@ +{ + "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", + "not_axis_device": "Wykryte urz\u0105dzenie nie jest urz\u0105dzeniem Axis" + }, + "error": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane.", + "already_in_progress": "Konfiguracja urz\u0105dzenia jest ju\u017c w toku.", + "device_unavailable": "Urz\u0105dzenie jest niedost\u0119pne", + "faulty_credentials": "B\u0142\u0119dne dane uwierzytelniaj\u0105ce" + }, + "flow_title": "Urz\u0105dzenie Axis: {name} ({host})", + "step": { + "user": { + "data": { + "host": "Nazwa hosta lub adres IP", + "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..10e9fb563ceaf --- /dev/null +++ b/homeassistant/components/axis/translations/pt-BR.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "O dispositivo j\u00e1 est\u00e1 configurado", + "bad_config_file": "Dados incorretos do arquivo de configura\u00e7\u00e3o", + "link_local_address": "Link de endere\u00e7os locais n\u00e3o s\u00e3o suportados", + "not_axis_device": "Dispositivo descoberto n\u00e3o \u00e9 um dispositivo Axis" + }, + "error": { + "already_configured": "O dispositivo j\u00e1 est\u00e1 configurado", + "already_in_progress": "O fluxo de configura\u00e7\u00e3o para o dispositivo j\u00e1 est\u00e1 em andamento.", + "device_unavailable": "O dispositivo n\u00e3o est\u00e1 dispon\u00edvel", + "faulty_credentials": "Credenciais do usu\u00e1rio inv\u00e1lidas" + }, + "flow_title": "Eixos do dispositivo: {name} ({host})", + "step": { + "user": { + "data": { + "host": "Host", + "password": "Senha", + "port": "Porta", + "username": "Nome de usu\u00e1rio" + }, + "title": "Configurar o dispositivo Axis" + } + } + }, + "title": "Dispositivo Axis" +} \ 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..2dc5a14249f63 --- /dev/null +++ b/homeassistant/components/axis/translations/pt.json @@ -0,0 +1,15 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "Servidor", + "password": "Palavra-passe", + "port": "Porta", + "username": "Nome de Utilizador" + } + } + } + }, + "title": "Dispositivo Axis" +} \ 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..578e642d4d2f4 --- /dev/null +++ b/homeassistant/components/axis/translations/ru.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u044d\u0442\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\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.", + "not_axis_device": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0435 \u044f\u0432\u043b\u044f\u0435\u0442\u0441\u044f \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u043c Axis." + }, + "error": { + "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u044d\u0442\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\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 \u044d\u0442\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u044f\u0435\u0442\u0441\u044f.", + "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\u0451\u0442\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435." + }, + "flow_title": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e Axis {name} ({host})", + "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..a41e5ddd652f8 --- /dev/null +++ b/homeassistant/components/axis/translations/sl.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "Naprava je \u017ee konfigurirana", + "bad_config_file": "Slabi podatki iz konfiguracijske datoteke", + "link_local_address": "Lokalni naslovi povezave niso podprti", + "not_axis_device": "Odkrita naprava ni naprava Axis" + }, + "error": { + "already_configured": "Naprava je \u017ee konfigurirana", + "already_in_progress": "Konfiguracijski tok za to napravo je \u017ee v teku.", + "device_unavailable": "Naprava ni na voljo", + "faulty_credentials": "Napa\u010dni uporabni\u0161ki podatki" + }, + "flow_title": "OS naprava: {Name} ({Host})", + "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..c208838ed1a5e --- /dev/null +++ b/homeassistant/components/axis/translations/sv.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten \u00e4r redan konfigurerad", + "bad_config_file": "Felaktig data fr\u00e5n konfigurationsfilen", + "link_local_address": "Link local addresses are not supported", + "not_axis_device": "Uppt\u00e4ckte enhet som inte \u00e4r en Axis enhet" + }, + "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" + }, + "flow_title": "Axisenhet: {name} ({host})", + "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 similarity index 100% rename from homeassistant/components/axis/.translations/th.json rename to homeassistant/components/axis/translations/th.json diff --git a/homeassistant/components/axis/translations/zh-Hans.json b/homeassistant/components/axis/translations/zh-Hans.json new file mode 100644 index 0000000000000..f7f6c8259ced1 --- /dev/null +++ b/homeassistant/components/axis/translations/zh-Hans.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "\u5bc6\u7801", + "port": "\u7aef\u53e3", + "username": "\u7528\u6237\u540d" + } + } + } + } +} \ 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..e24c29a86bc5d --- /dev/null +++ b/homeassistant/components/axis/translations/zh-Hant.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "\u8a2d\u5099\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "bad_config_file": "\u8a2d\u5b9a\u6a94\u6848\u8cc7\u6599\u7121\u6548\u932f\u8aa4", + "link_local_address": "\u4e0d\u652f\u63f4\u9023\u7d50\u672c\u5730\u7aef\u4f4d\u5740", + "not_axis_device": "\u6240\u767c\u73fe\u7684\u8a2d\u5099\u4e26\u975e Axis \u8a2d\u5099" + }, + "error": { + "already_configured": "\u8a2d\u5099\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "already_in_progress": "\u8a2d\u5099\u8a2d\u5b9a\u5df2\u7d93\u9032\u884c\u4e2d\u3002", + "device_unavailable": "\u8a2d\u5099\u7121\u6cd5\u4f7f\u7528", + "faulty_credentials": "\u4f7f\u7528\u8005\u6191\u8b49\u7121\u6548" + }, + "flow_title": "Axis \u8a2d\u5099\uff1a{name} ({host})", + "step": { + "user": { + "data": { + "host": "\u4e3b\u6a5f\u7aef", + "password": "\u5bc6\u78bc", + "port": "\u901a\u8a0a\u57e0", + "username": "\u4f7f\u7528\u8005\u540d\u7a31" + }, + "title": "\u8a2d\u5b9a Axis \u8a2d\u5099" + } + } + }, + "title": "Axis \u8a2d\u5099" +} \ No newline at end of file diff --git a/homeassistant/components/azure_event_hub/__init__.py b/homeassistant/components/azure_event_hub/__init__.py new file mode 100644 index 0000000000000..cc59790b6464f --- /dev/null +++ b/homeassistant/components/azure_event_hub/__init__.py @@ -0,0 +1,89 @@ +"""Support for Azure Event Hubs.""" +import json +import logging +from typing import Any, Dict + +from azure.eventhub import EventData, EventHubClientAsync +import voluptuous as vol + +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 = ( + f"amqps://{config[CONF_EVENT_HUB_NAMESPACE]}" + f".servicebus.windows.net/{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..f9d4cf09e0488 --- /dev/null +++ b/homeassistant/components/azure_event_hub/manifest.json @@ -0,0 +1,7 @@ +{ + "domain": "azure_event_hub", + "name": "Azure Event Hub", + "documentation": "https://www.home-assistant.io/integrations/azure_event_hub", + "requirements": ["azure-eventhub==1.3.1"], + "codeowners": ["@eavanvalkenburg"] +} diff --git a/homeassistant/components/azure_service_bus/__init__.py b/homeassistant/components/azure_service_bus/__init__.py new file mode 100644 index 0000000000000..f18dc9eb66c95 --- /dev/null +++ b/homeassistant/components/azure_service_bus/__init__.py @@ -0,0 +1 @@ +"""The Azure Service Bus integration.""" diff --git a/homeassistant/components/azure_service_bus/manifest.json b/homeassistant/components/azure_service_bus/manifest.json new file mode 100644 index 0000000000000..9e3f0e956e586 --- /dev/null +++ b/homeassistant/components/azure_service_bus/manifest.json @@ -0,0 +1,7 @@ +{ + "domain": "azure_service_bus", + "name": "Azure Service Bus", + "documentation": "https://www.home-assistant.io/integrations/azure_service_bus", + "requirements": ["azure-servicebus==0.50.1"], + "codeowners": ["@hfurubotten"] +} diff --git a/homeassistant/components/azure_service_bus/notify.py b/homeassistant/components/azure_service_bus/notify.py new file mode 100644 index 0000000000000..e7c85adede8f2 --- /dev/null +++ b/homeassistant/components/azure_service_bus/notify.py @@ -0,0 +1,106 @@ +"""Support for azure service bus notification.""" +import json +import logging + +from azure.servicebus.aio import Message, ServiceBusClient +from azure.servicebus.common.errors import ( + MessageSendFailed, + ServiceBusConnectionError, + ServiceBusResourceNotFound, +) +import voluptuous as vol + +from homeassistant.components.notify import ( + ATTR_DATA, + ATTR_TARGET, + ATTR_TITLE, + PLATFORM_SCHEMA, + BaseNotificationService, +) +from homeassistant.const import CONTENT_TYPE_JSON +import homeassistant.helpers.config_validation as cv + +CONF_CONNECTION_STRING = "connection_string" +CONF_QUEUE_NAME = "queue" +CONF_TOPIC_NAME = "topic" + +ATTR_ASB_MESSAGE = "message" +ATTR_ASB_TITLE = "title" +ATTR_ASB_TARGET = "target" + +PLATFORM_SCHEMA = vol.All( + cv.has_at_least_one_key(CONF_QUEUE_NAME, CONF_TOPIC_NAME), + PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_CONNECTION_STRING): cv.string, + vol.Exclusive( + CONF_QUEUE_NAME, "output", "Can only send to a queue or a topic." + ): cv.string, + vol.Exclusive( + CONF_TOPIC_NAME, "output", "Can only send to a queue or a topic." + ): cv.string, + } + ), +) + +_LOGGER = logging.getLogger(__name__) + + +def get_service(hass, config, discovery_info=None): + """Get the notification service.""" + connection_string = config[CONF_CONNECTION_STRING] + queue_name = config.get(CONF_QUEUE_NAME) + topic_name = config.get(CONF_TOPIC_NAME) + + # Library can do synchronous IO when creating the clients. + # Passes in loop here, but can't run setup on the event loop. + servicebus = ServiceBusClient.from_connection_string( + connection_string, loop=hass.loop + ) + + try: + if queue_name: + client = servicebus.get_queue(queue_name) + else: + client = servicebus.get_topic(topic_name) + except (ServiceBusConnectionError, ServiceBusResourceNotFound) as err: + _LOGGER.error( + "Connection error while creating client for queue/topic '%s'. %s", + queue_name or topic_name, + err, + ) + return None + + return ServiceBusNotificationService(client) + + +class ServiceBusNotificationService(BaseNotificationService): + """Implement the notification service for the service bus service.""" + + def __init__(self, client): + """Initialize the service.""" + self._client = client + + async def async_send_message(self, message, **kwargs): + """Send a message.""" + dto = {ATTR_ASB_MESSAGE: message} + + if ATTR_TITLE in kwargs: + dto[ATTR_ASB_TITLE] = kwargs[ATTR_TITLE] + if ATTR_TARGET in kwargs: + dto[ATTR_ASB_TARGET] = kwargs[ATTR_TARGET] + + data = kwargs.get(ATTR_DATA) + if data: + dto.update(data) + + queue_message = Message(json.dumps(dto)) + queue_message.properties.content_type = CONTENT_TYPE_JSON + try: + await self._client.send(queue_message) + except MessageSendFailed as err: + _LOGGER.error( + "Could not send service bus notification to %s. %s", + self._client.name, + err, + ) diff --git a/homeassistant/components/baidu/manifest.json b/homeassistant/components/baidu/manifest.json index 1dea1b7e37b14..88443e8672224 100644 --- a/homeassistant/components/baidu/manifest.json +++ b/homeassistant/components/baidu/manifest.json @@ -1,10 +1,7 @@ { "domain": "baidu", "name": "Baidu", - "documentation": "https://www.home-assistant.io/components/baidu", - "requirements": [ - "baidu-aip==1.6.6" - ], - "dependencies": [], + "documentation": "https://www.home-assistant.io/integrations/baidu", + "requirements": ["baidu-aip==1.6.6"], "codeowners": [] } diff --git a/homeassistant/components/baidu/tts.py b/homeassistant/components/baidu/tts.py index faf62e92651e5..2d0857de135e3 100644 --- a/homeassistant/components/baidu/tts.py +++ b/homeassistant/components/baidu/tts.py @@ -1,6 +1,7 @@ """Support for Baidu speech service.""" import logging +from aip import AipSpeech import voluptuous as vol from homeassistant.components.tts import CONF_LANG, PLATFORM_SCHEMA, Provider @@ -9,52 +10,49 @@ _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) - ), -}) +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', + CONF_PERSON: "per", + CONF_PITCH: "pit", + CONF_SPEED: "spd", + CONF_VOLUME: "vol", } -SUPPORTED_OPTIONS = [ - CONF_PERSON, - CONF_PITCH, - CONF_SPEED, - CONF_VOLUME, -] +SUPPORTED_OPTIONS = [CONF_PERSON, CONF_PITCH, CONF_SPEED, CONF_VOLUME] -def get_engine(hass, config): +def get_engine(hass, config, discovery_info=None): """Set up Baidu TTS component.""" return BaiduTTSProvider(hass, config) @@ -65,21 +63,21 @@ class BaiduTTSProvider(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._lang = conf[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), + "appid": conf[CONF_APP_ID], + "apikey": conf[CONF_API_KEY], + "secretkey": conf[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), + _OPTIONS[CONF_PERSON]: conf[CONF_PERSON], + _OPTIONS[CONF_PITCH]: conf[CONF_PITCH], + _OPTIONS[CONF_SPEED]: conf[CONF_SPEED], + _OPTIONS[CONF_VOLUME]: conf[CONF_VOLUME], } @property @@ -109,32 +107,28 @@ def supported_options(self): 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'] + 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 - ) + 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 - ) + 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'] + result["err_no"], + result["err_msg"], + result["err_detail"], ) return None, None diff --git a/homeassistant/components/bayesian/binary_sensor.py b/homeassistant/components/bayesian/binary_sensor.py index 6b2395ef6d2a4..c4150131901ea 100644 --- a/homeassistant/components/bayesian/binary_sensor.py +++ b/homeassistant/components/bayesian/binary_sensor.py @@ -3,96 +3,120 @@ import voluptuous as vol -from homeassistant.components.binary_sensor import ( - PLATFORM_SCHEMA, BinarySensorDevice) +from homeassistant.components.binary_sensor import PLATFORM_SCHEMA, BinarySensorEntity 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) + 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' +ATTR_OBSERVATIONS = "observations" +ATTR_OCCURRED_OBSERVATION_ENTITIES = "occurred_observation_entities" +ATTR_PROBABILITY = "probability" +ATTR_PROBABILITY_THRESHOLD = "probability_threshold" -CONF_OBSERVATIONS = 'observations' -CONF_PRIOR = 'prior' +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' +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): +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_given_true, prob_given_false): """Update probability using Bayes' rule.""" - numerator = prob_true * prior - denominator = numerator + prob_false * (1 - prior) + numerator = prob_given_true * prior + denominator = numerator + prob_given_false * (1 - prior) probability = numerator / denominator return probability -async def async_setup_platform(hass, config, async_add_entities, - discovery_info=None): +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) + name = config[CONF_NAME] + observations = config[CONF_OBSERVATIONS] + prior = config[CONF_PRIOR] + probability_threshold = config[CONF_PROBABILITY_THRESHOLD] device_class = config.get(CONF_DEVICE_CLASS) - async_add_entities([ - BayesianBinarySensor( - name, prior, observations, probability_threshold, device_class) - ], True) + async_add_entities( + [ + BayesianBinarySensor( + name, prior, observations, probability_threshold, device_class + ) + ], + True, + ) -class BayesianBinarySensor(BinarySensorDevice): +class BayesianBinarySensor(BinarySensorEntity): """Representation of a Bayesian sensor.""" - def __init__(self, name, prior, observations, probability_threshold, - device_class): + def __init__(self, name, prior, observations, probability_threshold, device_class): """Initialize the Bayesian sensor.""" self._name = name self._observations = observations @@ -102,102 +126,154 @@ def __init__(self, name, prior, observations, probability_threshold, self.prior = prior self.probability = prior - self.current_obs = OrderedDict({}) + self.current_observations = 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, []) + self.observations_by_entity = self._build_observations_by_entity() - 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 + self.observation_handlers = { + "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.""" + """ + Call when entity about to be added. + + All relevant update logic for instance attributes occurs within this closure. + Other methods in this class are designed to avoid directly modifying instance + attributes, by instead focusing on returning relevant data back to this method. + + The goal of this method is to ensure that `self.current_observations` and `self.probability` + are set on a best-effort basis when this entity is register with hass. + + In addition, this method must register the state listener defined within, which + will be called any time a relevant entity changes its state. + """ + @callback - def async_threshold_sensor_state_listener(entity, old_state, - new_state): - """Handle sensor state changes.""" + def async_threshold_sensor_state_listener(entity, _old_state, new_state): + """ + Handle sensor state changes. + + When a state changes, we must update our list of current observations, + then calculate the new probability. + """ if new_state.state == STATE_UNKNOWN: return - entity_obs_list = self.entity_obs[entity] + self.current_observations.update(self._record_entity_observations(entity)) + self.probability = self._calculate_new_probability() + + self.hass.async_add_job(self.async_update_ha_state, True) + + self.current_observations.update(self._initialize_current_observations()) + self.probability = self._calculate_new_probability() + async_track_state_change( + self.hass, + self.observations_by_entity, + async_threshold_sensor_state_listener, + ) + + def _initialize_current_observations(self): + local_observations = OrderedDict({}) + for entity in self.observations_by_entity: + local_observations.update(self._record_entity_observations(entity)) + return local_observations + + def _record_entity_observations(self, entity): + local_observations = OrderedDict({}) + entity_obs_list = self.observations_by_entity[entity] + + for entity_obs in entity_obs_list: + platform = entity_obs["platform"] - for entity_obs in entity_obs_list: - platform = entity_obs['platform'] + should_trigger = self.observation_handlers[platform](entity_obs) - self.watchers[platform](entity_obs) + if should_trigger: + obs_entry = {"entity_id": entity, **entity_obs} + else: + obs_entry = None - prior = self.prior - for obs in self.current_obs.values(): + local_observations[entity_obs["id"]] = obs_entry + + return local_observations + + def _calculate_new_probability(self): + prior = self.prior + + for obs in self.current_observations.values(): + if obs is not None: prior = update_probability( - prior, obs['prob_true'], obs['prob_false']) - self.probability = prior + prior, + obs["prob_given_true"], + obs.get("prob_given_false", 1 - obs["prob_given_true"]), + ) - self.hass.async_add_job(self.async_update_ha_state, True) + return prior - async_track_state_change( - self.hass, self.entity_obs, async_threshold_sensor_state_listener) + def _build_observations_by_entity(self): + """ + Build and return data structure of the form below. - def _update_current_obs(self, entity_observation, should_trigger): - """Update current observation.""" - obs_id = entity_observation['id'] + { + "sensor.sensor1": [{"id": 0, ...}, {"id": 1, ...}], + "sensor.sensor2": [{"id": 2, ...}], + ... + } - if should_trigger: - prob_true = entity_observation['prob_given_true'] - prob_false = entity_observation.get( - 'prob_given_false', 1 - prob_true) + Each "observation" must be recognized uniquely, and it should be possible + for all relevant observations to be looked up via their `entity_id`. + """ - self.current_obs[obs_id] = { - 'prob_true': prob_true, - 'prob_false': prob_false - } + observations_by_entity = {} + for ind, obs in enumerate(self._observations): + obs["id"] = ind - else: - self.current_obs.pop(obs_id, None) + if "entity_id" in obs: + entity_ids = [obs["entity_id"]] + elif "value_template" in obs: + entity_ids = obs.get(CONF_VALUE_TEMPLATE).extract_entities() + + for e_id in entity_ids: + obs_list = observations_by_entity.get(e_id, []) + obs_list.append(obs) + observations_by_entity[e_id] = obs_list + + return observations_by_entity def _process_numeric_state(self, entity_observation): - """Add entity to current_obs if numeric state conditions are met.""" - entity = entity_observation['entity_id'] + """Return True if numeric condition is 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) + self.hass, + entity, + entity_observation.get("below"), + entity_observation.get("above"), + None, + entity_observation, + ) + return should_trigger def _process_state(self, entity_observation): - """Add entity to current observations if state conditions are met.""" - entity = entity_observation['entity_id'] + """Return True if state conditions are met.""" + entity = entity_observation["entity_id"] should_trigger = condition.state( - self.hass, entity, entity_observation.get('to_state')) + self.hass, entity, entity_observation.get("to_state") + ) - self._update_current_obs(entity_observation, should_trigger) + return should_trigger def _process_template(self, entity_observation): - """Add entity to current_obs if template is true.""" + """Return True if template condition 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) + self.hass, template, entity_observation + ) + return should_trigger @property def name(self): @@ -222,8 +298,23 @@ def device_class(self): @property def device_state_attributes(self): """Return the state attributes of the sensor.""" + + attr_observations_list = list( + obs.copy() for obs in self.current_observations.values() if obs is not None + ) + + for item in attr_observations_list: + item.pop("value_template", None) + return { - ATTR_OBSERVATIONS: [val for val in self.current_obs.values()], + ATTR_OBSERVATIONS: attr_observations_list, + ATTR_OCCURRED_OBSERVATION_ENTITIES: list( + { + obs.get("entity_id") + for obs in self.current_observations.values() + if obs is not None + } + ), ATTR_PROBABILITY: round(self.probability, 2), ATTR_PROBABILITY_THRESHOLD: self._probability_threshold, } diff --git a/homeassistant/components/bayesian/manifest.json b/homeassistant/components/bayesian/manifest.json index 25480ac8bdc87..ca62e91f09ee5 100644 --- a/homeassistant/components/bayesian/manifest.json +++ b/homeassistant/components/bayesian/manifest.json @@ -1,8 +1,7 @@ { "domain": "bayesian", "name": "Bayesian", - "documentation": "https://www.home-assistant.io/components/bayesian", - "requirements": [], - "dependencies": [], - "codeowners": [] + "documentation": "https://www.home-assistant.io/integrations/bayesian", + "codeowners": [], + "quality_scale": "internal" } diff --git a/homeassistant/components/bbb_gpio/__init__.py b/homeassistant/components/bbb_gpio/__init__.py index 85ea5753739bb..30a4bacc4da3d 100644 --- a/homeassistant/components/bbb_gpio/__init__.py +++ b/homeassistant/components/bbb_gpio/__init__.py @@ -1,25 +1,25 @@ """Support for controlling GPIO pins of a Beaglebone Black.""" import logging -from homeassistant.const import ( - EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) +from Adafruit_BBIO import GPIO # pylint: disable=import-error + +from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP _LOGGER = logging.getLogger(__name__) -DOMAIN = 'bbb_gpio' +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.""" + """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) @@ -28,37 +28,29 @@ def prepare_gpio(event): 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) + + 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) + + 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 index bcc45a4af3202..229f7a6c61e04 100644 --- a/homeassistant/components/bbb_gpio/binary_sensor.py +++ b/homeassistant/components/bbb_gpio/binary_sensor.py @@ -4,39 +4,38 @@ 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) +from homeassistant.components.binary_sensor import PLATFORM_SCHEMA, BinarySensorEntity +from homeassistant.const import CONF_NAME, DEVICE_DEFAULT_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' +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' +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']) -}) +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}), -}) +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) + pins = config[CONF_PINS] binary_sensors = [] @@ -45,16 +44,16 @@ def setup_platform(hass, config, add_entities, discovery_info=None): add_entities(binary_sensors) -class BBBGPIOBinarySensor(BinarySensorDevice): +class BBBGPIOBinarySensor(BinarySensorEntity): """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) + self._name = params[CONF_NAME] or DEVICE_DEFAULT_NAME + self._bouncetime = params[CONF_BOUNCETIME] + self._pull_mode = params[CONF_PULL_MODE] + self._invert_logic = params[CONF_INVERT_LOGIC] bbb_gpio.setup_input(self._pin, self._pull_mode) self._state = bbb_gpio.read_input(self._pin) diff --git a/homeassistant/components/bbb_gpio/manifest.json b/homeassistant/components/bbb_gpio/manifest.json index 5632836bfbb62..201c01fa70994 100644 --- a/homeassistant/components/bbb_gpio/manifest.json +++ b/homeassistant/components/bbb_gpio/manifest.json @@ -1,10 +1,7 @@ { "domain": "bbb_gpio", - "name": "Bbb gpio", - "documentation": "https://www.home-assistant.io/components/bbb_gpio", - "requirements": [ - "Adafruit_BBIO==1.0.0" - ], - "dependencies": [], + "name": "BeagleBone Black GPIO", + "documentation": "https://www.home-assistant.io/integrations/bbb_gpio", + "requirements": ["Adafruit_BBIO==1.1.1"], "codeowners": [] } diff --git a/homeassistant/components/bbb_gpio/switch.py b/homeassistant/components/bbb_gpio/switch.py index 49b4c5de19cc0..cc776c21f9ee9 100644 --- a/homeassistant/components/bbb_gpio/switch.py +++ b/homeassistant/components/bbb_gpio/switch.py @@ -3,32 +3,34 @@ 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 +from homeassistant.components.switch import PLATFORM_SCHEMA +from homeassistant.const import CONF_NAME, DEVICE_DEFAULT_NAME import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import ToggleEntity _LOGGER = logging.getLogger(__name__) -CONF_PINS = 'pins' -CONF_INITIAL = 'initial' -CONF_INVERT_LOGIC = 'invert_logic' +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, -}) +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}), -}) +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) + pins = config[CONF_PINS] switches = [] for pin, params in pins.items(): @@ -42,9 +44,9 @@ class BBBGPIOSwitch(ToggleEntity): 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) + self._name = params[CONF_NAME] or DEVICE_DEFAULT_NAME + self._state = params[CONF_INITIAL] + self._invert_logic = params[CONF_INVERT_LOGIC] bbb_gpio.setup_output(self._pin) diff --git a/homeassistant/components/bbox/device_tracker.py b/homeassistant/components/bbox/device_tracker.py index f70969aa61bc9..8097c11eb89b9 100644 --- a/homeassistant/components/bbox/device_tracker.py +++ b/homeassistant/components/bbox/device_tracker.py @@ -2,11 +2,16 @@ from collections import namedtuple from datetime import timedelta import logging +from typing import List +import pybbox import voluptuous as vol from homeassistant.components.device_tracker import ( - DOMAIN, PLATFORM_SCHEMA, DeviceScanner) + DOMAIN, + PLATFORM_SCHEMA, + DeviceScanner, +) from homeassistant.const import CONF_HOST import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle @@ -14,13 +19,13 @@ _LOGGER = logging.getLogger(__name__) -DEFAULT_HOST = '192.168.1.254' +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, -}) +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + {vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string} +) def get_scanner(hass, config): @@ -30,7 +35,7 @@ def get_scanner(hass, config): return scanner if scanner.success_init else None -Device = namedtuple('Device', ['mac', 'name', 'ip', 'last_update']) +Device = namedtuple("Device", ["mac", "name", "ip", "last_update"]) class BboxDeviceScanner(DeviceScanner): @@ -38,12 +43,11 @@ class BboxDeviceScanner(DeviceScanner): 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.last_results: List[Device] = [] self.success_init = self._update_info() _LOGGER.info("Scanner initialized") @@ -56,8 +60,9 @@ def scan_devices(self): 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] + filter_named = [ + result.name for result in self.last_results if result.mac == device + ] if filter_named: return filter_named[0] @@ -71,19 +76,19 @@ def _update_info(self): """ _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: + if device["active"] != 1: continue last_results.append( - Device(device['macaddress'], device['hostname'], - device['ipaddress'], now)) + Device( + device["macaddress"], device["hostname"], device["ipaddress"], now + ) + ) self.last_results = last_results diff --git a/homeassistant/components/bbox/manifest.json b/homeassistant/components/bbox/manifest.json index 54cd9a3af64dd..bdace6c35f5a0 100644 --- a/homeassistant/components/bbox/manifest.json +++ b/homeassistant/components/bbox/manifest.json @@ -1,10 +1,7 @@ { "domain": "bbox", "name": "Bbox", - "documentation": "https://www.home-assistant.io/components/bbox", - "requirements": [ - "pybbox==0.0.5-alpha" - ], - "dependencies": [], + "documentation": "https://www.home-assistant.io/integrations/bbox", + "requirements": ["pybbox==0.0.5-alpha"], "codeowners": [] } diff --git a/homeassistant/components/bbox/sensor.py b/homeassistant/components/bbox/sensor.py index 80fa82b30fc7f..13c8f5bb03f09 100644 --- a/homeassistant/components/bbox/sensor.py +++ b/homeassistant/components/bbox/sensor.py @@ -1,44 +1,66 @@ """Support for Bbox Bouygues Modem Router.""" -import logging from datetime import timedelta +import logging +import pybbox 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) + ATTR_ATTRIBUTION, + CONF_MONITORED_VARIABLES, + CONF_NAME, + DATA_RATE_MEGABITS_PER_SECOND, + DEVICE_CLASS_TIMESTAMP, +) +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle +from homeassistant.util.dt import utcnow _LOGGER = logging.getLogger(__name__) -BANDWIDTH_MEGABITS_SECONDS = 'Mb/s' # type: str - ATTRIBUTION = "Powered by Bouygues Telecom" -DEFAULT_NAME = 'Bbox' +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'], + "down_max_bandwidth": [ + "Maximum Download Bandwidth", + DATA_RATE_MEGABITS_PER_SECOND, + "mdi:download", + ], + "up_max_bandwidth": [ + "Maximum Upload Bandwidth", + DATA_RATE_MEGABITS_PER_SECOND, + "mdi:upload", + ], + "current_down_bandwidth": [ + "Currently Used Download Bandwidth", + DATA_RATE_MEGABITS_PER_SECOND, + "mdi:download", + ], + "current_up_bandwidth": [ + "Currently Used Upload Bandwidth", + DATA_RATE_MEGABITS_PER_SECOND, + "mdi:upload", + ], + "uptime": ["Uptime", None, "mdi:clock"], + "number_of_reboots": ["Number of reboot", None, "mdi:restart"], } -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, -}) +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): @@ -52,15 +74,65 @@ def setup_platform(hass, config, add_entities, discovery_info=None): _LOGGER.error(error) return False - name = config.get(CONF_NAME) + name = config[CONF_NAME] sensors = [] for variable in config[CONF_MONITORED_VARIABLES]: - sensors.append(BboxSensor(bbox_data, variable, name)) + if variable == "uptime": + sensors.append(BboxUptimeSensor(bbox_data, variable, name)) + else: + sensors.append(BboxSensor(bbox_data, variable, name)) add_entities(sensors, True) +class BboxUptimeSensor(Entity): + """Bbox uptime 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 f"{self.client_name} {self._name}" + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return self._icon + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return {ATTR_ATTRIBUTION: ATTRIBUTION} + + @property + def device_class(self): + """Return the class of this sensor.""" + return DEVICE_CLASS_TIMESTAMP + + def update(self): + """Get the latest data from Bbox and update the state.""" + self.bbox_data.update() + uptime = utcnow() - timedelta( + seconds=self.bbox_data.router_infos["device"]["uptime"] + ) + self._state = uptime.replace(microsecond=0).isoformat() + + class BboxSensor(Entity): """Implementation of a Bbox sensor.""" @@ -77,7 +149,7 @@ def __init__(self, bbox_data, sensor_type, name): @property def name(self): """Return the name of the sensor.""" - return '{} {}'.format(self.client_name, self._name) + return f"{self.client_name} {self._name}" @property def state(self): @@ -97,25 +169,21 @@ def icon(self): @property def device_state_attributes(self): """Return the state attributes.""" - return { - ATTR_ATTRIBUTION: ATTRIBUTION, - } + 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) + 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) + elif self.type == "number_of_reboots": + self._state = self.bbox_data.router_infos["device"]["numberofboots"] class BboxData: @@ -124,16 +192,18 @@ class BboxData: def __init__(self): """Initialize the data object.""" self.data = None + self.router_infos = 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() + self.router_infos = box.get_bbox_info() except requests.exceptions.HTTPError as error: _LOGGER.error(error) self.data = None + self.router_infos = None return False diff --git a/homeassistant/components/beewi_smartclim/__init__.py b/homeassistant/components/beewi_smartclim/__init__.py new file mode 100644 index 0000000000000..f907ce95ae639 --- /dev/null +++ b/homeassistant/components/beewi_smartclim/__init__.py @@ -0,0 +1 @@ +"""The beewi_smartclim component.""" diff --git a/homeassistant/components/beewi_smartclim/manifest.json b/homeassistant/components/beewi_smartclim/manifest.json new file mode 100644 index 0000000000000..169132515d26f --- /dev/null +++ b/homeassistant/components/beewi_smartclim/manifest.json @@ -0,0 +1,7 @@ +{ + "domain": "beewi_smartclim", + "name": "BeeWi SmartClim BLE sensor", + "documentation": "https://www.home-assistant.io/integrations/beewi_smartclim", + "requirements": ["beewi_smartclim==0.0.7"], + "codeowners": ["@alemuro"] +} diff --git a/homeassistant/components/beewi_smartclim/sensor.py b/homeassistant/components/beewi_smartclim/sensor.py new file mode 100644 index 0000000000000..ec124b249718d --- /dev/null +++ b/homeassistant/components/beewi_smartclim/sensor.py @@ -0,0 +1,109 @@ +"""Platform for beewi_smartclim integration.""" +import logging + +from beewi_smartclim import BeewiSmartClimPoller +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_MAC, + CONF_NAME, + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_TEMPERATURE, + TEMP_CELSIUS, + UNIT_PERCENTAGE, +) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +# Default values +DEFAULT_NAME = "BeeWi SmartClim" + +# Sensor config +SENSOR_TYPES = [ + [DEVICE_CLASS_TEMPERATURE, "Temperature", TEMP_CELSIUS], + [DEVICE_CLASS_HUMIDITY, "Humidity", UNIT_PERCENTAGE], + [DEVICE_CLASS_BATTERY, "Battery", UNIT_PERCENTAGE], +] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_MAC): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the beewi_smartclim platform.""" + + mac = config[CONF_MAC] + prefix = config[CONF_NAME] + poller = BeewiSmartClimPoller(mac) + + sensors = [] + + for sensor_type in SENSOR_TYPES: + device = sensor_type[0] + name = sensor_type[1] + unit = sensor_type[2] + # `prefix` is the name configured by the user for the sensor, we're appending + # the device type at the end of the name (garden -> garden temperature) + if prefix: + name = f"{prefix} {name}" + + sensors.append(BeewiSmartclimSensor(poller, name, mac, device, unit)) + + add_entities(sensors) + + +class BeewiSmartclimSensor(Entity): + """Representation of a Sensor.""" + + def __init__(self, poller, name, mac, device, unit): + """Initialize the sensor.""" + self._poller = poller + self._name = name + self._mac = mac + self._device = device + self._unit = unit + 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. State is returned in Celsius.""" + return self._state + + @property + def device_class(self): + """Device class of this entity.""" + return self._device + + @property + def unique_id(self): + """Return a unique, Home Assistant friendly identifier for this entity.""" + return f"{self._mac}_{self._device}" + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self._unit + + def update(self): + """Fetch new state data from the poller.""" + self._poller.update_sensor() + self._state = None + if self._device == DEVICE_CLASS_TEMPERATURE: + self._state = self._poller.get_temperature() + if self._device == DEVICE_CLASS_HUMIDITY: + self._state = self._poller.get_humidity() + if self._device == DEVICE_CLASS_BATTERY: + self._state = self._poller.get_battery() diff --git a/homeassistant/components/bh1750/manifest.json b/homeassistant/components/bh1750/manifest.json index 90e62c783569c..e8473910abdf2 100644 --- a/homeassistant/components/bh1750/manifest.json +++ b/homeassistant/components/bh1750/manifest.json @@ -1,11 +1,7 @@ { "domain": "bh1750", - "name": "Bh1750", - "documentation": "https://www.home-assistant.io/components/bh1750", - "requirements": [ - "i2csense==0.0.4", - "smbus-cffi==0.5.1" - ], - "dependencies": [], + "name": "BH1750", + "documentation": "https://www.home-assistant.io/integrations/bh1750", + "requirements": ["i2csense==0.0.4", "smbus-cffi==0.5.1"], "codeowners": [] } diff --git a/homeassistant/components/bh1750/sensor.py b/homeassistant/components/bh1750/sensor.py index eaee023ce8616..df8e87f751d17 100644 --- a/homeassistant/components/bh1750/sensor.py +++ b/homeassistant/components/bh1750/sensor.py @@ -2,21 +2,23 @@ from functools import partial import logging +from i2csense.bh1750 import BH1750 # pylint: disable=import-error +import smbus # pylint: disable=import-error 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 +import homeassistant.helpers.config_validation as cv 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' +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. @@ -29,61 +31,66 @@ 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. + 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' +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): +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.0): 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) + name = config[CONF_NAME] + bus_number = config[CONF_I2C_BUS] + i2c_address = config[CONF_I2C_ADDRESS] + operation_mode = config[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) + partial( + BH1750, + bus, + i2c_address, + operation_mode=operation_mode, + measurement_delay=config[CONF_DELAY], + sensitivity=config[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) + dev = [BH1750Sensor(sensor, name, SENSOR_UNIT, config[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) @@ -91,7 +98,7 @@ async def async_setup_platform(hass, config, async_add_entities, class BH1750Sensor(Entity): """Implementation of the BH1750 sensor.""" - def __init__(self, bh1750_sensor, name, unit, multiplier=1.): + def __init__(self, bh1750_sensor, name, unit, multiplier=1.0): """Initialize the sensor.""" self._name = name self._unit_of_measurement = unit @@ -125,10 +132,9 @@ def device_class(self) -> str: 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)) + 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) + _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 19054588ee7dd..f022509f9dea5 100644 --- a/homeassistant/components/binary_sensor/__init__.py +++ b/homeassistant/components/binary_sensor/__init__.py @@ -5,88 +5,98 @@ import voluptuous as vol -from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.helpers.config_validation import ( # noqa: F401 + PLATFORM_SCHEMA, + PLATFORM_SCHEMA_BASE, +) from homeassistant.helpers.entity import Entity -from homeassistant.const import (STATE_ON, STATE_OFF) -from homeassistant.helpers.config_validation import ( # noqa - PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE) +from homeassistant.helpers.entity_component import EntityComponent + +# mypy: allow-untyped-defs, no-check-untyped-defs + +_LOGGER = logging.getLogger(__name__) -DOMAIN = 'binary_sensor' +DOMAIN = "binary_sensor" SCAN_INTERVAL = timedelta(seconds=30) -ENTITY_ID_FORMAT = DOMAIN + '.{}' +ENTITY_ID_FORMAT = DOMAIN + ".{}" # On means low, Off means normal -DEVICE_CLASS_BATTERY = 'battery' +DEVICE_CLASS_BATTERY = "battery" + +# On means charging, Off means not charging +DEVICE_CLASS_BATTERY_CHARGING = "battery_charging" # On means cold, Off means normal -DEVICE_CLASS_COLD = 'cold' +DEVICE_CLASS_COLD = "cold" # On means connected, Off means disconnected -DEVICE_CLASS_CONNECTIVITY = 'connectivity' +DEVICE_CLASS_CONNECTIVITY = "connectivity" # On means open, Off means closed -DEVICE_CLASS_DOOR = 'door' +DEVICE_CLASS_DOOR = "door" # On means open, Off means closed -DEVICE_CLASS_GARAGE_DOOR = 'garage_door' +DEVICE_CLASS_GARAGE_DOOR = "garage_door" # On means gas detected, Off means no gas (clear) -DEVICE_CLASS_GAS = 'gas' +DEVICE_CLASS_GAS = "gas" # On means hot, Off means normal -DEVICE_CLASS_HEAT = 'heat' +DEVICE_CLASS_HEAT = "heat" # On means light detected, Off means no light -DEVICE_CLASS_LIGHT = 'light' +DEVICE_CLASS_LIGHT = "light" # On means open (unlocked), Off means closed (locked) -DEVICE_CLASS_LOCK = 'lock' +DEVICE_CLASS_LOCK = "lock" # On means wet, Off means dry -DEVICE_CLASS_MOISTURE = 'moisture' +DEVICE_CLASS_MOISTURE = "moisture" # On means motion detected, Off means no motion (clear) -DEVICE_CLASS_MOTION = 'motion' +DEVICE_CLASS_MOTION = "motion" # On means moving, Off means not moving (stopped) -DEVICE_CLASS_MOVING = 'moving' +DEVICE_CLASS_MOVING = "moving" # On means occupied, Off means not occupied (clear) -DEVICE_CLASS_OCCUPANCY = 'occupancy' +DEVICE_CLASS_OCCUPANCY = "occupancy" # On means open, Off means closed -DEVICE_CLASS_OPENING = 'opening' +DEVICE_CLASS_OPENING = "opening" # On means plugged in, Off means unplugged -DEVICE_CLASS_PLUG = 'plug' +DEVICE_CLASS_PLUG = "plug" # On means power detected, Off means no power -DEVICE_CLASS_POWER = 'power' +DEVICE_CLASS_POWER = "power" # On means home, Off means away -DEVICE_CLASS_PRESENCE = 'presence' +DEVICE_CLASS_PRESENCE = "presence" # On means problem detected, Off means no problem (OK) -DEVICE_CLASS_PROBLEM = 'problem' +DEVICE_CLASS_PROBLEM = "problem" # On means unsafe, Off means safe -DEVICE_CLASS_SAFETY = 'safety' +DEVICE_CLASS_SAFETY = "safety" # On means smoke detected, Off means no smoke (clear) -DEVICE_CLASS_SMOKE = 'smoke' +DEVICE_CLASS_SMOKE = "smoke" # On means sound detected, Off means no sound (clear) -DEVICE_CLASS_SOUND = 'sound' +DEVICE_CLASS_SOUND = "sound" # On means vibration detected, Off means no vibration -DEVICE_CLASS_VIBRATION = 'vibration' +DEVICE_CLASS_VIBRATION = "vibration" # On means open, Off means closed -DEVICE_CLASS_WINDOW = 'window' +DEVICE_CLASS_WINDOW = "window" DEVICE_CLASSES = [ DEVICE_CLASS_BATTERY, + DEVICE_CLASS_BATTERY_CHARGING, DEVICE_CLASS_COLD, DEVICE_CLASS_CONNECTIVITY, DEVICE_CLASS_DOOR, @@ -117,7 +127,8 @@ async def async_setup(hass, config): """Track states and offer events for binary sensors.""" component = hass.data[DOMAIN] = EntityComponent( - logging.getLogger(__name__), DOMAIN, hass, SCAN_INTERVAL) + logging.getLogger(__name__), DOMAIN, hass, SCAN_INTERVAL + ) await component.async_setup(config) return True @@ -133,7 +144,7 @@ async def async_unload_entry(hass, entry): return await hass.data[DOMAIN].async_unload_entry(entry) -class BinarySensorDevice(Entity): +class BinarySensorEntity(Entity): """Represent a binary sensor.""" @property @@ -150,3 +161,15 @@ def state(self): def device_class(self): """Return the class of this device, from component DEVICE_CLASSES.""" return None + + +class BinarySensorDevice(BinarySensorEntity): + """Represent a binary sensor (for backwards compatibility).""" + + def __init_subclass__(cls, **kwargs): + """Print deprecation warning.""" + super().__init_subclass__(**kwargs) + _LOGGER.warning( + "BinarySensorDevice is deprecated, modify %s to extend BinarySensorEntity", + cls.__name__, + ) diff --git a/homeassistant/components/binary_sensor/device_condition.py b/homeassistant/components/binary_sensor/device_condition.py new file mode 100644 index 0000000000000..999a62b3a80e3 --- /dev/null +++ b/homeassistant/components/binary_sensor/device_condition.py @@ -0,0 +1,271 @@ +"""Implement device conditions for binary sensor.""" +from typing import Dict, List + +import voluptuous as vol + +from homeassistant.components.device_automation.const import CONF_IS_OFF, CONF_IS_ON +from homeassistant.const import ATTR_DEVICE_CLASS, CONF_ENTITY_ID, CONF_FOR, CONF_TYPE +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import condition, config_validation as cv +from homeassistant.helpers.entity_registry import ( + async_entries_for_device, + async_get_registry, +) +from homeassistant.helpers.typing import ConfigType + +from . import ( + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_BATTERY_CHARGING, + 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, + DOMAIN, +) + +DEVICE_CLASS_NONE = "none" + +CONF_IS_BAT_LOW = "is_bat_low" +CONF_IS_NOT_BAT_LOW = "is_not_bat_low" +CONF_IS_CHARGING = "is_charging" +CONF_IS_NOT_CHARGING = "is_not_charging" +CONF_IS_COLD = "is_cold" +CONF_IS_NOT_COLD = "is_not_cold" +CONF_IS_CONNECTED = "is_connected" +CONF_IS_NOT_CONNECTED = "is_not_connected" +CONF_IS_GAS = "is_gas" +CONF_IS_NO_GAS = "is_no_gas" +CONF_IS_HOT = "is_hot" +CONF_IS_NOT_HOT = "is_not_hot" +CONF_IS_LIGHT = "is_light" +CONF_IS_NO_LIGHT = "is_no_light" +CONF_IS_LOCKED = "is_locked" +CONF_IS_NOT_LOCKED = "is_not_locked" +CONF_IS_MOIST = "is_moist" +CONF_IS_NOT_MOIST = "is_not_moist" +CONF_IS_MOTION = "is_motion" +CONF_IS_NO_MOTION = "is_no_motion" +CONF_IS_MOVING = "is_moving" +CONF_IS_NOT_MOVING = "is_not_moving" +CONF_IS_OCCUPIED = "is_occupied" +CONF_IS_NOT_OCCUPIED = "is_not_occupied" +CONF_IS_PLUGGED_IN = "is_plugged_in" +CONF_IS_NOT_PLUGGED_IN = "is_not_plugged_in" +CONF_IS_POWERED = "is_powered" +CONF_IS_NOT_POWERED = "is_not_powered" +CONF_IS_PRESENT = "is_present" +CONF_IS_NOT_PRESENT = "is_not_present" +CONF_IS_PROBLEM = "is_problem" +CONF_IS_NO_PROBLEM = "is_no_problem" +CONF_IS_UNSAFE = "is_unsafe" +CONF_IS_NOT_UNSAFE = "is_not_unsafe" +CONF_IS_SMOKE = "is_smoke" +CONF_IS_NO_SMOKE = "is_no_smoke" +CONF_IS_SOUND = "is_sound" +CONF_IS_NO_SOUND = "is_no_sound" +CONF_IS_VIBRATION = "is_vibration" +CONF_IS_NO_VIBRATION = "is_no_vibration" +CONF_IS_OPEN = "is_open" +CONF_IS_NOT_OPEN = "is_not_open" + +IS_ON = [ + CONF_IS_BAT_LOW, + CONF_IS_CHARGING, + CONF_IS_COLD, + CONF_IS_CONNECTED, + CONF_IS_GAS, + CONF_IS_HOT, + CONF_IS_LIGHT, + CONF_IS_NOT_LOCKED, + CONF_IS_MOIST, + CONF_IS_MOTION, + CONF_IS_MOVING, + CONF_IS_OCCUPIED, + CONF_IS_OPEN, + CONF_IS_PLUGGED_IN, + CONF_IS_POWERED, + CONF_IS_PRESENT, + CONF_IS_PROBLEM, + CONF_IS_SMOKE, + CONF_IS_SOUND, + CONF_IS_UNSAFE, + CONF_IS_VIBRATION, + CONF_IS_ON, +] + +IS_OFF = [ + CONF_IS_NOT_BAT_LOW, + CONF_IS_NOT_CHARGING, + CONF_IS_NOT_COLD, + CONF_IS_NOT_CONNECTED, + CONF_IS_NOT_HOT, + CONF_IS_LOCKED, + CONF_IS_NOT_MOIST, + CONF_IS_NOT_MOVING, + CONF_IS_NOT_OCCUPIED, + CONF_IS_NOT_OPEN, + CONF_IS_NOT_PLUGGED_IN, + CONF_IS_NOT_POWERED, + CONF_IS_NOT_PRESENT, + CONF_IS_NOT_UNSAFE, + CONF_IS_NO_GAS, + CONF_IS_NO_LIGHT, + CONF_IS_NO_MOTION, + CONF_IS_NO_PROBLEM, + CONF_IS_NO_SMOKE, + CONF_IS_NO_SOUND, + CONF_IS_NO_VIBRATION, + CONF_IS_OFF, +] + +ENTITY_CONDITIONS = { + DEVICE_CLASS_BATTERY: [ + {CONF_TYPE: CONF_IS_BAT_LOW}, + {CONF_TYPE: CONF_IS_NOT_BAT_LOW}, + ], + DEVICE_CLASS_BATTERY_CHARGING: [ + {CONF_TYPE: CONF_IS_CHARGING}, + {CONF_TYPE: CONF_IS_NOT_CHARGING}, + ], + DEVICE_CLASS_COLD: [{CONF_TYPE: CONF_IS_COLD}, {CONF_TYPE: CONF_IS_NOT_COLD}], + DEVICE_CLASS_CONNECTIVITY: [ + {CONF_TYPE: CONF_IS_CONNECTED}, + {CONF_TYPE: CONF_IS_NOT_CONNECTED}, + ], + DEVICE_CLASS_DOOR: [{CONF_TYPE: CONF_IS_OPEN}, {CONF_TYPE: CONF_IS_NOT_OPEN}], + DEVICE_CLASS_GARAGE_DOOR: [ + {CONF_TYPE: CONF_IS_OPEN}, + {CONF_TYPE: CONF_IS_NOT_OPEN}, + ], + DEVICE_CLASS_GAS: [{CONF_TYPE: CONF_IS_GAS}, {CONF_TYPE: CONF_IS_NO_GAS}], + DEVICE_CLASS_HEAT: [{CONF_TYPE: CONF_IS_HOT}, {CONF_TYPE: CONF_IS_NOT_HOT}], + DEVICE_CLASS_LIGHT: [{CONF_TYPE: CONF_IS_LIGHT}, {CONF_TYPE: CONF_IS_NO_LIGHT}], + DEVICE_CLASS_LOCK: [{CONF_TYPE: CONF_IS_LOCKED}, {CONF_TYPE: CONF_IS_NOT_LOCKED}], + DEVICE_CLASS_MOISTURE: [{CONF_TYPE: CONF_IS_MOIST}, {CONF_TYPE: CONF_IS_NOT_MOIST}], + DEVICE_CLASS_MOTION: [{CONF_TYPE: CONF_IS_MOTION}, {CONF_TYPE: CONF_IS_NO_MOTION}], + DEVICE_CLASS_MOVING: [{CONF_TYPE: CONF_IS_MOVING}, {CONF_TYPE: CONF_IS_NOT_MOVING}], + DEVICE_CLASS_OCCUPANCY: [ + {CONF_TYPE: CONF_IS_OCCUPIED}, + {CONF_TYPE: CONF_IS_NOT_OCCUPIED}, + ], + DEVICE_CLASS_OPENING: [{CONF_TYPE: CONF_IS_OPEN}, {CONF_TYPE: CONF_IS_NOT_OPEN}], + DEVICE_CLASS_PLUG: [ + {CONF_TYPE: CONF_IS_PLUGGED_IN}, + {CONF_TYPE: CONF_IS_NOT_PLUGGED_IN}, + ], + DEVICE_CLASS_POWER: [ + {CONF_TYPE: CONF_IS_POWERED}, + {CONF_TYPE: CONF_IS_NOT_POWERED}, + ], + DEVICE_CLASS_PRESENCE: [ + {CONF_TYPE: CONF_IS_PRESENT}, + {CONF_TYPE: CONF_IS_NOT_PRESENT}, + ], + DEVICE_CLASS_PROBLEM: [ + {CONF_TYPE: CONF_IS_PROBLEM}, + {CONF_TYPE: CONF_IS_NO_PROBLEM}, + ], + DEVICE_CLASS_SAFETY: [{CONF_TYPE: CONF_IS_UNSAFE}, {CONF_TYPE: CONF_IS_NOT_UNSAFE}], + DEVICE_CLASS_SMOKE: [{CONF_TYPE: CONF_IS_SMOKE}, {CONF_TYPE: CONF_IS_NO_SMOKE}], + DEVICE_CLASS_SOUND: [{CONF_TYPE: CONF_IS_SOUND}, {CONF_TYPE: CONF_IS_NO_SOUND}], + DEVICE_CLASS_VIBRATION: [ + {CONF_TYPE: CONF_IS_VIBRATION}, + {CONF_TYPE: CONF_IS_NO_VIBRATION}, + ], + DEVICE_CLASS_WINDOW: [{CONF_TYPE: CONF_IS_OPEN}, {CONF_TYPE: CONF_IS_NOT_OPEN}], + DEVICE_CLASS_NONE: [{CONF_TYPE: CONF_IS_ON}, {CONF_TYPE: CONF_IS_OFF}], +} + +CONDITION_SCHEMA = cv.DEVICE_CONDITION_BASE_SCHEMA.extend( + { + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_TYPE): vol.In(IS_OFF + IS_ON), + vol.Optional(CONF_FOR): cv.positive_time_period_dict, + } +) + + +async def async_get_conditions( + hass: HomeAssistant, device_id: str +) -> List[Dict[str, str]]: + """List device conditions.""" + conditions: List[Dict[str, str]] = [] + entity_registry = await async_get_registry(hass) + entries = [ + entry + for entry in async_entries_for_device(entity_registry, device_id) + if entry.domain == DOMAIN + ] + + for entry in entries: + device_class = DEVICE_CLASS_NONE + state = hass.states.get(entry.entity_id) + if state and ATTR_DEVICE_CLASS in state.attributes: + device_class = state.attributes[ATTR_DEVICE_CLASS] + + templates = ENTITY_CONDITIONS.get( + device_class, ENTITY_CONDITIONS[DEVICE_CLASS_NONE] + ) + + conditions.extend( + { + **template, + "condition": "device", + "device_id": device_id, + "entity_id": entry.entity_id, + "domain": DOMAIN, + } + for template in templates + ) + + return conditions + + +@callback +def async_condition_from_config( + config: ConfigType, config_validation: bool +) -> condition.ConditionCheckerType: + """Evaluate state based on configuration.""" + if config_validation: + config = CONDITION_SCHEMA(config) + condition_type = config[CONF_TYPE] + if condition_type in IS_ON: + stat = "on" + else: + stat = "off" + state_config = { + condition.CONF_CONDITION: "state", + condition.CONF_ENTITY_ID: config[CONF_ENTITY_ID], + condition.CONF_STATE: stat, + } + if CONF_FOR in config: + state_config[CONF_FOR] = config[CONF_FOR] + + return condition.state_from_config(state_config) + + +async def async_get_condition_capabilities(hass: HomeAssistant, config: dict) -> dict: + """List condition capabilities.""" + return { + "extra_fields": vol.Schema( + {vol.Optional(CONF_FOR): cv.positive_time_period_dict} + ) + } diff --git a/homeassistant/components/binary_sensor/device_trigger.py b/homeassistant/components/binary_sensor/device_trigger.py new file mode 100644 index 0000000000000..d50cc20c1aee4 --- /dev/null +++ b/homeassistant/components/binary_sensor/device_trigger.py @@ -0,0 +1,255 @@ +"""Provides device triggers for binary sensors.""" +import voluptuous as vol + +from homeassistant.components.automation import state as state_automation +from homeassistant.components.device_automation import TRIGGER_BASE_SCHEMA +from homeassistant.components.device_automation.const import ( + CONF_TURNED_OFF, + CONF_TURNED_ON, +) +from homeassistant.const import ATTR_DEVICE_CLASS, CONF_ENTITY_ID, CONF_FOR, CONF_TYPE +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity_registry import async_entries_for_device + +from . import ( + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_BATTERY_CHARGING, + 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, + DOMAIN, +) + +# mypy: allow-untyped-defs, no-check-untyped-defs + +DEVICE_CLASS_NONE = "none" + +CONF_BAT_LOW = "bat_low" +CONF_NOT_BAT_LOW = "not_bat_low" +CONF_CHARGING = "charging" +CONF_NOT_CHARGING = "not_charging" +CONF_COLD = "cold" +CONF_NOT_COLD = "not_cold" +CONF_CONNECTED = "connected" +CONF_NOT_CONNECTED = "not_connected" +CONF_GAS = "gas" +CONF_NO_GAS = "no_gas" +CONF_HOT = "hot" +CONF_NOT_HOT = "not_hot" +CONF_LIGHT = "light" +CONF_NO_LIGHT = "no_light" +CONF_LOCKED = "locked" +CONF_NOT_LOCKED = "not_locked" +CONF_MOIST = "moist" +CONF_NOT_MOIST = "not_moist" +CONF_MOTION = "motion" +CONF_NO_MOTION = "no_motion" +CONF_MOVING = "moving" +CONF_NOT_MOVING = "not_moving" +CONF_OCCUPIED = "occupied" +CONF_NOT_OCCUPIED = "not_occupied" +CONF_PLUGGED_IN = "plugged_in" +CONF_NOT_PLUGGED_IN = "not_plugged_in" +CONF_POWERED = "powered" +CONF_NOT_POWERED = "not_powered" +CONF_PRESENT = "present" +CONF_NOT_PRESENT = "not_present" +CONF_PROBLEM = "problem" +CONF_NO_PROBLEM = "no_problem" +CONF_UNSAFE = "unsafe" +CONF_NOT_UNSAFE = "not_unsafe" +CONF_SMOKE = "smoke" +CONF_NO_SMOKE = "no_smoke" +CONF_SOUND = "sound" +CONF_NO_SOUND = "no_sound" +CONF_VIBRATION = "vibration" +CONF_NO_VIBRATION = "no_vibration" +CONF_OPENED = "opened" +CONF_NOT_OPENED = "not_opened" + + +TURNED_ON = [ + CONF_BAT_LOW, + CONF_COLD, + CONF_CONNECTED, + CONF_GAS, + CONF_HOT, + CONF_LIGHT, + CONF_NOT_LOCKED, + CONF_MOIST, + CONF_MOTION, + CONF_MOVING, + CONF_OCCUPIED, + CONF_OPENED, + CONF_PLUGGED_IN, + CONF_POWERED, + CONF_PRESENT, + CONF_PROBLEM, + CONF_SMOKE, + CONF_SOUND, + CONF_UNSAFE, + CONF_VIBRATION, + CONF_TURNED_ON, +] + +TURNED_OFF = [ + CONF_NOT_BAT_LOW, + CONF_NOT_COLD, + CONF_NOT_CONNECTED, + CONF_NOT_HOT, + CONF_LOCKED, + CONF_NOT_MOIST, + CONF_NOT_MOVING, + CONF_NOT_OCCUPIED, + CONF_NOT_OPENED, + CONF_NOT_PLUGGED_IN, + CONF_NOT_POWERED, + CONF_NOT_PRESENT, + CONF_NOT_UNSAFE, + CONF_NO_GAS, + CONF_NO_LIGHT, + CONF_NO_MOTION, + CONF_NO_PROBLEM, + CONF_NO_SMOKE, + CONF_NO_SOUND, + CONF_NO_VIBRATION, + CONF_TURNED_OFF, +] + + +ENTITY_TRIGGERS = { + DEVICE_CLASS_BATTERY: [{CONF_TYPE: CONF_BAT_LOW}, {CONF_TYPE: CONF_NOT_BAT_LOW}], + DEVICE_CLASS_BATTERY_CHARGING: [ + {CONF_TYPE: CONF_CHARGING}, + {CONF_TYPE: CONF_NOT_CHARGING}, + ], + DEVICE_CLASS_COLD: [{CONF_TYPE: CONF_COLD}, {CONF_TYPE: CONF_NOT_COLD}], + DEVICE_CLASS_CONNECTIVITY: [ + {CONF_TYPE: CONF_CONNECTED}, + {CONF_TYPE: CONF_NOT_CONNECTED}, + ], + DEVICE_CLASS_DOOR: [{CONF_TYPE: CONF_OPENED}, {CONF_TYPE: CONF_NOT_OPENED}], + DEVICE_CLASS_GARAGE_DOOR: [{CONF_TYPE: CONF_OPENED}, {CONF_TYPE: CONF_NOT_OPENED}], + DEVICE_CLASS_GAS: [{CONF_TYPE: CONF_GAS}, {CONF_TYPE: CONF_NO_GAS}], + DEVICE_CLASS_HEAT: [{CONF_TYPE: CONF_HOT}, {CONF_TYPE: CONF_NOT_HOT}], + DEVICE_CLASS_LIGHT: [{CONF_TYPE: CONF_LIGHT}, {CONF_TYPE: CONF_NO_LIGHT}], + DEVICE_CLASS_LOCK: [{CONF_TYPE: CONF_LOCKED}, {CONF_TYPE: CONF_NOT_LOCKED}], + DEVICE_CLASS_MOISTURE: [{CONF_TYPE: CONF_MOIST}, {CONF_TYPE: CONF_NOT_MOIST}], + DEVICE_CLASS_MOTION: [{CONF_TYPE: CONF_MOTION}, {CONF_TYPE: CONF_NO_MOTION}], + DEVICE_CLASS_MOVING: [{CONF_TYPE: CONF_MOVING}, {CONF_TYPE: CONF_NOT_MOVING}], + DEVICE_CLASS_OCCUPANCY: [ + {CONF_TYPE: CONF_OCCUPIED}, + {CONF_TYPE: CONF_NOT_OCCUPIED}, + ], + DEVICE_CLASS_OPENING: [{CONF_TYPE: CONF_OPENED}, {CONF_TYPE: CONF_NOT_OPENED}], + DEVICE_CLASS_PLUG: [{CONF_TYPE: CONF_PLUGGED_IN}, {CONF_TYPE: CONF_NOT_PLUGGED_IN}], + DEVICE_CLASS_POWER: [{CONF_TYPE: CONF_POWERED}, {CONF_TYPE: CONF_NOT_POWERED}], + DEVICE_CLASS_PRESENCE: [{CONF_TYPE: CONF_PRESENT}, {CONF_TYPE: CONF_NOT_PRESENT}], + DEVICE_CLASS_PROBLEM: [{CONF_TYPE: CONF_PROBLEM}, {CONF_TYPE: CONF_NO_PROBLEM}], + DEVICE_CLASS_SAFETY: [{CONF_TYPE: CONF_UNSAFE}, {CONF_TYPE: CONF_NOT_UNSAFE}], + DEVICE_CLASS_SMOKE: [{CONF_TYPE: CONF_SMOKE}, {CONF_TYPE: CONF_NO_SMOKE}], + DEVICE_CLASS_SOUND: [{CONF_TYPE: CONF_SOUND}, {CONF_TYPE: CONF_NO_SOUND}], + DEVICE_CLASS_VIBRATION: [ + {CONF_TYPE: CONF_VIBRATION}, + {CONF_TYPE: CONF_NO_VIBRATION}, + ], + DEVICE_CLASS_WINDOW: [{CONF_TYPE: CONF_OPENED}, {CONF_TYPE: CONF_NOT_OPENED}], + DEVICE_CLASS_NONE: [{CONF_TYPE: CONF_TURNED_ON}, {CONF_TYPE: CONF_TURNED_OFF}], +} + + +TRIGGER_SCHEMA = TRIGGER_BASE_SCHEMA.extend( + { + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_TYPE): vol.In(TURNED_OFF + TURNED_ON), + vol.Optional(CONF_FOR): cv.positive_time_period_dict, + } +) + + +async def async_attach_trigger(hass, config, action, automation_info): + """Listen for state changes based on configuration.""" + trigger_type = config[CONF_TYPE] + if trigger_type in TURNED_ON: + from_state = "off" + to_state = "on" + else: + from_state = "on" + to_state = "off" + + state_config = { + state_automation.CONF_PLATFORM: "state", + state_automation.CONF_ENTITY_ID: config[CONF_ENTITY_ID], + state_automation.CONF_FROM: from_state, + state_automation.CONF_TO: to_state, + } + if CONF_FOR in config: + state_config[CONF_FOR] = config[CONF_FOR] + + state_config = state_automation.TRIGGER_SCHEMA(state_config) + return await state_automation.async_attach_trigger( + hass, state_config, action, automation_info, platform_type="device" + ) + + +async def async_get_triggers(hass, device_id): + """List device triggers.""" + triggers = [] + entity_registry = await hass.helpers.entity_registry.async_get_registry() + + entries = [ + entry + for entry in async_entries_for_device(entity_registry, device_id) + if entry.domain == DOMAIN + ] + + for entry in entries: + device_class = DEVICE_CLASS_NONE + state = hass.states.get(entry.entity_id) + if state: + device_class = state.attributes.get(ATTR_DEVICE_CLASS) + + templates = ENTITY_TRIGGERS.get( + device_class, ENTITY_TRIGGERS[DEVICE_CLASS_NONE] + ) + + triggers.extend( + { + **automation, + "platform": "device", + "device_id": device_id, + "entity_id": entry.entity_id, + "domain": DOMAIN, + } + for automation in templates + ) + + return triggers + + +async def async_get_trigger_capabilities(hass, config): + """List trigger capabilities.""" + return { + "extra_fields": vol.Schema( + {vol.Optional(CONF_FOR): cv.positive_time_period_dict} + ) + } diff --git a/homeassistant/components/binary_sensor/manifest.json b/homeassistant/components/binary_sensor/manifest.json index d627351958d57..be2feb9d207c6 100644 --- a/homeassistant/components/binary_sensor/manifest.json +++ b/homeassistant/components/binary_sensor/manifest.json @@ -1,8 +1,7 @@ { "domain": "binary_sensor", - "name": "Binary sensor", - "documentation": "https://www.home-assistant.io/components/binary_sensor", - "requirements": [], - "dependencies": [], - "codeowners": [] + "name": "Binary Sensor", + "documentation": "https://www.home-assistant.io/integrations/binary_sensor", + "codeowners": [], + "quality_scale": "internal" } diff --git a/homeassistant/components/binary_sensor/strings.json b/homeassistant/components/binary_sensor/strings.json new file mode 100644 index 0000000000000..045fcdae7078f --- /dev/null +++ b/homeassistant/components/binary_sensor/strings.json @@ -0,0 +1,175 @@ +{ + "title": "Binary sensor", + "device_automation": { + "condition_type": { + "is_bat_low": "{entity_name} battery is low", + "is_not_bat_low": "{entity_name} battery is normal", + "is_cold": "{entity_name} is cold", + "is_not_cold": "{entity_name} is not cold", + "is_connected": "{entity_name} is connected", + "is_not_connected": "{entity_name} is disconnected", + "is_gas": "{entity_name} is detecting gas", + "is_no_gas": "{entity_name} is not detecting gas", + "is_hot": "{entity_name} is hot", + "is_not_hot": "{entity_name} is not hot", + "is_light": "{entity_name} is detecting light", + "is_no_light": "{entity_name} is not detecting light", + "is_locked": "{entity_name} is locked", + "is_not_locked": "{entity_name} is unlocked", + "is_moist": "{entity_name} is moist", + "is_not_moist": "{entity_name} is dry", + "is_motion": "{entity_name} is detecting motion", + "is_no_motion": "{entity_name} is not detecting motion", + "is_moving": "{entity_name} is moving", + "is_not_moving": "{entity_name} is not moving", + "is_occupied": "{entity_name} is occupied", + "is_not_occupied": "{entity_name} is not occupied", + "is_plugged_in": "{entity_name} is plugged in", + "is_not_plugged_in": "{entity_name} is unplugged", + "is_powered": "{entity_name} is powered", + "is_not_powered": "{entity_name} is not powered", + "is_present": "{entity_name} is present", + "is_not_present": "{entity_name} is not present", + "is_problem": "{entity_name} is detecting problem", + "is_no_problem": "{entity_name} is not detecting problem", + "is_unsafe": "{entity_name} is unsafe", + "is_not_unsafe": "{entity_name} is safe", + "is_smoke": "{entity_name} is detecting smoke", + "is_no_smoke": "{entity_name} is not detecting smoke", + "is_sound": "{entity_name} is detecting sound", + "is_no_sound": "{entity_name} is not detecting sound", + "is_vibration": "{entity_name} is detecting vibration", + "is_no_vibration": "{entity_name} is not detecting vibration", + "is_open": "{entity_name} is open", + "is_not_open": "{entity_name} is closed", + "is_on": "{entity_name} is on", + "is_off": "{entity_name} is off" + }, + "trigger_type": { + "bat_low": "{entity_name} battery low", + "not_bat_low": "{entity_name} battery normal", + "cold": "{entity_name} became cold", + "not_cold": "{entity_name} became not cold", + "connected": "{entity_name} connected", + "not_connected": "{entity_name} disconnected", + "gas": "{entity_name} started detecting gas", + "no_gas": "{entity_name} stopped detecting gas", + "hot": "{entity_name} became hot", + "not_hot": "{entity_name} became not hot", + "light": "{entity_name} started detecting light", + "no_light": "{entity_name} stopped detecting light", + "locked": "{entity_name} locked", + "not_locked": "{entity_name} unlocked", + "moist": "{entity_name} became moist", + "not_moist": "{entity_name} became dry", + "motion": "{entity_name} started detecting motion", + "no_motion": "{entity_name} stopped detecting motion", + "moving": "{entity_name} started moving", + "not_moving": "{entity_name} stopped moving", + "occupied": "{entity_name} became occupied", + "not_occupied": "{entity_name} became not occupied", + "plugged_in": "{entity_name} plugged in", + "not_plugged_in": "{entity_name} unplugged", + "powered": "{entity_name} powered", + "not_powered": "{entity_name} not powered", + "present": "{entity_name} present", + "not_present": "{entity_name} not present", + "problem": "{entity_name} started detecting problem", + "no_problem": "{entity_name} stopped detecting problem", + "unsafe": "{entity_name} became unsafe", + "not_unsafe": "{entity_name} became safe", + "smoke": "{entity_name} started detecting smoke", + "no_smoke": "{entity_name} stopped detecting smoke", + "sound": "{entity_name} started detecting sound", + "no_sound": "{entity_name} stopped detecting sound", + "vibration": "{entity_name} started detecting vibration", + "no_vibration": "{entity_name} stopped detecting vibration", + "opened": "{entity_name} opened", + "not_opened": "{entity_name} closed", + "turned_on": "{entity_name} turned on", + "turned_off": "{entity_name} turned off" + } + }, + "state": { + "battery": { + "off": "Normal", + "on": "Low" + }, + "cold": { + "off": "[%key:component::binary_sensor::state::battery::off%]", + "on": "Cold" + }, + "connectivity": { + "off": "[%key:common::state::disconnected%]", + "on": "[%key:common::state::connected%]" + }, + "door": { + "off": "[%key:common::state::closed%]", + "on": "[%key:common::state::open%]" + }, + "garage_door": { + "off": "[%key:common::state::closed%]", + "on": "[%key:common::state::open%]" + }, + "gas": { + "off": "Clear", + "on": "Detected" + }, + "heat": { + "off": "[%key:component::binary_sensor::state::battery::off%]", + "on": "Hot" + }, + "lock": { + "off": "[%key:common::state::locked%]", + "on": "[%key:common::state::unlocked%]" + }, + "moisture": { + "off": "Dry", + "on": "Wet" + }, + "motion": { + "off": "[%key:component::binary_sensor::state::gas::off%]", + "on": "[%key:component::binary_sensor::state::gas::on%]" + }, + "occupancy": { + "off": "[%key:component::binary_sensor::state::gas::off%]", + "on": "[%key:component::binary_sensor::state::gas::on%]" + }, + "opening": { + "off": "[%key:common::state::closed%]", + "on": "[%key:common::state::open%]" + }, + "presence": { + "off": "[%key:component::device_tracker::state::_::not_home%]", + "on": "[%key:component::device_tracker::state::_::home%]" + }, + "problem": { + "off": "OK", + "on": "Problem" + }, + "safety": { + "off": "Safe", + "on": "Unsafe" + }, + "smoke": { + "off": "[%key:component::binary_sensor::state::gas::off%]", + "on": "[%key:component::binary_sensor::state::gas::on%]" + }, + "sound": { + "off": "[%key:component::binary_sensor::state::gas::off%]", + "on": "[%key:component::binary_sensor::state::gas::on%]" + }, + "vibration": { + "off": "[%key:component::binary_sensor::state::gas::off%]", + "on": "[%key:component::binary_sensor::state::gas::on%]" + }, + "window": { + "off": "[%key:common::state::closed%]", + "on": "[%key:common::state::open%]" + }, + "_": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" + } + } +} diff --git a/homeassistant/components/binary_sensor/translations/af.json b/homeassistant/components/binary_sensor/translations/af.json new file mode 100644 index 0000000000000..c0988c3aa6894 --- /dev/null +++ b/homeassistant/components/binary_sensor/translations/af.json @@ -0,0 +1,85 @@ +{ + "state": { + "_": { + "off": "Af", + "on": "Aan" + }, + "battery": { + "off": "Normaal", + "on": "Laag" + }, + "cold": { + "off": "Normaal", + "on": "Koud" + }, + "connectivity": { + "off": "Ontkoppel", + "on": "Gekoppel" + }, + "door": { + "off": "Toe", + "on": "Oop" + }, + "garage_door": { + "off": "Toe", + "on": "Oop" + }, + "gas": { + "off": "Ongemerk", + "on": "Bespeur" + }, + "heat": { + "off": "Normaal", + "on": "Warm" + }, + "lock": { + "off": "Gesluit", + "on": "Oopgesluit" + }, + "moisture": { + "off": "Droog", + "on": "Nat" + }, + "motion": { + "off": "Ongemerk", + "on": "Bespeur" + }, + "occupancy": { + "off": "Ongemerk", + "on": "Bespeur" + }, + "opening": { + "off": "Toe", + "on": "Oop" + }, + "presence": { + "off": "Elders", + "on": "Tuis" + }, + "problem": { + "off": "OK", + "on": "Probleem" + }, + "safety": { + "off": "Veilige", + "on": "Onveilige" + }, + "smoke": { + "off": "Ongemerk", + "on": "Bespeur" + }, + "sound": { + "off": "Ongemerk", + "on": "Bespeur" + }, + "vibration": { + "off": "Ongemerk", + "on": "Bespeur" + }, + "window": { + "off": "Toe", + "on": "Oop" + } + }, + "title": "Bin\u00eare sensor" +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/translations/ar.json b/homeassistant/components/binary_sensor/translations/ar.json new file mode 100644 index 0000000000000..7782421ef1cd1 --- /dev/null +++ b/homeassistant/components/binary_sensor/translations/ar.json @@ -0,0 +1,85 @@ +{ + "state": { + "_": { + "off": "\u0625\u064a\u0642\u0627\u0641", + "on": "\u062a\u0634\u063a\u064a\u0644" + }, + "battery": { + "off": "\u0637\u0628\u064a\u0639\u064a", + "on": "\u0645\u0646\u062e\u0641\u0636" + }, + "cold": { + "off": "\u0637\u0628\u064a\u0639\u064a", + "on": "\u0628\u0627\u0631\u062f" + }, + "connectivity": { + "off": "\u0645\u0641\u0635\u0648\u0644", + "on": "\u0645\u062a\u0635\u0644" + }, + "door": { + "off": "\u0645\u063a\u0644\u0642", + "on": "\u0645\u0641\u062a\u0648\u062d" + }, + "garage_door": { + "off": "\u0645\u063a\u0644\u0642", + "on": "\u0645\u0641\u062a\u0648\u062d" + }, + "gas": { + "off": "\u0644\u0645 \u064a\u062a\u0645 \u0627\u0644\u0643\u0634\u0641", + "on": "\u062a\u0645 \u0627\u0644\u0643\u0634\u0641" + }, + "heat": { + "off": "\u0637\u0628\u064a\u0639\u064a", + "on": "\u062d\u0627\u0631" + }, + "lock": { + "off": "\u0645\u0642\u0641\u0644", + "on": "\u063a\u064a\u0631 \u0645\u0642\u0641\u0644" + }, + "moisture": { + "off": "\u062c\u0627\u0641", + "on": "\u0645\u0628\u0644\u0644" + }, + "motion": { + "off": "\u0644\u0645 \u064a\u062a\u0645 \u0627\u0644\u0643\u0634\u0641", + "on": "\u062a\u0645 \u0627\u0644\u0643\u0634\u0641" + }, + "occupancy": { + "off": "\u0644\u0645 \u064a\u062a\u0645 \u0627\u0644\u0643\u0634\u0641", + "on": "\u062a\u0645 \u0627\u0644\u0643\u0634\u0641" + }, + "opening": { + "off": "\u0645\u0642\u0641\u0644", + "on": "\u0645\u0641\u062a\u0648\u062d" + }, + "presence": { + "off": "\u062e\u0627\u0631\u062c \u0627\u0644\u0645\u0646\u0632\u0644", + "on": "\u0641\u064a \u0627\u0644\u0645\u0646\u0632\u0644" + }, + "problem": { + "off": "\u0645\u0648\u0627\u0641\u0642", + "on": "\u0639\u0637\u0644" + }, + "safety": { + "off": "\u0623\u0645\u0646", + "on": "\u063a\u064a\u0631 \u0623\u0645\u0646" + }, + "smoke": { + "off": "\u0644\u0645 \u064a\u062a\u0645 \u0627\u0644\u0643\u0634\u0641", + "on": "\u062a\u0645 \u0627\u0644\u0643\u0634\u0641" + }, + "sound": { + "off": "\u0644\u0645 \u064a\u062a\u0645 \u0627\u0644\u0643\u0634\u0641", + "on": "\u062a\u0645 \u0627\u0644\u0643\u0634\u0641" + }, + "vibration": { + "off": "\u0644\u0645 \u064a\u062a\u0645 \u0627\u0644\u0643\u0634\u0641", + "on": "\u062a\u0645 \u0627\u0644\u0643\u0634\u0641" + }, + "window": { + "off": "\u0645\u063a\u0644\u0642", + "on": "\u0645\u0641\u062a\u0648\u062d" + } + }, + "title": "\u062c\u0647\u0627\u0632 \u0627\u0633\u062a\u0634\u0639\u0627\u0631 \u062b\u0646\u0627\u0626\u064a" +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/translations/bg.json b/homeassistant/components/binary_sensor/translations/bg.json new file mode 100644 index 0000000000000..2d969af731e2d --- /dev/null +++ b/homeassistant/components/binary_sensor/translations/bg.json @@ -0,0 +1,175 @@ +{ + "device_automation": { + "condition_type": { + "is_bat_low": "{entity_name} \u0431\u0430\u0442\u0435\u0440\u0438\u044f\u0442\u0430 \u0435 \u0438\u0437\u0442\u043e\u0449\u0435\u043d\u0430", + "is_cold": "{entity_name} \u0435 \u0441\u0442\u0443\u0434\u0435\u043d", + "is_connected": "{entity_name} \u0435 \u0441\u0432\u044a\u0440\u0437\u0430\u043d", + "is_gas": "{entity_name} \u043e\u0442\u043a\u0440\u0438\u0432\u0430 \u0433\u0430\u0437", + "is_hot": "{entity_name} \u0435 \u0433\u043e\u0440\u0435\u0449", + "is_light": "{entity_name} \u0437\u0430\u0441\u0438\u0447\u0430 \u0441\u0432\u0435\u0442\u043b\u0438\u043d\u0430", + "is_locked": "{entity_name} \u0435 \u0437\u0430\u043a\u043b\u044e\u0447\u0435\u043d", + "is_moist": "{entity_name} \u0435 \u0432\u043b\u0430\u0436\u0435\u043d", + "is_motion": "{entity_name} \u043e\u0442\u043a\u0440\u0438\u0432\u0430 \u0434\u0432\u0438\u0436\u0435\u043d\u0438\u0435", + "is_moving": "{entity_name} \u0441\u0435 \u0434\u0432\u0438\u0436\u0438", + "is_no_gas": "{entity_name} \u043d\u0435 \u043e\u0442\u043a\u0440\u0438\u0432\u0430 \u0433\u0430\u0437", + "is_no_light": "{entity_name} \u043d\u0435 \u0437\u0430\u0441\u0438\u0447\u0430 \u0441\u0432\u0435\u0442\u043b\u0438\u043d\u0430", + "is_no_motion": "{entity_name} \u043d\u0435 \u043e\u0442\u043a\u0440\u0438\u0432\u0430 \u0434\u0432\u0438\u0436\u0435\u043d\u0438\u0435", + "is_no_problem": "{entity_name} \u043d\u0435 \u043e\u0442\u043a\u0440\u0438\u0432\u0430 \u043f\u0440\u043e\u0431\u043b\u0435\u043c", + "is_no_smoke": "{entity_name} \u043d\u0435 \u043e\u0442\u043a\u0440\u0438\u0432\u0430 \u0434\u0438\u043c", + "is_no_sound": "{entity_name} \u043d\u0435 \u0437\u0430\u0441\u0438\u0447\u0430 \u0437\u0432\u0443\u043a", + "is_no_vibration": "{entity_name} \u043d\u0435 \u0437\u0430\u0441\u0438\u0447\u0430 \u0432\u0438\u0431\u0440\u0430\u0446\u0438\u0438", + "is_not_bat_low": "{entity_name} \u0431\u0430\u0442\u0435\u0440\u0438\u044f\u0442\u0430 \u0435 \u0437\u0430\u0440\u0435\u0434\u0435\u043d\u0430", + "is_not_cold": "{entity_name} \u043d\u0435 \u0435 \u0441\u0442\u0443\u0434\u0435\u043d", + "is_not_connected": "{entity_name} \u0435 \u0440\u0430\u0437\u043a\u0430\u0447\u0435\u043d", + "is_not_hot": "{entity_name} \u043d\u0435 \u0435 \u0433\u043e\u0440\u0435\u0449", + "is_not_locked": "{entity_name} \u0435 \u043e\u0442\u043a\u043b\u044e\u0447\u0435\u043d", + "is_not_moist": "{entity_name} \u0435 \u0441\u0443\u0445", + "is_not_moving": "{entity_name} \u043d\u0435 \u0441\u0435 \u0434\u0432\u0438\u0436\u0438", + "is_not_occupied": "{entity_name} \u043d\u0435 \u0435 \u0437\u0430\u0435\u0442", + "is_not_open": "{entity_name} \u0435 \u0437\u0430\u0442\u0432\u043e\u0440\u0435\u043d", + "is_not_plugged_in": "{entity_name} \u0435 \u0438\u0437\u043a\u043b\u044e\u0447\u0435\u043d", + "is_not_powered": "{entity_name} \u043d\u0435 \u0441\u0435 \u0437\u0430\u0445\u0440\u0430\u043d\u0432\u0430", + "is_not_present": "{entity_name} \u043d\u0435 \u0435 \u043d\u0430\u043b\u0438\u0446\u0435", + "is_not_unsafe": "{entity_name} \u0435 \u0431\u0435\u0437\u043e\u043f\u0430\u0441\u0435\u043d", + "is_occupied": "{entity_name} \u0435 \u0437\u0430\u0435\u0442", + "is_off": "{entity_name} \u0435 \u0438\u0437\u043a\u043b\u044e\u0447\u0435\u043d", + "is_on": "{entity_name} \u0435 \u0432\u043a\u043b\u044e\u0447\u0435\u043d", + "is_open": "{entity_name} \u0435 \u043e\u0442\u0432\u043e\u0440\u0435\u043d", + "is_plugged_in": "{entity_name} \u0435 \u0432\u043a\u043b\u044e\u0447\u0435\u043d", + "is_powered": "{entity_name} \u0441\u0435 \u0437\u0430\u0445\u0440\u0430\u043d\u0432\u0430", + "is_present": "{entity_name} \u043f\u0440\u0438\u0441\u044a\u0441\u0442\u0432\u0430", + "is_problem": "{entity_name} \u043e\u0442\u043a\u0440\u0438\u0432\u0430 \u043f\u0440\u043e\u0431\u043b\u0435\u043c", + "is_smoke": "{entity_name} \u043e\u0442\u043a\u0440\u0438\u0432\u0430 \u0434\u0438\u043c", + "is_sound": "{entity_name} \u043e\u0442\u043a\u0440\u0438\u0432\u0430 \u0437\u0432\u0443\u043a", + "is_unsafe": "{entity_name} \u043d\u0435 \u0435 \u0431\u0435\u0437\u043e\u043f\u0430\u0441\u0435\u043d", + "is_vibration": "{entity_name} \u0437\u0430\u0441\u0438\u0447\u0430 \u0432\u0438\u0431\u0440\u0430\u0446\u0438\u0438" + }, + "trigger_type": { + "bat_low": "{entity_name} \u0438\u0437\u0442\u043e\u0449\u0435\u043d\u0430 \u0431\u0430\u0442\u0435\u0440\u0438\u044f", + "cold": "{entity_name} \u0441\u0435 \u0438\u0437\u0441\u0442\u0443\u0434\u0438", + "connected": "{entity_name} \u0441\u0432\u044a\u0440\u0437\u0430\u043d", + "gas": "{entity_name} \u0437\u0430\u043f\u043e\u0447\u043d\u0430 \u0434\u0430 \u043e\u0442\u043a\u0440\u0438\u0432\u0430 \u0433\u0430\u0437", + "hot": "{entity_name} \u0441\u0435 \u0441\u0442\u043e\u043f\u043b\u0438", + "light": "{entity_name} \u0437\u0430\u043f\u043e\u0447\u043d\u0430 \u0434\u0430 \u043e\u0442\u043a\u0440\u0438\u0432\u0430 \u0441\u0432\u0435\u0442\u043b\u0438\u043d\u0430", + "locked": "{entity_name} \u0437\u0430\u043a\u043b\u044e\u0447\u0435\u043d", + "moist": "{entity_name} \u0441\u0442\u0430\u043d\u0430 \u0432\u043b\u0430\u0436\u0435\u043d", + "motion": "{entity_name} \u0437\u0430\u043f\u043e\u0447\u043d\u0430 \u043e\u0442\u043a\u0440\u0438\u0432\u0430\u043d\u0435 \u043d\u0430 \u0434\u0432\u0438\u0436\u0435\u043d\u0438\u0435", + "moving": "{entity_name} \u0437\u0430\u043f\u043e\u0447\u043d\u0430 \u043e\u0442\u043a\u0440\u0438\u0432\u0430 \u0434\u0432\u0438\u0436\u0435\u043d\u0438\u0435", + "no_gas": "{entity_name} \u0441\u043f\u0440\u044f \u0434\u0430 \u043e\u0442\u043a\u0440\u0438\u0432\u0430 \u0433\u0430\u0437", + "no_light": "{entity_name} \u0441\u043f\u0440\u044f \u0434\u0430 \u0437\u0430\u0441\u0438\u0447\u0430 \u0441\u0432\u0435\u0442\u043b\u0438\u043d\u0430", + "no_motion": "{entity_name} \u0441\u043f\u0440\u044f \u0434\u0430 \u043e\u0442\u043a\u0440\u0438\u0432\u0430 \u0434\u0432\u0438\u0436\u0435\u043d\u0438\u0435", + "no_problem": "{entity_name} \u0441\u043f\u0440\u044f \u0434\u0430 \u043e\u0442\u043a\u0440\u0438\u0432\u0430 \u043f\u0440\u043e\u0431\u043b\u0435\u043c", + "no_smoke": "{entity_name} \u0441\u043f\u0440\u044f \u0434\u0430 \u043e\u0442\u043a\u0440\u0438\u0432\u0430 \u0434\u0438\u043c", + "no_sound": "{entity_name} \u0441\u043f\u0440\u044f \u0434\u0430 \u043e\u0442\u043a\u0440\u0438\u0432\u0430 \u0437\u0432\u0443\u043a", + "no_vibration": "{entity_name} \u0441\u043f\u0440\u044f \u0434\u0430 \u0437\u0430\u0441\u0438\u0447\u0430 \u0432\u0438\u0431\u0440\u0430\u0446\u0438\u0438", + "not_bat_low": "{entity_name} \u0431\u0430\u0442\u0435\u0440\u0438\u044f\u0442\u0430 \u043d\u0435 \u0435 \u0438\u0437\u0442\u043e\u0449\u0435\u043d\u0430", + "not_cold": "{entity_name} \u0441\u0435 \u0441\u0442\u043e\u043f\u043b\u0438", + "not_connected": "{entity_name} \u0438\u0437\u043a\u043b\u044e\u0447\u0435\u043d", + "not_hot": "{entity_name} \u043e\u0445\u043b\u0430\u0434\u043d\u044f", + "not_locked": "{entity_name} \u043e\u0442\u043a\u043b\u044e\u0447\u0435\u043d", + "not_moist": "{entity_name} \u0441\u0442\u0430\u0432\u0430 \u0441\u0443\u0445", + "not_moving": "{entity_name} \u0441\u043f\u0440\u044f \u0434\u0430 \u0441\u0435 \u0434\u0432\u0438\u0436\u0438", + "not_occupied": "{entity_name} \u0432\u0435\u0447\u0435 \u043d\u0435 \u0435 \u0437\u0430\u0435\u0442", + "not_opened": "{entity_name} \u0437\u0430\u0442\u0432\u043e\u0440\u0435\u043d", + "not_plugged_in": "{entity_name} \u0438\u0437\u043a\u043b\u044e\u0447\u0435\u043d", + "not_powered": "{entity_name} \u043d\u0435 \u0441\u0435 \u0437\u0430\u0445\u0440\u0430\u043d\u0432\u0430", + "not_present": "{entity_name} \u043d\u0435 \u043f\u0440\u0438\u0441\u044a\u0441\u0442\u0432\u0430", + "not_unsafe": "{entity_name} \u0441\u0442\u0430\u043d\u0430 \u0431\u0435\u0437\u043e\u043f\u0430\u0441\u0435\u043d", + "occupied": "{entity_name} \u0441\u0442\u0430\u043d\u0430 \u0437\u0430\u0435\u0442", + "opened": "{entity_name} \u0441\u0435 \u043e\u0442\u0432\u043e\u0440\u0438", + "plugged_in": "{entity_name} \u0441\u0435 \u0432\u043a\u043b\u044e\u0447\u0438", + "powered": "{entity_name} \u0441\u0435 \u0437\u0430\u0445\u0440\u0430\u043d\u0432\u0430", + "present": "{entity_name} \u043f\u0440\u0438\u0441\u044a\u0441\u0442\u0432\u0430", + "problem": "{entity_name} \u0437\u0430\u043f\u043e\u0447\u043d\u0430 \u0434\u0430 \u043e\u0442\u043a\u0440\u0438\u0432\u0430 \u043f\u0440\u043e\u0431\u043b\u0435\u043c", + "smoke": "{entity_name} \u0437\u0430\u043f\u043e\u0447\u043d\u0430 \u0434\u0430 \u043e\u0442\u043a\u0440\u0438\u0432\u0430 \u0434\u0438\u043c", + "sound": "{entity_name} \u0437\u0430\u043f\u043e\u0447\u043d\u0430 \u0434\u0430 \u0437\u0430\u0441\u0438\u0447\u0430 \u0437\u0432\u0443\u043a", + "turned_off": "{entity_name} \u0435 \u0438\u0437\u043a\u043b\u044e\u0447\u0435\u043d", + "turned_on": "{entity_name} \u0435 \u0432\u043a\u043b\u044e\u0447\u0435\u043d", + "unsafe": "{entity_name} \u0441\u0442\u0430\u043d\u0430 \u043e\u043f\u0430\u0441\u0435\u043d", + "vibration": "{entity_name} \u0437\u0430\u043f\u043e\u0447\u043d\u0430 \u0434\u0430 \u0437\u0430\u0441\u0438\u0447\u0430 \u0432\u0438\u0431\u0440\u0430\u0446\u0438\u0438" + } + }, + "state": { + "_": { + "off": "\u0418\u0437\u043a\u043b\u044e\u0447\u0435\u043d", + "on": "\u0412\u043a\u043b\u044e\u0447\u0435\u043d" + }, + "battery": { + "off": "\u041d\u043e\u0440\u043c\u0430\u043b\u043d\u0430", + "on": "\u0418\u0437\u0442\u043e\u0449\u0435\u043d\u0430" + }, + "cold": { + "off": "\u041d\u043e\u0440\u043c\u0430\u043b\u043d\u043e", + "on": "\u0421\u0442\u0443\u0434\u0435\u043d\u043e" + }, + "connectivity": { + "off": "\u0418\u0437\u043a\u043b\u044e\u0447\u0435\u043d", + "on": "\u0421\u0432\u044a\u0440\u0437\u0430\u043d" + }, + "door": { + "off": "\u0417\u0430\u0442\u0432\u043e\u0440\u0435\u043d\u0430", + "on": "\u041e\u0442\u0432\u043e\u0440\u0435\u043d\u0430" + }, + "garage_door": { + "off": "\u0417\u0430\u0442\u0432\u043e\u0440\u0435\u043d\u0430", + "on": "\u041e\u0442\u0432\u043e\u0440\u0435\u043d\u0430" + }, + "gas": { + "off": "\u0427\u0438\u0441\u0442\u043e", + "on": "\u0420\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u0430\u043d" + }, + "heat": { + "off": "\u041d\u043e\u0440\u043c\u0430\u043b\u043d\u043e", + "on": "\u0413\u043e\u0440\u0435\u0449\u043e" + }, + "lock": { + "off": "\u0417\u0430\u043a\u043b\u044e\u0447\u0435\u043d\u043e", + "on": "\u041e\u0442\u043a\u043b\u044e\u0447\u0435\u043d\u043e" + }, + "moisture": { + "off": "\u0421\u0443\u0445", + "on": "\u041c\u043e\u043a\u044a\u0440" + }, + "motion": { + "off": "\u0411\u0435\u0437 \u0434\u0432\u0438\u0436\u0435\u043d\u0438\u0435", + "on": "\u0414\u0432\u0438\u0436\u0435\u043d\u0438\u0435" + }, + "occupancy": { + "off": "\u0427\u0438\u0441\u0442\u043e", + "on": "\u0420\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u0430\u043d" + }, + "opening": { + "off": "\u0417\u0430\u0442\u0432\u043e\u0440\u0435\u043d", + "on": "\u041e\u0442\u0432\u043e\u0440\u0435\u043d" + }, + "presence": { + "off": "\u041e\u0442\u0441\u044a\u0441\u0442\u0432\u0430", + "on": "\u0412\u043a\u044a\u0449\u0438" + }, + "problem": { + "off": "\u041e\u041a", + "on": "\u041f\u0440\u043e\u0431\u043b\u0435\u043c" + }, + "safety": { + "off": "\u0411\u0435\u0437\u043e\u043f\u0430\u0441\u0435\u043d", + "on": "\u041e\u043f\u0430\u0441\u043d\u043e\u0441\u0442" + }, + "smoke": { + "off": "\u0427\u0438\u0441\u0442\u043e", + "on": "\u0420\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u0430\u043d" + }, + "sound": { + "off": "\u0427\u0438\u0441\u0442\u043e", + "on": "\u0420\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u0430\u043d" + }, + "vibration": { + "off": "\u0427\u0438\u0441\u0442\u043e", + "on": "\u0420\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u0430\u043d\u0430" + }, + "window": { + "off": "\u0417\u0430\u0442\u0432\u043e\u0440\u0435\u043d", + "on": "\u041e\u0442\u0432\u043e\u0440\u0435\u043d" + } + }, + "title": "\u0414\u0432\u043e\u0438\u0447\u0435\u043d \u0441\u0435\u043d\u0437\u043e\u0440" +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/translations/bs.json b/homeassistant/components/binary_sensor/translations/bs.json new file mode 100644 index 0000000000000..58975af616be0 --- /dev/null +++ b/homeassistant/components/binary_sensor/translations/bs.json @@ -0,0 +1,61 @@ +{ + "state": { + "_": { + "off": "Isklju\u010den", + "on": "Uklju\u010den" + }, + "battery": { + "off": "Normalno", + "on": "Nisko" + }, + "connectivity": { + "off": "Nepovezan", + "on": "Povezan" + }, + "gas": { + "off": "\u010cist", + "on": "Otkriven" + }, + "moisture": { + "off": "Suho", + "on": "Mokar" + }, + "motion": { + "off": "\u010cist", + "on": "Otkriven" + }, + "occupancy": { + "off": "\u010cist", + "on": "Otkriven" + }, + "opening": { + "off": "Zatvoren", + "on": "Otvoren" + }, + "presence": { + "off": "Odsutan", + "on": "Kod ku\u0107e" + }, + "problem": { + "off": "OK", + "on": "Problem" + }, + "safety": { + "off": "Siguran", + "on": "Nesiguran" + }, + "smoke": { + "off": "\u010cist", + "on": "Otkriven" + }, + "sound": { + "off": "\u010cist", + "on": "Otkriven" + }, + "vibration": { + "off": "\u010cist", + "on": "Otkriven" + } + }, + "title": "Binarni senzor" +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/translations/ca.json b/homeassistant/components/binary_sensor/translations/ca.json new file mode 100644 index 0000000000000..995b5906c538e --- /dev/null +++ b/homeassistant/components/binary_sensor/translations/ca.json @@ -0,0 +1,175 @@ +{ + "device_automation": { + "condition_type": { + "is_bat_low": "Bateria de {entity_name} baixa", + "is_cold": "{entity_name} est\u00e0 fred", + "is_connected": "{entity_name} est\u00e0 connectat", + "is_gas": "{entity_name} est\u00e0 detectant gas", + "is_hot": "{entity_name} est\u00e0 calent", + "is_light": "{entity_name} est\u00e0 detectant llum", + "is_locked": "{entity_name} est\u00e0 bloquejat", + "is_moist": "{entity_name} est\u00e0 humit", + "is_motion": "{entity_name} est\u00e0 detectant moviment", + "is_moving": "{entity_name} s'est\u00e0 movent", + "is_no_gas": "{entity_name} no detecta gas", + "is_no_light": "{entity_name} no detecta llum", + "is_no_motion": "{entity_name} no detecta moviment", + "is_no_problem": "{entity_name} no est\u00e0 detectant cap problema", + "is_no_smoke": "{entity_name} no detecta fum", + "is_no_sound": "{entity_name} no detecta so", + "is_no_vibration": "{entity_name} no detecta vibraci\u00f3", + "is_not_bat_low": "Bateria de {entity_name} normal", + "is_not_cold": "{entity_name} no est\u00e0 fred", + "is_not_connected": "{entity_name} est\u00e0 desconnectat", + "is_not_hot": "{entity_name} no est\u00e0 calent", + "is_not_locked": "{entity_name} est\u00e0 desbloquejat", + "is_not_moist": "{entity_name} est\u00e0 sec", + "is_not_moving": "{entity_name} no s'est\u00e0 movent", + "is_not_occupied": "{entity_name} no est\u00e0 ocupat", + "is_not_open": "{entity_name} est\u00e0 tancat", + "is_not_plugged_in": "{entity_name} est\u00e0 desendollat", + "is_not_powered": "{entity_name} no est\u00e0 alimentat", + "is_not_present": "{entity_name} no est\u00e0 present", + "is_not_unsafe": "{entity_name} \u00e9s segur", + "is_occupied": "{entity_name} est\u00e0 ocupat", + "is_off": "{entity_name} est\u00e0 apagat", + "is_on": "{entity_name} est\u00e0 enc\u00e8s", + "is_open": "{entity_name} est\u00e0 obert", + "is_plugged_in": "{entity_name} est\u00e0 endollat", + "is_powered": "{entity_name} est\u00e0 alimentat", + "is_present": "{entity_name} est\u00e0 present", + "is_problem": "{entity_name} est\u00e0 detectant un problema", + "is_smoke": "{entity_name} est\u00e0 detectant fum", + "is_sound": "{entity_name} est\u00e0 detectant so", + "is_unsafe": "{entity_name} \u00e9s insegur", + "is_vibration": "{entity_name} est\u00e0 detectant vibraci\u00f3" + }, + "trigger_type": { + "bat_low": "Bateria de {entity_name} baixa", + "cold": "{entity_name} es torna fred", + "connected": "{entity_name} est\u00e0 connectat", + "gas": "{entity_name} ha comen\u00e7at a detectar gas", + "hot": "{entity_name} es torna calent", + "light": "{entity_name} ha comen\u00e7at a detectar llum", + "locked": "{entity_name} est\u00e0 bloquejat", + "moist": "{entity_name} es torna humit", + "motion": "{entity_name} ha comen\u00e7at a detectar moviment", + "moving": "{entity_name} ha comen\u00e7at a moure's", + "no_gas": "{entity_name} ha deixat de detectar gas", + "no_light": "{entity_name} ha deixat de detectar llum", + "no_motion": "{entity_name} ha deixat de detectar moviment", + "no_problem": "{entity_name} ha deixat de detectar un problema", + "no_smoke": "{entity_name} ha deixat de detectar fum", + "no_sound": "{entity_name} ha deixat de detectar so", + "no_vibration": "{entity_name} ha deixat de detectar vibraci\u00f3", + "not_bat_low": "Bateria de {entity_name} normal", + "not_cold": "{entity_name} es torna no-fred", + "not_connected": "{entity_name} est\u00e0 desconnectat", + "not_hot": "{entity_name} es torna no-calent", + "not_locked": "{entity_name} est\u00e0 desbloquejat", + "not_moist": "{entity_name} es torna sec", + "not_moving": "{entity_name} ha parat de moure's", + "not_occupied": "{entity_name} es desocupa", + "not_opened": "{entity_name} es tanca", + "not_plugged_in": "{entity_name} desendollat", + "not_powered": "{entity_name} no est\u00e0 alimentat", + "not_present": "{entity_name} no est\u00e0 present", + "not_unsafe": "{entity_name} es torna segur", + "occupied": "{entity_name} s'ocupa", + "opened": "{entity_name} s'ha obert", + "plugged_in": "{entity_name} s'ha endollat", + "powered": "{entity_name} alimentat", + "present": "{entity_name} present", + "problem": "{entity_name} ha comen\u00e7at a detectar un problema", + "smoke": "{entity_name} ha comen\u00e7at a detectar fum", + "sound": "{entity_name} ha comen\u00e7at a detectar so", + "turned_off": "{entity_name} apagat", + "turned_on": "{entity_name} enc\u00e8s", + "unsafe": "{entity_name} es torna insegur", + "vibration": "{entity_name} ha comen\u00e7at a detectar vibraci\u00f3" + } + }, + "state": { + "_": { + "off": "Desactivat", + "on": "Activat" + }, + "battery": { + "off": "Normal", + "on": "Baixa" + }, + "cold": { + "off": "Normal", + "on": "Fred" + }, + "connectivity": { + "off": "Desconnectat", + "on": "Connectat" + }, + "door": { + "off": "Tancada", + "on": "Oberta" + }, + "garage_door": { + "off": "Tancada", + "on": "Oberta" + }, + "gas": { + "off": "Lliure", + "on": "Detectat" + }, + "heat": { + "off": "Normal", + "on": "Calent" + }, + "lock": { + "off": "Bloquejat", + "on": "Desbloquejat" + }, + "moisture": { + "off": "Sec", + "on": "Humit" + }, + "motion": { + "off": "Lliure", + "on": "Detectat" + }, + "occupancy": { + "off": "Lliure", + "on": "Detectat" + }, + "opening": { + "off": "Tancat", + "on": "Obert" + }, + "presence": { + "off": "Lliure", + "on": "Detectat" + }, + "problem": { + "off": "Correcte", + "on": "Problema" + }, + "safety": { + "off": "Segur", + "on": "No segur" + }, + "smoke": { + "off": "Lliure", + "on": "Detectat" + }, + "sound": { + "off": "Lliure", + "on": "Detectat" + }, + "vibration": { + "off": "Lliure", + "on": "Detectat" + }, + "window": { + "off": "Tancada", + "on": "Oberta" + } + }, + "title": "Sensor binari" +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/translations/cs.json b/homeassistant/components/binary_sensor/translations/cs.json new file mode 100644 index 0000000000000..c3ace898a8b00 --- /dev/null +++ b/homeassistant/components/binary_sensor/translations/cs.json @@ -0,0 +1,91 @@ +{ + "device_automation": { + "trigger_type": { + "moist": "{entity_name} se navlh\u010dil", + "not_opened": "{entity_name} uzav\u0159eno" + } + }, + "state": { + "_": { + "off": "Neaktivn\u00ed", + "on": "Aktivn\u00ed" + }, + "battery": { + "off": "Norm\u00e1ln\u00ed", + "on": "N\u00edzk\u00fd stav" + }, + "cold": { + "off": "Norm\u00e1ln\u00ed", + "on": "Chladn\u00e9" + }, + "connectivity": { + "off": "Odpojeno", + "on": "P\u0159ipojeno" + }, + "door": { + "off": "Zav\u0159eno", + "on": "Otev\u0159eno" + }, + "garage_door": { + "off": "Zav\u0159eno", + "on": "Otev\u0159eno" + }, + "gas": { + "off": "\u017d\u00e1dn\u00fd plyn", + "on": "Zji\u0161t\u011bn plyn" + }, + "heat": { + "off": "Norm\u00e1ln\u00ed", + "on": "Hork\u00e9" + }, + "lock": { + "off": "Zam\u010deno", + "on": "Odem\u010deno" + }, + "moisture": { + "off": "Sucho", + "on": "Vlhko" + }, + "motion": { + "off": "Bez pohybu", + "on": "Zaznamen\u00e1n pohyb" + }, + "occupancy": { + "off": "Volno", + "on": "Obsazeno" + }, + "opening": { + "off": "Zav\u0159eno", + "on": "Otev\u0159eno" + }, + "presence": { + "off": "Pry\u010d", + "on": "Doma" + }, + "problem": { + "off": "V po\u0159\u00e1dku", + "on": "Probl\u00e9m" + }, + "safety": { + "off": "Zaji\u0161t\u011bno", + "on": "Nezaji\u0161t\u011bno" + }, + "smoke": { + "off": "\u017d\u00e1dn\u00fd d\u00fdm", + "on": "Zji\u0161t\u011bn d\u00fdm" + }, + "sound": { + "off": "Ticho", + "on": "Zachycen zvuk" + }, + "vibration": { + "off": "Klid", + "on": "Zji\u0161t\u011bny vibrace" + }, + "window": { + "off": "Zav\u0159eno", + "on": "Otev\u0159eno" + } + }, + "title": "Bin\u00e1rn\u00ed senzor" +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/translations/cy.json b/homeassistant/components/binary_sensor/translations/cy.json new file mode 100644 index 0000000000000..d28227d7c39c2 --- /dev/null +++ b/homeassistant/components/binary_sensor/translations/cy.json @@ -0,0 +1,85 @@ +{ + "state": { + "_": { + "off": "i ffwrdd", + "on": "Ar" + }, + "battery": { + "off": "Arferol", + "on": "Isel" + }, + "cold": { + "off": "Arferol", + "on": "Oer" + }, + "connectivity": { + "off": "Wedi datgysylltu", + "on": "Cysylltiedig" + }, + "door": { + "off": "Cau", + "on": "Agor" + }, + "garage_door": { + "off": "Cau", + "on": "Agor" + }, + "gas": { + "off": "Clir", + "on": "Wedi'i ganfod" + }, + "heat": { + "off": "Arferol", + "on": "Poeth" + }, + "lock": { + "off": "Cloi", + "on": "Dad-gloi" + }, + "moisture": { + "off": "Sych", + "on": "Gwlyb" + }, + "motion": { + "off": "Clir", + "on": "Wedi'i ganfod" + }, + "occupancy": { + "off": "Clir", + "on": "Wedi'i ganfod" + }, + "opening": { + "off": "Cau", + "on": "Agor" + }, + "presence": { + "off": "Allan", + "on": "Gartref" + }, + "problem": { + "off": "iawn", + "on": "Problem" + }, + "safety": { + "off": "Diogel", + "on": "Anniogel" + }, + "smoke": { + "off": "Clir", + "on": "Wedi'i ganfod" + }, + "sound": { + "off": "Clir", + "on": "Wedi'i ganfod" + }, + "vibration": { + "off": "Clir", + "on": "Wedi'i ganfod" + }, + "window": { + "off": "Cau", + "on": "Agored" + } + }, + "title": "Synhwyrydd deuaidd" +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/translations/da.json b/homeassistant/components/binary_sensor/translations/da.json new file mode 100644 index 0000000000000..7215c5a355675 --- /dev/null +++ b/homeassistant/components/binary_sensor/translations/da.json @@ -0,0 +1,175 @@ +{ + "device_automation": { + "condition_type": { + "is_bat_low": "{entity_name} batteri er lavt", + "is_cold": "{entity_name} er kold", + "is_connected": "{entity_name} er tilsluttet", + "is_gas": "{entity_name} registrerer gas", + "is_hot": "{entity_name} er varm", + "is_light": "{entity_name} registrerer lys", + "is_locked": "{entity_name} er l\u00e5st", + "is_moist": "{entity_name} er fugtig", + "is_motion": "{entity_name} registrerer bev\u00e6gelse", + "is_moving": "{entity_name} bev\u00e6ger sig", + "is_no_gas": "{entity_name} registrerer ikke gas", + "is_no_light": "{entity_name} registrerer ikke lys", + "is_no_motion": "{entity_name} registrerer ikke bev\u00e6gelse", + "is_no_problem": "{entity_name} registrerer ikke noget problem", + "is_no_smoke": "{entity_name} registrerer ikke r\u00f8g", + "is_no_sound": "{entity_name} registrerer ikke lyd", + "is_no_vibration": "{entity_name} registrerer ikke vibration", + "is_not_bat_low": "{entity_name} batteri er normalt", + "is_not_cold": "{entity_name} er ikke kold", + "is_not_connected": "{entity_name} er afbrudt", + "is_not_hot": "{entity_name} er ikke varm", + "is_not_locked": "{entity_name} er l\u00e5st op", + "is_not_moist": "{entity_name} er t\u00f8r", + "is_not_moving": "{entity_name} bev\u00e6ger sig ikke", + "is_not_occupied": "{entity_name} er ikke optaget", + "is_not_open": "{entity_name} er lukket", + "is_not_plugged_in": "{entity_name} er ikke tilsluttet str\u00f8m", + "is_not_powered": "{entity_name} er ikke tilsluttet str\u00f8m", + "is_not_present": "{entity_name} er ikke til stede", + "is_not_unsafe": "{entity_name} er sikker", + "is_occupied": "{entity_name} er optaget", + "is_off": "{entity_name} er sl\u00e5et fra", + "is_on": "{entity_name} er sl\u00e5et til", + "is_open": "{entity_name} er \u00e5ben", + "is_plugged_in": "{entity_name} er tilsluttet str\u00f8m", + "is_powered": "{entity_name} er tilsluttet str\u00f8m", + "is_present": "{entity_name} er til stede", + "is_problem": "{entity_name} registrerer problem", + "is_smoke": "{entity_name} registrerer r\u00f8g", + "is_sound": "{entity_name} registrerer lyd", + "is_unsafe": "{entity_name} er usikker", + "is_vibration": "{entity_name} registrerer vibration" + }, + "trigger_type": { + "bat_low": "{entity_name} lavt batteriniveau", + "cold": "{entity_name} blev kold", + "connected": "{entity_name} tilsluttet", + "gas": "{entity_name} begyndte at registrere gas", + "hot": "{entity_name} blev varm", + "light": "{entity_name} begyndte at registrere lys", + "locked": "{entity_name} l\u00e5st", + "moist": "{entity_name} blev fugtig", + "motion": "{entity_name} begyndte at registrere bev\u00e6gelse", + "moving": "{entity_name} begyndte at bev\u00e6ge sig", + "no_gas": "{entity_name} stoppede med at registrere gas", + "no_light": "{entity_name} stoppede med at registrere lys", + "no_motion": "{entity_name} stoppede med at registrere bev\u00e6gelse", + "no_problem": "{entity_name} stoppede med at registrere problem", + "no_smoke": "{entity_name} stoppede med at registrere r\u00f8g", + "no_sound": "{entity_name} stoppede med at registrere lyd", + "no_vibration": "{entity_name} stoppede med at registrere vibration", + "not_bat_low": "{entity_name} batteri normalt", + "not_cold": "{entity_name} blev ikke kold", + "not_connected": "{entity_name} afbrudt", + "not_hot": "{entity_name} blev ikke varm", + "not_locked": "{entity_name} l\u00e5st op", + "not_moist": "{entity_name} blev t\u00f8r", + "not_moving": "{entity_name} stoppede med at bev\u00e6ge sig", + "not_occupied": "{entity_name} blev ikke optaget", + "not_opened": "{entity_name} lukket", + "not_plugged_in": "{entity_name} ikke tilsluttet str\u00f8m", + "not_powered": "{entity_name} ikke tilsluttet str\u00f8m", + "not_present": "{entity_name} ikke til stede", + "not_unsafe": "{entity_name} blev sikker", + "occupied": "{entity_name} blev optaget", + "opened": "{entity_name} \u00e5bnet", + "plugged_in": "{entity_name} tilsluttet str\u00f8m", + "powered": "{entity_name} tilsluttet str\u00f8m", + "present": "{entity_name} til stede", + "problem": "{entity_name} begyndte at registrere problem", + "smoke": "{entity_name} begyndte at registrere r\u00f8g", + "sound": "{entity_name} begyndte at registrere lyd", + "turned_off": "{entity_name} slukkede", + "turned_on": "{entity_name} t\u00e6ndte", + "unsafe": "{entity_name} blev usikker", + "vibration": "{entity_name} begyndte at registrere vibration" + } + }, + "state": { + "_": { + "off": "Fra", + "on": "Til" + }, + "battery": { + "off": "Normal", + "on": "Lav" + }, + "cold": { + "off": "Normal", + "on": "Kold" + }, + "connectivity": { + "off": "Afbrudt", + "on": "Forbundet" + }, + "door": { + "off": "Lukket", + "on": "\u00c5ben" + }, + "garage_door": { + "off": "Lukket", + "on": "\u00c5ben" + }, + "gas": { + "off": "Ikke registreret", + "on": "Registreret" + }, + "heat": { + "off": "Normal", + "on": "Varm" + }, + "lock": { + "off": "L\u00e5st", + "on": "Ul\u00e5st" + }, + "moisture": { + "off": "T\u00f8r", + "on": "Fugtig" + }, + "motion": { + "off": "Ikke registreret", + "on": "Registreret" + }, + "occupancy": { + "off": "Ikke registreret", + "on": "Registreret" + }, + "opening": { + "off": "Lukket", + "on": "\u00c5ben" + }, + "presence": { + "off": "Ude", + "on": "Hjemme" + }, + "problem": { + "off": "OK", + "on": "Problem" + }, + "safety": { + "off": "Sikret", + "on": "Usikret" + }, + "smoke": { + "off": "Ikke registreret", + "on": "Registreret" + }, + "sound": { + "off": "Ikke registreret", + "on": "Registreret" + }, + "vibration": { + "off": "Ikke registreret", + "on": "Registreret" + }, + "window": { + "off": "Lukket", + "on": "\u00c5ben" + } + }, + "title": "Bin\u00e6r sensor" +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/translations/de.json b/homeassistant/components/binary_sensor/translations/de.json new file mode 100644 index 0000000000000..3687536eb5b66 --- /dev/null +++ b/homeassistant/components/binary_sensor/translations/de.json @@ -0,0 +1,175 @@ +{ + "device_automation": { + "condition_type": { + "is_bat_low": "{entity_name} Batterie ist schwach", + "is_cold": "{entity_name} ist kalt", + "is_connected": "{entity_name} ist verbunden", + "is_gas": "{entity_name} erkennt Gas", + "is_hot": "{entity_name} ist hei\u00df", + "is_light": "{entity_name} erkennt Licht", + "is_locked": "{entity_name} ist gesperrt", + "is_moist": "{entity_name} ist feucht", + "is_motion": "{entity_name} erkennt Bewegung", + "is_moving": "{entity_name} bewegt sich", + "is_no_gas": "{entity_name} erkennt kein Gas", + "is_no_light": "{entity_name} erkennt kein Licht", + "is_no_motion": "{entity_name} erkennt keine Bewegung", + "is_no_problem": "{entity_name} erkennt kein Problem", + "is_no_smoke": "{entity_name} erkennt keinen Rauch", + "is_no_sound": "{entity_name} erkennt keine Ger\u00e4usche", + "is_no_vibration": "{entity_name} erkennt keine Vibrationen", + "is_not_bat_low": "{entity_name} Batterie ist normal", + "is_not_cold": "{entity_name} ist nicht kalt", + "is_not_connected": "{entity_name} ist nicht verbunden", + "is_not_hot": "{entity_name} ist nicht hei\u00df", + "is_not_locked": "{entity_name} ist entsperrt", + "is_not_moist": "{entity_name} ist trocken", + "is_not_moving": "{entity_name} bewegt sich nicht", + "is_not_occupied": "{entity_name} ist nicht besch\u00e4ftigt / besetzt", + "is_not_open": "{entity_name} ist geschlossen", + "is_not_plugged_in": "{entity_name} ist nicht angeschlossen", + "is_not_powered": "{entity_name} wird nicht mit Strom versorgt", + "is_not_present": "{entity_name} ist nicht vorhanden", + "is_not_unsafe": "{entity_name} ist sicher", + "is_occupied": "{entity_name} ist besch\u00e4ftigt / besetzt", + "is_off": "{entity_name} ist ausgeschaltet", + "is_on": "{entity_name} ist eingeschaltet", + "is_open": "{entity_name} ist offen", + "is_plugged_in": "{entity_name} ist eingesteckt", + "is_powered": "{entity_name} wird mit Strom versorgt", + "is_present": "{entity_name} ist vorhanden", + "is_problem": "{entity_name} hat ein Problem festgestellt", + "is_smoke": "{entity_name} hat Rauch detektiert", + "is_sound": "{entity_name} hat Ger\u00e4usche detektiert", + "is_unsafe": "{entity_name} ist unsicher", + "is_vibration": "{entity_name} erkennt Vibrationen." + }, + "trigger_type": { + "bat_low": "{entity_name} Batterie schwach", + "cold": "{entity_name} wurde kalt", + "connected": "{entity_name} verbunden", + "gas": "{entity_name} hat Gas detektiert", + "hot": "{entity_name} wurde hei\u00df", + "light": "{entity_name} hat Licht detektiert", + "locked": "{entity_name} gesperrt", + "moist": "{entity_name} wurde feucht", + "motion": "{entity_name} hat Bewegungen detektiert", + "moving": "{entity_name} hat angefangen sich zu bewegen", + "no_gas": "{entity_name} hat kein Gas mehr erkannt", + "no_light": "{entity_name} hat kein Licht mehr erkannt", + "no_motion": "{entity_name} hat keine Bewegung mehr erkannt", + "no_problem": "{entity_name} hat kein Problem mehr erkannt", + "no_smoke": "{entity_name} hat keinen Rauch mehr erkannt", + "no_sound": "{entity_name} hat keine Ger\u00e4usche mehr erkannt", + "no_vibration": "{entity_name}hat keine Vibrationen mehr erkannt", + "not_bat_low": "{entity_name} Batterie normal", + "not_cold": "{entity_name} w\u00e4rmte auf", + "not_connected": "{entity_name} getrennt", + "not_hot": "{entity_name} k\u00fchlte ab", + "not_locked": "{entity_name} entsperrt", + "not_moist": "{entity_name} wurde trocken", + "not_moving": "{entity_name} bewegt sich nicht mehr", + "not_occupied": "{entity_name} wurde frei / inaktiv", + "not_opened": "{entity_name} geschlossen", + "not_plugged_in": "{entity_name} ist nicht angeschlossen", + "not_powered": "{entity_name} nicht mit Strom versorgt", + "not_present": "{entity_name} nicht anwesend", + "not_unsafe": "{entity_name} wurde sicher", + "occupied": "{entity_name} wurde besch\u00e4ftigt / besetzt", + "opened": "{entity_name} ge\u00f6ffnet", + "plugged_in": "{entity_name} eingesteckt", + "powered": "{entity_name} wird mit Strom versorgt", + "present": "{entity_name} anwesend", + "problem": "{entity_name} hat ein Problem festgestellt", + "smoke": "{entity_name} detektiert Rauch", + "sound": "{entity_name} detektiert Ger\u00e4usche", + "turned_off": "{entity_name} ausgeschaltet", + "turned_on": "{entity_name} eingeschaltet", + "unsafe": "{entity_name} ist unsicher", + "vibration": "{entity_name} detektiert Vibrationen" + } + }, + "state": { + "_": { + "off": "Aus", + "on": "An" + }, + "battery": { + "off": "Normal", + "on": "Schwach" + }, + "cold": { + "off": "Normal", + "on": "Kalt" + }, + "connectivity": { + "off": "Getrennt", + "on": "Verbunden" + }, + "door": { + "off": "Geschlossen", + "on": "Offen" + }, + "garage_door": { + "off": "Geschlossen", + "on": "Offen" + }, + "gas": { + "off": "Normal", + "on": "Erkannt" + }, + "heat": { + "off": "Normal", + "on": "Hei\u00df" + }, + "lock": { + "off": "Verriegelt", + "on": "Entriegelt" + }, + "moisture": { + "off": "Trocken", + "on": "Nass" + }, + "motion": { + "off": "Ruhig", + "on": "Bewegung erkannt" + }, + "occupancy": { + "off": "Frei", + "on": "Belegt" + }, + "opening": { + "off": "Geschlossen", + "on": "Offen" + }, + "presence": { + "off": "Abwesend", + "on": "Zu Hause" + }, + "problem": { + "off": "OK", + "on": "Problem" + }, + "safety": { + "off": "Sicher", + "on": "Unsicher" + }, + "smoke": { + "off": "OK", + "on": "Rauch erkannt" + }, + "sound": { + "off": "Stille", + "on": "Ger\u00e4usch erkannt" + }, + "vibration": { + "off": "Normal", + "on": "Vibration" + }, + "window": { + "off": "Geschlossen", + "on": "Offen" + } + }, + "title": "Bin\u00e4rsensor" +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/translations/el.json b/homeassistant/components/binary_sensor/translations/el.json new file mode 100644 index 0000000000000..f4ed1d55bc240 --- /dev/null +++ b/homeassistant/components/binary_sensor/translations/el.json @@ -0,0 +1,85 @@ +{ + "state": { + "_": { + "off": "\u0391\u03bd\u03b5\u03bd\u03b5\u03c1\u03b3\u03cc\u03c2", + "on": "\u0395\u03bd\u03b5\u03c1\u03b3\u03cc\u03c2" + }, + "battery": { + "off": "\u039a\u03b1\u03bd\u03bf\u03bd\u03b9\u03ba\u03cc\u03c2", + "on": "\u03a7\u03b1\u03bc\u03b7\u03bb\u03cc\u03c2" + }, + "cold": { + "off": "\u03a6\u03c5\u03c3\u03b9\u03bf\u03bb\u03bf\u03b3\u03b9\u03ba\u03cc", + "on": "\u039a\u03c1\u03cd\u03bf" + }, + "connectivity": { + "off": "\u0391\u03c0\u03bf\u03c3\u03cd\u03bd\u03b4\u03b5\u03c3\u03b7", + "on": "\u03a3\u03c5\u03bd\u03b4\u03b5\u03b4\u03b5\u03bc\u03ad\u03bd\u03bf\u03c2" + }, + "door": { + "off": "\u039a\u03bb\u03b5\u03b9\u03c3\u03c4\u03ae", + "on": "\u0391\u03bd\u03bf\u03b9\u03c7\u03c4\u03ae" + }, + "garage_door": { + "off": "\u039a\u03bb\u03b5\u03b9\u03c3\u03c4\u03cc", + "on": "\u0386\u03bd\u03bf\u03b9\u03b3\u03bc\u03b1" + }, + "gas": { + "off": "\u0394\u03b5\u03bd \u0395\u03bd\u03c4\u03bf\u03c0\u03af\u03c3\u03c4\u03b7\u03ba\u03b5", + "on": "\u0395\u03bd\u03c4\u03bf\u03c0\u03af\u03c3\u03c4\u03b7\u03ba\u03b5" + }, + "heat": { + "off": "\u03a6\u03c5\u03c3\u03b9\u03bf\u03bb\u03bf\u03b3\u03b9\u03ba\u03cc", + "on": "\u039a\u03b1\u03c5\u03c4\u03cc" + }, + "lock": { + "off": "\u039a\u03bb\u03b5\u03b9\u03b4\u03c9\u03bc\u03ad\u03bd\u03bf", + "on": "\u039e\u03b5\u03ba\u03bb\u03b5\u03af\u03b4\u03c9\u03c4\u03bf" + }, + "moisture": { + "off": "\u039e\u03b7\u03c1\u03cc", + "on": "\u03a5\u03b3\u03c1\u03cc" + }, + "motion": { + "off": "\u0394\u03b5\u03bd \u0395\u03bd\u03c4\u03bf\u03c0\u03af\u03c3\u03c4\u03b7\u03ba\u03b5", + "on": "\u0395\u03bd\u03c4\u03bf\u03c0\u03af\u03c3\u03c4\u03b7\u03ba\u03b5" + }, + "occupancy": { + "off": "\u0394\u03b5\u03bd \u0395\u03bd\u03c4\u03bf\u03c0\u03af\u03c3\u03c4\u03b7\u03ba\u03b5", + "on": "\u0395\u03bd\u03c4\u03bf\u03c0\u03af\u03c3\u03c4\u03b7\u03ba\u03b5" + }, + "opening": { + "off": "\u039a\u03bb\u03b5\u03b9\u03c3\u03c4\u03cc", + "on": "\u0391\u03bd\u03bf\u03b9\u03c7\u03c4\u03cc" + }, + "presence": { + "off": "\u0395\u03ba\u03c4\u03cc\u03c2", + "on": "\u03a3\u03c0\u03af\u03c4\u03b9" + }, + "problem": { + "off": "\u0395\u03bd\u03c4\u03ac\u03be\u03b5\u03b9", + "on": "\u03a0\u03c1\u03cc\u03b2\u03bb\u03b7\u03bc\u03b1" + }, + "safety": { + "off": "\u0391\u03c3\u03c6\u03b1\u03bb\u03ae\u03c2", + "on": "\u0391\u03bd\u03b1\u03c3\u03c6\u03b1\u03bb\u03ae\u03c2" + }, + "smoke": { + "off": "\u0394\u03b5\u03bd \u0395\u03bd\u03c4\u03bf\u03c0\u03af\u03c3\u03c4\u03b7\u03ba\u03b5", + "on": "\u0395\u03bd\u03c4\u03bf\u03c0\u03af\u03c3\u03c4\u03b7\u03ba\u03b5" + }, + "sound": { + "off": "\u0394\u03b5\u03bd \u0395\u03bd\u03c4\u03bf\u03c0\u03af\u03c3\u03c4\u03b7\u03ba\u03b5", + "on": "\u0395\u03bd\u03c4\u03bf\u03c0\u03af\u03c3\u03c4\u03b7\u03ba\u03b5" + }, + "vibration": { + "off": "\u0394\u03b5\u03bd \u0395\u03bd\u03c4\u03bf\u03c0\u03af\u03c3\u03c4\u03b7\u03ba\u03b5", + "on": "\u0395\u03bd\u03c4\u03bf\u03c0\u03af\u03c3\u03c4\u03b7\u03ba\u03b5" + }, + "window": { + "off": "\u039a\u03bb\u03b5\u03b9\u03c3\u03c4\u03cc", + "on": "\u0391\u03bd\u03bf\u03b9\u03c7\u03c4\u03cc" + } + }, + "title": "\u0394\u03c5\u03b1\u03b4\u03b9\u03ba\u03cc\u03c2 \u03b1\u03b9\u03c3\u03b8\u03b7\u03c4\u03ae\u03c1\u03b1\u03c2" +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/translations/en.json b/homeassistant/components/binary_sensor/translations/en.json new file mode 100644 index 0000000000000..c9a1ad15a8be2 --- /dev/null +++ b/homeassistant/components/binary_sensor/translations/en.json @@ -0,0 +1,175 @@ +{ + "device_automation": { + "condition_type": { + "is_bat_low": "{entity_name} battery is low", + "is_cold": "{entity_name} is cold", + "is_connected": "{entity_name} is connected", + "is_gas": "{entity_name} is detecting gas", + "is_hot": "{entity_name} is hot", + "is_light": "{entity_name} is detecting light", + "is_locked": "{entity_name} is locked", + "is_moist": "{entity_name} is moist", + "is_motion": "{entity_name} is detecting motion", + "is_moving": "{entity_name} is moving", + "is_no_gas": "{entity_name} is not detecting gas", + "is_no_light": "{entity_name} is not detecting light", + "is_no_motion": "{entity_name} is not detecting motion", + "is_no_problem": "{entity_name} is not detecting problem", + "is_no_smoke": "{entity_name} is not detecting smoke", + "is_no_sound": "{entity_name} is not detecting sound", + "is_no_vibration": "{entity_name} is not detecting vibration", + "is_not_bat_low": "{entity_name} battery is normal", + "is_not_cold": "{entity_name} is not cold", + "is_not_connected": "{entity_name} is disconnected", + "is_not_hot": "{entity_name} is not hot", + "is_not_locked": "{entity_name} is unlocked", + "is_not_moist": "{entity_name} is dry", + "is_not_moving": "{entity_name} is not moving", + "is_not_occupied": "{entity_name} is not occupied", + "is_not_open": "{entity_name} is closed", + "is_not_plugged_in": "{entity_name} is unplugged", + "is_not_powered": "{entity_name} is not powered", + "is_not_present": "{entity_name} is not present", + "is_not_unsafe": "{entity_name} is safe", + "is_occupied": "{entity_name} is occupied", + "is_off": "{entity_name} is off", + "is_on": "{entity_name} is on", + "is_open": "{entity_name} is open", + "is_plugged_in": "{entity_name} is plugged in", + "is_powered": "{entity_name} is powered", + "is_present": "{entity_name} is present", + "is_problem": "{entity_name} is detecting problem", + "is_smoke": "{entity_name} is detecting smoke", + "is_sound": "{entity_name} is detecting sound", + "is_unsafe": "{entity_name} is unsafe", + "is_vibration": "{entity_name} is detecting vibration" + }, + "trigger_type": { + "bat_low": "{entity_name} battery low", + "cold": "{entity_name} became cold", + "connected": "{entity_name} connected", + "gas": "{entity_name} started detecting gas", + "hot": "{entity_name} became hot", + "light": "{entity_name} started detecting light", + "locked": "{entity_name} locked", + "moist": "{entity_name} became moist", + "motion": "{entity_name} started detecting motion", + "moving": "{entity_name} started moving", + "no_gas": "{entity_name} stopped detecting gas", + "no_light": "{entity_name} stopped detecting light", + "no_motion": "{entity_name} stopped detecting motion", + "no_problem": "{entity_name} stopped detecting problem", + "no_smoke": "{entity_name} stopped detecting smoke", + "no_sound": "{entity_name} stopped detecting sound", + "no_vibration": "{entity_name} stopped detecting vibration", + "not_bat_low": "{entity_name} battery normal", + "not_cold": "{entity_name} became not cold", + "not_connected": "{entity_name} disconnected", + "not_hot": "{entity_name} became not hot", + "not_locked": "{entity_name} unlocked", + "not_moist": "{entity_name} became dry", + "not_moving": "{entity_name} stopped moving", + "not_occupied": "{entity_name} became not occupied", + "not_opened": "{entity_name} closed", + "not_plugged_in": "{entity_name} unplugged", + "not_powered": "{entity_name} not powered", + "not_present": "{entity_name} not present", + "not_unsafe": "{entity_name} became safe", + "occupied": "{entity_name} became occupied", + "opened": "{entity_name} opened", + "plugged_in": "{entity_name} plugged in", + "powered": "{entity_name} powered", + "present": "{entity_name} present", + "problem": "{entity_name} started detecting problem", + "smoke": "{entity_name} started detecting smoke", + "sound": "{entity_name} started detecting sound", + "turned_off": "{entity_name} turned off", + "turned_on": "{entity_name} turned on", + "unsafe": "{entity_name} became unsafe", + "vibration": "{entity_name} started detecting vibration" + } + }, + "state": { + "_": { + "off": "Off", + "on": "On" + }, + "battery": { + "off": "Normal", + "on": "Low" + }, + "cold": { + "off": "Normal", + "on": "Cold" + }, + "connectivity": { + "off": "Disconnected", + "on": "Connected" + }, + "door": { + "off": "Closed", + "on": "Open" + }, + "garage_door": { + "off": "Closed", + "on": "Open" + }, + "gas": { + "off": "Clear", + "on": "Detected" + }, + "heat": { + "off": "Normal", + "on": "Hot" + }, + "lock": { + "off": "Locked", + "on": "Unlocked" + }, + "moisture": { + "off": "Dry", + "on": "Wet" + }, + "motion": { + "off": "Clear", + "on": "Detected" + }, + "occupancy": { + "off": "Clear", + "on": "Detected" + }, + "opening": { + "off": "Closed", + "on": "Open" + }, + "presence": { + "off": "Away", + "on": "Home" + }, + "problem": { + "off": "OK", + "on": "Problem" + }, + "safety": { + "off": "Safe", + "on": "Unsafe" + }, + "smoke": { + "off": "Clear", + "on": "Detected" + }, + "sound": { + "off": "Clear", + "on": "Detected" + }, + "vibration": { + "off": "Clear", + "on": "Detected" + }, + "window": { + "off": "Closed", + "on": "Open" + } + }, + "title": "Binary sensor" +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/translations/es-419.json b/homeassistant/components/binary_sensor/translations/es-419.json new file mode 100644 index 0000000000000..5bada49741e19 --- /dev/null +++ b/homeassistant/components/binary_sensor/translations/es-419.json @@ -0,0 +1,171 @@ +{ + "device_automation": { + "condition_type": { + "is_bat_low": "{entity_name} la bater\u00eda est\u00e1 baja", + "is_cold": "{entity_name} est\u00e1 fr\u00edo", + "is_connected": "{entity_name} est\u00e1 conectado", + "is_gas": "{entity_name} est\u00e1 detectando gas", + "is_hot": "{entity_name} est\u00e1 caliente", + "is_light": "{entity_name} est\u00e1 detectando luz", + "is_locked": "{entity_name} est\u00e1 bloqueado", + "is_moist": "{entity_name} est\u00e1 h\u00famedo", + "is_motion": "{entity_name} est\u00e1 detectando movimiento", + "is_moving": "{entity_name} se est\u00e1 moviendo", + "is_no_gas": "{entity_name} no detecta gas", + "is_no_light": "{entity_name} no detecta luz", + "is_no_motion": "{entity_name} no detecta movimiento", + "is_no_problem": "{entity_name} no detecta el problema", + "is_no_smoke": "{entity_name} no detecta humo", + "is_no_sound": "{entity_name} no detecta sonido", + "is_no_vibration": "{entity_name} no detecta vibraciones", + "is_not_bat_low": "{entity_name} bater\u00eda est\u00e1 normal", + "is_not_cold": "{entity_name} no est\u00e1 fr\u00edo", + "is_not_connected": "{entity_name} est\u00e1 desconectado", + "is_not_hot": "{entity_name} no est\u00e1 caliente", + "is_not_locked": "{entity_name} est\u00e1 desbloqueado", + "is_not_moist": "{entity_name} est\u00e1 seco", + "is_not_moving": "{entity_name} no se mueve", + "is_not_occupied": "{entity_name} no est\u00e1 ocupado", + "is_not_open": "{entity_name} est\u00e1 cerrado", + "is_not_plugged_in": "{entity_name} est\u00e1 desconectado", + "is_not_present": "{entity_name} no est\u00e1 presente", + "is_not_unsafe": "{entity_name} es seguro", + "is_occupied": "{entity_name} est\u00e1 ocupado", + "is_off": "{entity_name} est\u00e1 apagado", + "is_on": "{entity_name} est\u00e1 encendido", + "is_open": "{entity_name} est\u00e1 abierto", + "is_plugged_in": "{entity_name} est\u00e1 enchufado", + "is_powered": "{entity_name} est\u00e1 encendido", + "is_present": "{entity_name} est\u00e1 presente", + "is_problem": "{entity_name} est\u00e1 detectando un problema", + "is_smoke": "{entity_name} est\u00e1 detectando humo", + "is_sound": "{entity_name} est\u00e1 detectando sonido", + "is_unsafe": "{entity_name} es inseguro", + "is_vibration": "{entity_name} est\u00e1 detectando vibraciones" + }, + "trigger_type": { + "bat_low": "{entity_name} bater\u00eda baja", + "cold": "{entity_name} se enfri\u00f3", + "connected": "{entity_name} conectado", + "gas": "{entity_name} comenz\u00f3 a detectar gas", + "hot": "{entity_name} se calent\u00f3", + "light": "{entity_name} comenz\u00f3 a detectar luz", + "locked": "{entity_name} bloqueado", + "moist": "{entity_name} se humedeci\u00f3", + "motion": "{entity_name} comenz\u00f3 a detectar movimiento", + "moving": "{entity_name} comenz\u00f3 a moverse", + "no_gas": "{entity_name} dej\u00f3 de detectar gas", + "no_light": "{entity_name} dej\u00f3 de detectar luz", + "no_motion": "{entity_name} dej\u00f3 de detectar movimiento", + "no_problem": "{entity_name} dej\u00f3 de detectar problemas", + "no_smoke": "{entity_name} dej\u00f3 de detectar humo", + "no_sound": "{entity_name} dej\u00f3 de detectar sonido", + "no_vibration": "{entity_name} dej\u00f3 de detectar vibraciones", + "not_bat_low": "{entity_name} bater\u00eda normal", + "not_cold": "{entity_name} no se enfri\u00f3", + "not_connected": "{entity_name} desconectado", + "not_hot": "{entity_name} no se calent\u00f3", + "not_locked": "{entity_name} desbloqueado", + "not_moist": "{entity_name} se sec\u00f3", + "not_moving": "{entity_name} dej\u00f3 de moverse", + "not_opened": "{entity_name} cerrado", + "not_plugged_in": "{entity_name} desconectado", + "not_present": "{entity_name} no presente", + "not_unsafe": "{entity_name} se volvi\u00f3 seguro", + "occupied": "{entity_name} se ocup\u00f3", + "opened": "{entity_name} abierto", + "plugged_in": "{entity_name} enchufado", + "present": "{entity_name} presente", + "problem": "{entity_name} comenz\u00f3 a detectar problemas", + "smoke": "{entity_name} comenz\u00f3 a detectar humo", + "sound": "{entity_name} comenz\u00f3 a detectar sonido", + "turned_off": "{entity_name} apagado", + "turned_on": "{entity_name} encendido", + "unsafe": "{entity_name} se volvi\u00f3 inseguro", + "vibration": "{entity_name} comenz\u00f3 a detectar vibraciones" + } + }, + "state": { + "_": { + "off": "Desactivado", + "on": "Encendido" + }, + "battery": { + "off": "Normal", + "on": "Baja" + }, + "cold": { + "off": "Normal", + "on": "Fr\u00edo" + }, + "connectivity": { + "off": "Desconectado", + "on": "Conectado" + }, + "door": { + "off": "Cerrada", + "on": "Abierta" + }, + "garage_door": { + "off": "Cerrada", + "on": "Abierta" + }, + "gas": { + "off": "Despejado", + "on": "Detectado" + }, + "heat": { + "off": "Normal", + "on": "Caliente" + }, + "lock": { + "off": "Bloqueado", + "on": "Desbloqueado" + }, + "moisture": { + "off": "Seco", + "on": "Humedo" + }, + "motion": { + "off": "Despejado", + "on": "Detectado" + }, + "occupancy": { + "off": "Despejado", + "on": "Detectado" + }, + "opening": { + "off": "Cerrado", + "on": "Abierto" + }, + "presence": { + "off": "Fuera de casa", + "on": "En Casa" + }, + "problem": { + "off": "OK", + "on": "Problema" + }, + "safety": { + "off": "Seguro", + "on": "Inseguro" + }, + "smoke": { + "off": "Despejado", + "on": "Detectado" + }, + "sound": { + "off": "Despejado", + "on": "Detectado" + }, + "vibration": { + "off": "Despejado", + "on": "Detectado" + }, + "window": { + "off": "Cerrada", + "on": "Abierta" + } + }, + "title": "Sensor binario" +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/translations/es.json b/homeassistant/components/binary_sensor/translations/es.json new file mode 100644 index 0000000000000..75b8a33026f96 --- /dev/null +++ b/homeassistant/components/binary_sensor/translations/es.json @@ -0,0 +1,175 @@ +{ + "device_automation": { + "condition_type": { + "is_bat_low": "{entity_name} la bater\u00eda est\u00e1 baja", + "is_cold": "{entity_name} est\u00e1 fr\u00edo", + "is_connected": "{entity_name} est\u00e1 conectado", + "is_gas": "{entity_name} est\u00e1 detectando gas", + "is_hot": "{entity_name} est\u00e1 caliente", + "is_light": "{entity_name} est\u00e1 detectando luz", + "is_locked": "{entity_name} est\u00e1 bloqueado", + "is_moist": "{entity_name} est\u00e1 h\u00famedo", + "is_motion": "{entity_name} est\u00e1 detectando movimiento", + "is_moving": "{entity_name} se est\u00e1 moviendo", + "is_no_gas": "{entity_name} no detecta gas", + "is_no_light": "{entity_name} no detecta la luz", + "is_no_motion": "{entity_name} no detecta movimiento", + "is_no_problem": "{entity_name} no detecta el problema", + "is_no_smoke": "{entity_name} no detecta humo", + "is_no_sound": "{entity_name} no detecta sonido", + "is_no_vibration": "{entity_name} no detecta vibraci\u00f3n", + "is_not_bat_low": "La bater\u00eda de {entity_name} es normal", + "is_not_cold": "{entity_name} no est\u00e1 fr\u00edo", + "is_not_connected": "{entity_name} est\u00e1 desconectado", + "is_not_hot": "{entity_name} no est\u00e1 caliente", + "is_not_locked": "{entity_name} est\u00e1 desbloqueado", + "is_not_moist": "{entity_name} est\u00e1 seco", + "is_not_moving": "{entity_name} no se mueve", + "is_not_occupied": "{entity_name} no est\u00e1 ocupado", + "is_not_open": "{entity_name} est\u00e1 cerrado", + "is_not_plugged_in": "{entity_name} est\u00e1 desconectado", + "is_not_powered": "{entity_name} no tiene alimentaci\u00f3n", + "is_not_present": "{entity_name} no est\u00e1 presente", + "is_not_unsafe": "{entity_name} es seguro", + "is_occupied": "{entity_name} est\u00e1 ocupado", + "is_off": "{entity_name} est\u00e1 apagado", + "is_on": "{entity_name} est\u00e1 activado", + "is_open": "{entity_name} est\u00e1 abierto", + "is_plugged_in": "{entity_name} est\u00e1 conectado", + "is_powered": "{entity_name} est\u00e1 activado", + "is_present": "{entity_name} est\u00e1 presente", + "is_problem": "{entity_name} est\u00e1 detectando un problema", + "is_smoke": "{entity_name} est\u00e1 detectando humo", + "is_sound": "{entity_name} est\u00e1 detectando sonido", + "is_unsafe": "{entity_name} no es seguro", + "is_vibration": "{entity_name} est\u00e1 detectando vibraciones" + }, + "trigger_type": { + "bat_low": "{entity_name} bater\u00eda baja", + "cold": "{entity_name} se enfri\u00f3", + "connected": "{entity_name} conectado", + "gas": "{entity_name} empez\u00f3 a detectar gas", + "hot": "{entity_name} se est\u00e1 calentando", + "light": "{entity_name} empez\u00f3 a detectar la luz", + "locked": "{entity_name} bloqueado", + "moist": "{entity_name} se humedece", + "motion": "{entity_name} comenz\u00f3 a detectar movimiento", + "moving": "{entity_name} empez\u00f3 a moverse", + "no_gas": "{entity_name} dej\u00f3 de detectar gas", + "no_light": "{entity_name} dej\u00f3 de detectar la luz", + "no_motion": "{entity_name} dej\u00f3 de detectar movimiento", + "no_problem": "{entity_name} dej\u00f3 de detectar el problema", + "no_smoke": "{entity_name} dej\u00f3 de detectar humo", + "no_sound": "{entity_name} dej\u00f3 de detectar sonido", + "no_vibration": "{entity_name} dej\u00f3 de detectar vibraci\u00f3n", + "not_bat_low": "{entity_name} bater\u00eda normal", + "not_cold": "{entity_name} no se enfri\u00f3", + "not_connected": "{entity_name} desconectado", + "not_hot": "{entity_name} no se calent\u00f3", + "not_locked": "{entity_name} desbloqueado", + "not_moist": "{entity_name} se sec\u00f3", + "not_moving": "{entity_name} dej\u00f3 de moverse", + "not_occupied": "{entity_name} no est\u00e1 ocupado", + "not_opened": "{entity_name} cerrado", + "not_plugged_in": "{entity_name} desconectado", + "not_powered": "{entity_name} no est\u00e1 activado", + "not_present": "{entity_name} no est\u00e1 presente", + "not_unsafe": "{entity_name} se volvi\u00f3 seguro", + "occupied": "{entity_name} se convirti\u00f3 en ocupado", + "opened": "{entity_name} abierto", + "plugged_in": "{entity_name} conectado", + "powered": "{entity_name} alimentado", + "present": "{entity_name} presente", + "problem": "{entity_name} empez\u00f3 a detectar problemas", + "smoke": "{entity_name} empez\u00f3 a detectar humo", + "sound": "{entity_name} empez\u00f3 a detectar sonido", + "turned_off": "{entity_name} desactivado", + "turned_on": "{entity_name} activado", + "unsafe": "{entity_name} se volvi\u00f3 inseguro", + "vibration": "{entity_name} empez\u00f3 a detectar vibraciones" + } + }, + "state": { + "_": { + "off": "Apagado", + "on": "Encendido" + }, + "battery": { + "off": "Normal", + "on": "Bajo" + }, + "cold": { + "off": "Normal", + "on": "Frio" + }, + "connectivity": { + "off": "Desconectado", + "on": "Conectado" + }, + "door": { + "off": "Cerrada", + "on": "Abierta" + }, + "garage_door": { + "off": "Cerrada", + "on": "Abierta" + }, + "gas": { + "off": "No detectado", + "on": "Detectado" + }, + "heat": { + "off": "Normal", + "on": "Caliente" + }, + "lock": { + "off": "Bloqueado", + "on": "Desbloqueado" + }, + "moisture": { + "off": "Seco", + "on": "H\u00famedo" + }, + "motion": { + "off": "Sin movimiento", + "on": "Detectado" + }, + "occupancy": { + "off": "No detectado", + "on": "Detectado" + }, + "opening": { + "off": "Cerrado", + "on": "Abierto" + }, + "presence": { + "off": "Fuera de casa", + "on": "En casa" + }, + "problem": { + "off": "OK", + "on": "Problema" + }, + "safety": { + "off": "Seguro", + "on": "Inseguro" + }, + "smoke": { + "off": "No detectado", + "on": "Detectado" + }, + "sound": { + "off": "No detectado", + "on": "Detectado" + }, + "vibration": { + "off": "No detectado", + "on": "Detectado" + }, + "window": { + "off": "Cerrada", + "on": "Abierta" + } + }, + "title": "Sensor binario" +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/translations/et.json b/homeassistant/components/binary_sensor/translations/et.json new file mode 100644 index 0000000000000..a9da1be9ee288 --- /dev/null +++ b/homeassistant/components/binary_sensor/translations/et.json @@ -0,0 +1,85 @@ +{ + "state": { + "_": { + "off": "V\u00e4ljas", + "on": "Sees" + }, + "battery": { + "off": "Tavaline", + "on": "Madal" + }, + "cold": { + "off": "Normaalne", + "on": "Jahe" + }, + "connectivity": { + "off": "Lahti \u00fchendatud", + "on": "\u00dchendatud" + }, + "door": { + "off": "Suletud", + "on": "Avatud" + }, + "garage_door": { + "off": "Suletud", + "on": "Avatud" + }, + "gas": { + "off": "Puudub", + "on": "Tuvastatud" + }, + "heat": { + "off": "Normaalne", + "on": "Palav" + }, + "lock": { + "off": "Lukus", + "on": "Lukustamata" + }, + "moisture": { + "off": "Kuiv", + "on": "M\u00e4rg" + }, + "motion": { + "off": "Puudub", + "on": "Tuvastatud" + }, + "occupancy": { + "off": "Puudub", + "on": "Tuvastatud" + }, + "opening": { + "off": "Suletud", + "on": "Avatud" + }, + "presence": { + "off": "Eemal", + "on": "Kodus" + }, + "problem": { + "off": "OK", + "on": "Probleem" + }, + "safety": { + "off": "Ohutu", + "on": "Ohtlik" + }, + "smoke": { + "off": "Puudub", + "on": "Tuvastatud" + }, + "sound": { + "off": "Puudub", + "on": "Tuvastatud" + }, + "vibration": { + "off": "Puudub", + "on": "Tuvastatud" + }, + "window": { + "off": "Suletud", + "on": "Avatud" + } + }, + "title": "Binaarne andur" +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/translations/eu.json b/homeassistant/components/binary_sensor/translations/eu.json new file mode 100644 index 0000000000000..a60728ce6cdc2 --- /dev/null +++ b/homeassistant/components/binary_sensor/translations/eu.json @@ -0,0 +1,60 @@ +{ + "state": { + "_": { + "off": "Itzalita", + "on": "Piztuta" + }, + "battery": { + "off": "Normala", + "on": "Baxua" + }, + "cold": { + "off": "Normala", + "on": "Hotza" + }, + "connectivity": { + "off": "Deskonektatuta", + "on": "Konektatuta" + }, + "door": { + "off": "Itxita", + "on": "Ireki" + }, + "garage_door": { + "off": "Itxita", + "on": "Ireki" + }, + "heat": { + "off": "Normala", + "on": "Beroa" + }, + "lock": { + "off": "Itxita", + "on": "Irekita" + }, + "moisture": { + "off": "Lehorra", + "on": "Buztita" + }, + "opening": { + "off": "Itxita", + "on": "Ireki" + }, + "presence": { + "off": "Kanpoan", + "on": "Etxean" + }, + "problem": { + "off": "Ondo", + "on": "Arazoa" + }, + "safety": { + "off": "Babestuta" + }, + "window": { + "off": "Itxita", + "on": "Ireki" + } + }, + "title": "Sentsore bitarra" +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/translations/fa.json b/homeassistant/components/binary_sensor/translations/fa.json new file mode 100644 index 0000000000000..4fbfa928fcdef --- /dev/null +++ b/homeassistant/components/binary_sensor/translations/fa.json @@ -0,0 +1,85 @@ +{ + "state": { + "_": { + "off": "\u062e\u0627\u0645\u0648\u0634", + "on": "\u0631\u0648\u0634\u0646" + }, + "battery": { + "off": "\u0639\u0627\u062f\u06cc", + "on": "\u06a9\u0645" + }, + "cold": { + "off": "\u0639\u0627\u062f\u06cc", + "on": "\u0633\u0631\u062f" + }, + "connectivity": { + "off": "\u0642\u0637\u0639 ", + "on": "\u0645\u062a\u0635\u0644" + }, + "door": { + "off": "\u0628\u0633\u062a\u0647", + "on": "\u0628\u0627\u0632" + }, + "garage_door": { + "off": "\u0628\u0633\u062a\u0647", + "on": "\u0628\u0627\u0632" + }, + "gas": { + "off": "\u0639\u0627\u062f\u06cc", + "on": "\u0634\u0646\u0627\u0633\u0627\u06cc\u06cc \u0634\u062f" + }, + "heat": { + "off": "\u0639\u0627\u062f\u06cc", + "on": "\u062f\u0627\u063a" + }, + "lock": { + "off": "\u0642\u0641\u0644", + "on": "\u0628\u0627\u0632" + }, + "moisture": { + "off": "\u062e\u0634\u06a9", + "on": "\u0645\u0631\u0637\u0648\u0628" + }, + "motion": { + "off": "\u0639\u0627\u062f\u06cc", + "on": "\u0634\u0646\u0627\u0633\u0627\u06cc\u06cc \u0634\u062f" + }, + "occupancy": { + "off": "\u0639\u0627\u062f\u06cc", + "on": "\u0634\u0646\u0627\u0633\u0627\u06cc\u06cc \u0634\u062f" + }, + "opening": { + "off": "\u0628\u0633\u062a\u0647 \u0634\u062f\u0647", + "on": "\u0628\u0627\u0632" + }, + "presence": { + "off": "\u0628\u06cc\u0631\u0648\u0646", + "on": "\u062e\u0627\u0646\u0647" + }, + "problem": { + "off": "\u062e\u0648\u0628", + "on": "\u0645\u0634\u06a9\u0644" + }, + "safety": { + "off": "\u0627\u0645\u0646", + "on": "\u0646\u0627 \u0627\u0645\u0646" + }, + "smoke": { + "off": "\u0639\u0627\u062f\u06cc", + "on": "\u0634\u0646\u0627\u0633\u0627\u06cc\u06cc \u0634\u062f" + }, + "sound": { + "off": "\u0639\u0627\u062f\u06cc", + "on": "\u0634\u0646\u0627\u0633\u0627\u06cc\u06cc \u0634\u062f" + }, + "vibration": { + "off": "\u067e\u0627\u06a9 \u06a9\u0631\u062f\u0646", + "on": "\u0634\u0646\u0627\u0633\u0627\u06cc\u06cc \u0634\u062f" + }, + "window": { + "off": "\u0628\u0633\u062a\u0647", + "on": "\u0628\u0627\u0632" + } + }, + "title": "\u062d\u0633\u06af\u0631 \u0628\u0627\u06cc\u0646\u0631\u06cc" +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/translations/fi.json b/homeassistant/components/binary_sensor/translations/fi.json new file mode 100644 index 0000000000000..b5c65028e73b1 --- /dev/null +++ b/homeassistant/components/binary_sensor/translations/fi.json @@ -0,0 +1,85 @@ +{ + "state": { + "_": { + "off": "Pois", + "on": "P\u00e4\u00e4ll\u00e4" + }, + "battery": { + "off": "Normaali", + "on": "Alhainen" + }, + "cold": { + "off": "Normaali", + "on": "Kylm\u00e4" + }, + "connectivity": { + "off": "Ei yhteytt\u00e4", + "on": "Yhdistetty" + }, + "door": { + "off": "Suljettu", + "on": "Auki" + }, + "garage_door": { + "off": "Suljettu", + "on": "Auki" + }, + "gas": { + "off": "Pois", + "on": "Havaittu" + }, + "heat": { + "off": "Normaali", + "on": "Kuuma" + }, + "lock": { + "off": "Lukittu", + "on": "Auki" + }, + "moisture": { + "off": "Kuiva", + "on": "Kostea" + }, + "motion": { + "off": "Ei liikett\u00e4", + "on": "Havaittu" + }, + "occupancy": { + "off": "Ei liikett\u00e4", + "on": "Havaittu" + }, + "opening": { + "off": "Suljettu", + "on": "Auki" + }, + "presence": { + "off": "Poissa", + "on": "Kotona" + }, + "problem": { + "off": "OK", + "on": "Ongelma" + }, + "safety": { + "off": "Turvallinen", + "on": "Vaarallinen" + }, + "smoke": { + "off": "Ei savua", + "on": "Havaittu" + }, + "sound": { + "off": "Ei \u00e4\u00e4nt\u00e4", + "on": "Havaittu" + }, + "vibration": { + "off": "Ei v\u00e4rin\u00e4\u00e4", + "on": "Havaittu" + }, + "window": { + "off": "Suljettu", + "on": "Auki" + } + }, + "title": "Bin\u00e4\u00e4risensori" +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/translations/fr.json b/homeassistant/components/binary_sensor/translations/fr.json new file mode 100644 index 0000000000000..a27c3368923af --- /dev/null +++ b/homeassistant/components/binary_sensor/translations/fr.json @@ -0,0 +1,175 @@ +{ + "device_automation": { + "condition_type": { + "is_bat_low": "{entity_name} batterie faible", + "is_cold": "{entity_name} est froid", + "is_connected": "{entity_name} est connect\u00e9", + "is_gas": "{entity_name} d\u00e9tecte du gaz", + "is_hot": "{entity_name} est chaud", + "is_light": "{entity_name} d\u00e9tecte de la lumi\u00e8re", + "is_locked": "{entity_name} est verrouill\u00e9", + "is_moist": "{entity_name} est humide", + "is_motion": "{entity_name} d\u00e9tecte du mouvement", + "is_moving": "{entity_name} se d\u00e9place", + "is_no_gas": "{entity_name} ne d\u00e9tecte pas de gaz", + "is_no_light": "{entity_name} ne d\u00e9tecte pas de lumi\u00e8re", + "is_no_motion": "{entity_name} ne d\u00e9tecte pas de mouvement", + "is_no_problem": "{entity_name} ne d\u00e9tecte pas de probl\u00e8me", + "is_no_smoke": "{entity_name} ne d\u00e9tecte pas de fum\u00e9e", + "is_no_sound": "{entity_name} ne d\u00e9tecte pas de son", + "is_no_vibration": "{entity_name} ne d\u00e9tecte pas de vibration", + "is_not_bat_low": "{entity_name} batterie normale", + "is_not_cold": "{entity_name} n'est pas froid", + "is_not_connected": "{entity_name} est d\u00e9connect\u00e9", + "is_not_hot": "{entity_name} n'est pas chaud", + "is_not_locked": "{entity_name} est d\u00e9verrouill\u00e9", + "is_not_moist": "{entity_name} est sec", + "is_not_moving": "{entity_name} ne bouge pas", + "is_not_occupied": "{entity_name} n'est pas occup\u00e9", + "is_not_open": "{entity_name} est ferm\u00e9", + "is_not_plugged_in": "{entity_name} est d\u00e9branch\u00e9", + "is_not_powered": "{entity_name} n'est pas aliment\u00e9", + "is_not_present": "{entity_name} n'est pas pr\u00e9sent", + "is_not_unsafe": "{entity_name} est en s\u00e9curit\u00e9", + "is_occupied": "{entity_name} est occup\u00e9", + "is_off": "{entity_name} est d\u00e9sactiv\u00e9", + "is_on": "{entity_name} est activ\u00e9", + "is_open": "{entity_name} est ouvert", + "is_plugged_in": "{entity_name} est branch\u00e9", + "is_powered": "{entity_name} est aliment\u00e9", + "is_present": "{entity_name} est pr\u00e9sent", + "is_problem": "{entity_name} d\u00e9tecte un probl\u00e8me", + "is_smoke": "{entity_name} d\u00e9tecte de la fum\u00e9e", + "is_sound": "{entity_name} d\u00e9tecte du son", + "is_unsafe": "{entity_name} est dangereux", + "is_vibration": "{entity_name} d\u00e9tecte des vibrations" + }, + "trigger_type": { + "bat_low": "{entity_name} batterie faible", + "cold": "{entity_name} est devenu froid", + "connected": "{entity_name} connect\u00e9", + "gas": "{entity_name} a commenc\u00e9 \u00e0 d\u00e9tecter du gaz", + "hot": "{entity_name} est devenu chaud", + "light": "{entity_name} a commenc\u00e9 \u00e0 d\u00e9tecter la lumi\u00e8re", + "locked": "{entity_name} verrouill\u00e9", + "moist": "{entity_name} est devenu humide", + "motion": "{entity_name} a commenc\u00e9 \u00e0 d\u00e9tecter du mouvement", + "moving": "{entity_name} a commenc\u00e9 \u00e0 se d\u00e9placer", + "no_gas": "{entity_name} a arr\u00eat\u00e9 de d\u00e9tecter le gaz", + "no_light": "{entity_name} a arr\u00eat\u00e9 de d\u00e9tecter la lumi\u00e8re", + "no_motion": "{entity_name} a arr\u00eat\u00e9 de d\u00e9tecter le mouvement", + "no_problem": "{entity_name} a cess\u00e9 de d\u00e9tecter un probl\u00e8me", + "no_smoke": "{entity_name} a cess\u00e9 de d\u00e9tecter de la fum\u00e9e", + "no_sound": "{entity_name} a cess\u00e9 de d\u00e9tecter du bruit", + "no_vibration": "{entity_name} a cess\u00e9 de d\u00e9tecter des vibrations", + "not_bat_low": "{entity_name} batterie normale", + "not_cold": "{entity_name} n'est plus froid", + "not_connected": "{entity_name} d\u00e9connect\u00e9", + "not_hot": "{entity_name} n'est plus chaud", + "not_locked": "{entity_name} d\u00e9verrouill\u00e9", + "not_moist": "{entity_name} est devenu sec", + "not_moving": "{entity_name} a cess\u00e9 de bouger", + "not_occupied": "{entity_name} est devenu non occup\u00e9", + "not_opened": "{entity_name} ferm\u00e9", + "not_plugged_in": "{entity_name} d\u00e9branch\u00e9", + "not_powered": "{entity_name} non aliment\u00e9", + "not_present": "{entity_name} non pr\u00e9sent", + "not_unsafe": "{entity_name} est devenu s\u00fbr", + "occupied": "{entity_name} est devenu occup\u00e9", + "opened": "{entity_name} ouvert", + "plugged_in": "{entity_name} branch\u00e9", + "powered": "{entity_name} aliment\u00e9", + "present": "{entity_name} pr\u00e9sent", + "problem": "{entity_name} a commenc\u00e9 \u00e0 d\u00e9tecter un probl\u00e8me", + "smoke": "{entity_name} commenc\u00e9 \u00e0 d\u00e9tecter la fum\u00e9e", + "sound": "{entity_name} commenc\u00e9 \u00e0 d\u00e9tecter le son", + "turned_off": "{entity_name} est d\u00e9sactiv\u00e9", + "turned_on": "{entity_name} est activ\u00e9", + "unsafe": "{entity_name} est devenu dangereux", + "vibration": "{entity_name} a commenc\u00e9 \u00e0 d\u00e9tecter les vibrations" + } + }, + "state": { + "_": { + "off": "Inactif", + "on": "Actif" + }, + "battery": { + "off": "Normal", + "on": "Faible" + }, + "cold": { + "off": "Normale", + "on": "Froid" + }, + "connectivity": { + "off": "D\u00e9connect\u00e9", + "on": "Connect\u00e9" + }, + "door": { + "off": "Ferm\u00e9e", + "on": "Ouverte" + }, + "garage_door": { + "off": "Ferm\u00e9e", + "on": "Ouverte" + }, + "gas": { + "off": "Non d\u00e9tect\u00e9", + "on": "D\u00e9tect\u00e9" + }, + "heat": { + "off": "Normale", + "on": "Chaud" + }, + "lock": { + "off": "Verrouill\u00e9", + "on": "D\u00e9verrouill\u00e9" + }, + "moisture": { + "off": "Sec", + "on": "Humide" + }, + "motion": { + "off": "RAS", + "on": "D\u00e9tect\u00e9" + }, + "occupancy": { + "off": "RAS", + "on": "D\u00e9tect\u00e9" + }, + "opening": { + "off": "Ferm\u00e9", + "on": "Ouvert" + }, + "presence": { + "off": "Absent", + "on": "Pr\u00e9sent" + }, + "problem": { + "off": "OK", + "on": "Probl\u00e8me" + }, + "safety": { + "off": "S\u00e9curis\u00e9", + "on": "Dangereux" + }, + "smoke": { + "off": "RAS", + "on": "D\u00e9tect\u00e9" + }, + "sound": { + "off": "RAS", + "on": "D\u00e9tect\u00e9" + }, + "vibration": { + "off": "RAS", + "on": "D\u00e9tect\u00e9e" + }, + "window": { + "off": "Ferm\u00e9e", + "on": "Ouverte" + } + }, + "title": "Capteur binaire" +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/translations/gsw.json b/homeassistant/components/binary_sensor/translations/gsw.json new file mode 100644 index 0000000000000..51fdfdd3cde7b --- /dev/null +++ b/homeassistant/components/binary_sensor/translations/gsw.json @@ -0,0 +1,64 @@ +{ + "state": { + "_": { + "off": "Us", + "on": "Ah" + }, + "battery": { + "off": "Normau", + "on": "Nidrig" + }, + "connectivity": { + "off": "Trennt", + "on": "Verbunge" + }, + "gas": { + "off": "Frei", + "on": "Erk\u00e4nnt" + }, + "heat": { + "on": "Heiss" + }, + "moisture": { + "off": "Troch\u00e4", + "on": "Nass" + }, + "motion": { + "off": "Ok", + "on": "Erch\u00e4nt" + }, + "occupancy": { + "off": "Ok", + "on": "Erch\u00e4nt" + }, + "opening": { + "off": "Gschlos\u00e4", + "on": "Off\u00e4" + }, + "presence": { + "off": "Nid Dahei", + "on": "Dahei" + }, + "problem": { + "off": "OK", + "on": "Problem" + }, + "safety": { + "off": "Sicher", + "on": "Unsicher" + }, + "smoke": { + "off": "Ok", + "on": "Erch\u00e4nt" + }, + "sound": { + "off": "Ok", + "on": "Erch\u00e4nt" + }, + "vibration": { + "off": "Ok", + "on": "Erch\u00e4nt" + } + }, + "title": "Bin\u00e4re Sensor" +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/translations/he.json b/homeassistant/components/binary_sensor/translations/he.json new file mode 100644 index 0000000000000..9178d8ef64978 --- /dev/null +++ b/homeassistant/components/binary_sensor/translations/he.json @@ -0,0 +1,85 @@ +{ + "state": { + "_": { + "off": "\u05db\u05d1\u05d5\u05d9", + "on": "\u05d3\u05dc\u05d5\u05e7" + }, + "battery": { + "off": "\u05e0\u05d5\u05e8\u05de\u05dc\u05d9", + "on": "\u05e0\u05de\u05d5\u05da" + }, + "cold": { + "off": "\u05e8\u05d2\u05d9\u05dc", + "on": "\u05e7\u05b7\u05e8" + }, + "connectivity": { + "off": "\u05de\u05e0\u05d5\u05ea\u05e7", + "on": "\u05de\u05d7\u05d5\u05d1\u05e8" + }, + "door": { + "off": "\u05e1\u05d2\u05d5\u05e8\u05d4", + "on": "\u05e4\u05ea\u05d5\u05d7\u05d4" + }, + "garage_door": { + "off": "\u05e1\u05d2\u05d5\u05e8\u05d4", + "on": "\u05e4\u05ea\u05d5\u05d7\u05d4" + }, + "gas": { + "off": "\u05e0\u05e7\u05d9", + "on": "\u05d0\u05d5\u05ea\u05e8" + }, + "heat": { + "off": "\u05e8\u05d2\u05d9\u05dc", + "on": "\u05d7\u05dd" + }, + "lock": { + "off": "\u05e0\u05e2\u05d5\u05dc", + "on": "\u05dc\u05d0 \u05e0\u05e2\u05d5\u05dc" + }, + "moisture": { + "off": "\u05d9\u05d1\u05e9", + "on": "\u05e8\u05d8\u05d5\u05d1" + }, + "motion": { + "off": "\u05e0\u05e7\u05d9", + "on": "\u05d6\u05d5\u05d4\u05d4" + }, + "occupancy": { + "off": "\u05e0\u05e7\u05d9", + "on": "\u05d6\u05d5\u05d4\u05d4" + }, + "opening": { + "off": "\u05e1\u05d2\u05d5\u05e8", + "on": "\u05e4\u05ea\u05d5\u05d7" + }, + "presence": { + "off": "\u05dc\u05d0 \u05e0\u05d5\u05db\u05d7", + "on": "\u05e0\u05d5\u05db\u05d7" + }, + "problem": { + "off": "\u05d0\u05d5\u05e7\u05d9\u05d9", + "on": "\u05d1\u05e2\u05d9\u05d9\u05d4" + }, + "safety": { + "off": "\u05d1\u05d8\u05d5\u05d7", + "on": "\u05dc\u05d0 \u05d1\u05d8\u05d5\u05d7" + }, + "smoke": { + "off": "\u05e0\u05e7\u05d9", + "on": "\u05d0\u05d5\u05ea\u05e8" + }, + "sound": { + "off": "\u05e0\u05e7\u05d9", + "on": "\u05d0\u05d5\u05ea\u05e8" + }, + "vibration": { + "off": "\u05e0\u05e7\u05d9", + "on": "\u05d0\u05d5\u05ea\u05e8" + }, + "window": { + "off": "\u05e1\u05d2\u05d5\u05e8", + "on": "\u05e4\u05ea\u05d5\u05d7" + } + }, + "title": "\u05d7\u05d9\u05d9\u05e9\u05df \u05d1\u05d9\u05e0\u05d0\u05e8\u05d9" +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/translations/hi.json b/homeassistant/components/binary_sensor/translations/hi.json new file mode 100644 index 0000000000000..ca66925b6c9c6 --- /dev/null +++ b/homeassistant/components/binary_sensor/translations/hi.json @@ -0,0 +1,45 @@ +{ + "state": { + "_": { + "off": "\u092c\u0902\u0926" + }, + "battery": { + "off": "\u0938\u093e\u0927\u093e\u0930\u0923", + "on": "\u0915\u092e" + }, + "cold": { + "off": "\u0938\u093e\u0927\u093e\u0930\u0923", + "on": "\u0938\u0930\u094d\u0926\u0940" + }, + "connectivity": { + "off": "\u0921\u093f\u0938\u094d\u0915\u0928\u0947\u0915\u094d\u091f \u0915\u093f\u092f\u093e \u0917\u092f\u093e", + "on": "\u091c\u0941\u0921\u093c\u0947 \u0939\u0941\u090f" + }, + "door": { + "off": "\u092c\u0902\u0926", + "on": "\u0916\u0941\u0932\u093e" + }, + "garage_door": { + "off": "\u092c\u0902\u0926", + "on": "\u0916\u0941\u0932\u093e" + }, + "heat": { + "on": "\u0917\u0930\u094d\u092e" + }, + "motion": { + "off": "\u0935\u093f\u0936\u0926", + "on": "\u0905\u0928\u0941\u0938\u0928\u094d\u0927\u093e\u0928\u093f\u0924" + }, + "opening": { + "on": "\u0916\u0941\u0932\u093e" + }, + "presence": { + "on": "\u0918\u0930" + }, + "window": { + "off": "\u092c\u0902\u0926", + "on": "\u0916\u0941\u0932\u0940" + } + }, + "title": "\u092c\u093e\u0907\u0928\u0930\u0940 \u0938\u0947\u0902\u0938\u0930" +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/translations/hr.json b/homeassistant/components/binary_sensor/translations/hr.json new file mode 100644 index 0000000000000..b1586d5e0f354 --- /dev/null +++ b/homeassistant/components/binary_sensor/translations/hr.json @@ -0,0 +1,85 @@ +{ + "state": { + "_": { + "off": "Isklju\u010den", + "on": "Uklju\u010den" + }, + "battery": { + "off": "Normalno", + "on": "Prazna" + }, + "cold": { + "off": "Normalno", + "on": "Hladno" + }, + "connectivity": { + "off": "Nije spojen", + "on": "Spojen" + }, + "door": { + "off": "Zatvoreno", + "on": "Otvori" + }, + "garage_door": { + "off": "Zatvoren", + "on": "Otvoreno" + }, + "gas": { + "off": "\u010cisto", + "on": "Otkriveno" + }, + "heat": { + "off": "Normalno", + "on": "Vru\u0107e" + }, + "lock": { + "off": "Zaklju\u010dano", + "on": "Otklju\u010dano" + }, + "moisture": { + "off": "Suho", + "on": "Mokro" + }, + "motion": { + "off": "\u010cisto", + "on": "Otkriveno" + }, + "occupancy": { + "off": "\u010cisto", + "on": "Otkriveno" + }, + "opening": { + "off": "Zatvoreno", + "on": "Otvoreno" + }, + "presence": { + "off": "Odsutan", + "on": "Doma" + }, + "problem": { + "off": "OK", + "on": "Problem" + }, + "safety": { + "off": "Sigurno", + "on": "Nesigurno" + }, + "smoke": { + "off": "\u010cisto", + "on": "Otkriveno" + }, + "sound": { + "off": "\u010cisto", + "on": "Otkriveno" + }, + "vibration": { + "off": "\u010cisto", + "on": "Otkriveno" + }, + "window": { + "off": "Zatvoreno", + "on": "Otvoreno" + } + }, + "title": "Binarni senzor" +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/translations/hu.json b/homeassistant/components/binary_sensor/translations/hu.json new file mode 100644 index 0000000000000..bb4904f12dc54 --- /dev/null +++ b/homeassistant/components/binary_sensor/translations/hu.json @@ -0,0 +1,175 @@ +{ + "device_automation": { + "condition_type": { + "is_bat_low": "{entity_name} akkufesz\u00fclts\u00e9g alacsony", + "is_cold": "{entity_name} hideg", + "is_connected": "{entity_name} csatlakoztatva van", + "is_gas": "{entity_name} g\u00e1zt \u00e9rz\u00e9kel", + "is_hot": "{entity_name} forr\u00f3", + "is_light": "{entity_name} f\u00e9nyt \u00e9rz\u00e9kel", + "is_locked": "{entity_name} z\u00e1rva van", + "is_moist": "{entity_name} nedves", + "is_motion": "{entity_name} mozg\u00e1st \u00e9rz\u00e9kel", + "is_moving": "{entity_name} mozog", + "is_no_gas": "{entity_name} nem \u00e9rz\u00e9kel g\u00e1zt", + "is_no_light": "{entity_name} nem \u00e9rz\u00e9kel f\u00e9nyt", + "is_no_motion": "{entity_name} nem \u00e9rz\u00e9kel mozg\u00e1st", + "is_no_problem": "{entity_name} nem \u00e9szlel probl\u00e9m\u00e1t", + "is_no_smoke": "{entity_name} nem \u00e9rz\u00e9kel f\u00fcst\u00f6t", + "is_no_sound": "{entity_name} nem \u00e9rz\u00e9kel hangot", + "is_no_vibration": "{entity_name} nem \u00e9rz\u00e9kel rezg\u00e9st", + "is_not_bat_low": "{entity_name} akkufesz\u00fclts\u00e9g megfelel\u0151", + "is_not_cold": "{entity_name} nem hideg", + "is_not_connected": "{entity_name} le van csatlakoztatva", + "is_not_hot": "{entity_name} nem forr\u00f3", + "is_not_locked": "{entity_name} nyitva van", + "is_not_moist": "{entity_name} sz\u00e1raz", + "is_not_moving": "{entity_name} nem mozog", + "is_not_occupied": "{entity_name} nem foglalt", + "is_not_open": "{entity_name} z\u00e1rva van", + "is_not_plugged_in": "{entity_name} nincs csatlakoztatva", + "is_not_powered": "{entity_name} nincs fesz\u00fcts\u00e9g alatt", + "is_not_present": "{entity_name} nincs jelen", + "is_not_unsafe": "{entity_name} biztons\u00e1gos", + "is_occupied": "{entity_name} foglalt", + "is_off": "{entity_name} ki van kapcsolva", + "is_on": "{entity_name} be van kapcsolva", + "is_open": "{entity_name} nyitva van", + "is_plugged_in": "{entity_name} csatlakoztatva van", + "is_powered": "{entity_name} fesz\u00fclts\u00e9g alatt van", + "is_present": "{entity_name} jelen van", + "is_problem": "{entity_name} probl\u00e9m\u00e1t \u00e9szlel", + "is_smoke": "{entity_name} f\u00fcst\u00f6t \u00e9rz\u00e9kel", + "is_sound": "{entity_name} hangot \u00e9rz\u00e9kel", + "is_unsafe": "{entity_name} nem biztons\u00e1gos", + "is_vibration": "{entity_name} rezg\u00e9st \u00e9rz\u00e9kel" + }, + "trigger_type": { + "bat_low": "{entity_name} akkufesz\u00fclts\u00e9g alacsony", + "cold": "{entity_name} hideg lett", + "connected": "{entity_name} csatlakozik", + "gas": "{entity_name} g\u00e1zt \u00e9rz\u00e9kel", + "hot": "{entity_name} felforr\u00f3sodik", + "light": "{entity_name} f\u00e9nyt \u00e9rz\u00e9kel", + "locked": "{entity_name} be lett z\u00e1rva", + "moist": "{entity_name} nedves lett", + "motion": "{entity_name} mozg\u00e1st \u00e9rz\u00e9kel", + "moving": "{entity_name} mozog", + "no_gas": "{entity_name} m\u00e1r nem \u00e9rz\u00e9kel g\u00e1zt", + "no_light": "{entity_name} m\u00e1r nem \u00e9rz\u00e9kel f\u00e9nyt", + "no_motion": "{entity_name} m\u00e1r nem \u00e9rz\u00e9kel mozg\u00e1st", + "no_problem": "{entity_name} m\u00e1r nem \u00e9szlel probl\u00e9m\u00e1t", + "no_smoke": "{entity_name} m\u00e1r nem \u00e9rz\u00e9kel f\u00fcst\u00f6t", + "no_sound": "{entity_name} m\u00e1r nem \u00e9rz\u00e9kel hangot", + "no_vibration": "{entity_name} m\u00e1r nem \u00e9rz\u00e9kel rezg\u00e9st", + "not_bat_low": "{entity_name} akkufesz\u00fclts\u00e9g megfelel\u0151", + "not_cold": "{entity_name} m\u00e1r nem hideg", + "not_connected": "{entity_name} lecsatlakozik", + "not_hot": "{entity_name} m\u00e1r nem forr\u00f3", + "not_locked": "{entity_name} ki lett nyitva", + "not_moist": "{entity_name} sz\u00e1raz lett", + "not_moving": "{entity_name} m\u00e1r nem mozog", + "not_occupied": "{entity_name} m\u00e1r nem foglalt", + "not_opened": "{entity_name} be lett csukva", + "not_plugged_in": "{entity_name} m\u00e1r nincs csatlakoztatva", + "not_powered": "{entity_name} m\u00e1r nincs fesz\u00fcts\u00e9g alatt", + "not_present": "{entity_name} m\u00e1r nincs jelen", + "not_unsafe": "{entity_name} biztons\u00e1gos lett", + "occupied": "{entity_name} foglalt lett", + "opened": "{entity_name} ki lett nyitva", + "plugged_in": "{entity_name} csatlakoztatva lett", + "powered": "{entity_name} m\u00e1r fesz\u00fclts\u00e9g alatt van", + "present": "{entity_name} m\u00e1r jelen van", + "problem": "{entity_name} probl\u00e9m\u00e1t \u00e9szlel", + "smoke": "{entity_name} f\u00fcst\u00f6t \u00e9rz\u00e9kel", + "sound": "{entity_name} hangot \u00e9rz\u00e9kel", + "turned_off": "{entity_name} ki lett kapcsolva", + "turned_on": "{entity_name} be lett kapcsolva", + "unsafe": "{entity_name} m\u00e1r nem biztons\u00e1gos", + "vibration": "{entity_name} rezg\u00e9st \u00e9rz\u00e9kel" + } + }, + "state": { + "_": { + "off": "Ki", + "on": "Be" + }, + "battery": { + "off": "Norm\u00e1l", + "on": "Alacsony" + }, + "cold": { + "off": "Norm\u00e1l", + "on": "Hideg" + }, + "connectivity": { + "off": "Lekapcsol\u00f3dva", + "on": "Kapcsol\u00f3dva" + }, + "door": { + "off": "Z\u00e1rva", + "on": "Nyitva" + }, + "garage_door": { + "off": "Z\u00e1rva", + "on": "Nyitva" + }, + "gas": { + "off": "Norm\u00e1l", + "on": "\u00c9szlelve" + }, + "heat": { + "off": "Norm\u00e1l", + "on": "Meleg" + }, + "lock": { + "off": "Bez\u00e1rva", + "on": "Kinyitva" + }, + "moisture": { + "off": "Sz\u00e1raz", + "on": "Nedves" + }, + "motion": { + "off": "Norm\u00e1l", + "on": "\u00c9szlelve" + }, + "occupancy": { + "off": "Norm\u00e1l", + "on": "\u00c9szlelve" + }, + "opening": { + "off": "Z\u00e1rva", + "on": "Nyitva" + }, + "presence": { + "off": "T\u00e1vol", + "on": "Otthon" + }, + "problem": { + "off": "OK", + "on": "Probl\u00e9ma" + }, + "safety": { + "off": "Biztons\u00e1gos", + "on": "Nem biztons\u00e1gos" + }, + "smoke": { + "off": "Norm\u00e1l", + "on": "\u00c9szlelve" + }, + "sound": { + "off": "Norm\u00e1l", + "on": "\u00c9szlelve" + }, + "vibration": { + "off": "Norm\u00e1l", + "on": "\u00c9szlelve" + }, + "window": { + "off": "Z\u00e1rva", + "on": "Nyitva" + } + }, + "title": "Bin\u00e1ris \u00e9rz\u00e9kel\u0151" +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/translations/hy.json b/homeassistant/components/binary_sensor/translations/hy.json new file mode 100644 index 0000000000000..7a23642b750b4 --- /dev/null +++ b/homeassistant/components/binary_sensor/translations/hy.json @@ -0,0 +1,85 @@ +{ + "state": { + "_": { + "off": "\u0531\u0576\u057b\u0561\u057f\u057e\u0561\u056e", + "on": "\u0544\u056b\u0561\u0581\u0561\u056e" + }, + "battery": { + "off": "\u0546\u0578\u0580\u0574\u0561\u056c \u0567", + "on": "\u0551\u0561\u056e\u0580" + }, + "cold": { + "off": "\u0546\u0578\u0580\u0574\u0561\u056c", + "on": "\u054d\u0561\u057c\u0568" + }, + "connectivity": { + "off": "\u0531\u0576\u057b\u0561\u057f\u057e\u0561\u056e \u0567", + "on": "\u053f\u0561\u057a\u057e\u0561\u056e" + }, + "door": { + "off": "\u0553\u0561\u056f\u057e\u0561\u056e \u0567", + "on": "\u0532\u0561\u0581\u0565\u056c" + }, + "garage_door": { + "off": "\u0553\u0561\u056f\u057e\u0561\u056e \u0567", + "on": "\u0532\u0561\u0581\u0565\u056c" + }, + "gas": { + "off": "\u0544\u0561\u0584\u0580\u0565\u056c", + "on": "\u0540\u0561\u0575\u057f\u0576\u0561\u0562\u0565\u0580\u057e\u0565\u056c \u0567" + }, + "heat": { + "off": "\u0546\u0578\u0580\u0574\u0561\u056c", + "on": "\u0539\u0565\u056a" + }, + "lock": { + "off": "\u056f\u0578\u0572\u057a\u057e\u0561\u056e", + "on": "\u0562\u0561\u0581\u0565\u056c \u0567" + }, + "moisture": { + "off": "\u0549\u0578\u0580", + "on": "\u053d\u0578\u0576\u0561\u057e" + }, + "motion": { + "off": "\u0544\u0561\u0584\u0580\u0565\u056c", + "on": "\u0540\u0561\u0575\u057f\u0576\u0561\u0562\u0565\u0580\u057e\u0565\u056c \u0567" + }, + "occupancy": { + "off": "\u0544\u0561\u0584\u0580\u0565\u056c", + "on": "\u0540\u0561\u0575\u057f\u0576\u0561\u0562\u0565\u0580\u057e\u0565\u056c \u0567" + }, + "opening": { + "off": "\u0553\u0561\u056f\u057e\u0561\u056e", + "on": "\u0532\u0561\u0581" + }, + "presence": { + "off": "\u0540\u0565\u057c\u0578\u0582", + "on": "\u054f\u0578\u0582\u0576" + }, + "problem": { + "off": "OK", + "on": "\u053d\u0576\u0564\u056b\u0580" + }, + "safety": { + "off": "\u0531\u057a\u0561\u0570\u0578\u057e", + "on": "\u0531\u0576\u057e\u057f\u0561\u0576\u0563" + }, + "smoke": { + "off": "\u0544\u0561\u0584\u0580\u0565\u056c", + "on": "\u0540\u0561\u0575\u057f\u0576\u0561\u0562\u0565\u0580\u057e\u0565\u056c \u0567" + }, + "sound": { + "off": "\u0544\u0561\u0584\u0580\u0565\u056c", + "on": "\u0540\u0561\u0575\u057f\u0576\u0561\u0562\u0565\u0580\u057e\u0565\u056c \u0567" + }, + "vibration": { + "off": "\u0544\u0561\u0584\u0580\u0565\u056c", + "on": "\u0540\u0561\u0575\u057f\u0576\u0561\u0562\u0565\u0580\u057e\u0565\u056c \u0567" + }, + "window": { + "off": "\u0553\u0561\u056f\u057e\u0561\u056e \u0567", + "on": "\u0532\u0561\u0581\u0565\u056c" + } + }, + "title": "\u0535\u0580\u056f\u0578\u0582\u0561\u056f\u0561\u0576 \u054d\u0565\u0576\u057d\u0578\u0580" +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/translations/id.json b/homeassistant/components/binary_sensor/translations/id.json new file mode 100644 index 0000000000000..4ca757da6e5e0 --- /dev/null +++ b/homeassistant/components/binary_sensor/translations/id.json @@ -0,0 +1,85 @@ +{ + "state": { + "_": { + "off": "Off", + "on": "On" + }, + "battery": { + "off": "Normal", + "on": "Rendah" + }, + "cold": { + "off": "Normal", + "on": "Dingin" + }, + "connectivity": { + "off": "Terputus", + "on": "Terhubung" + }, + "door": { + "off": "Tertutup", + "on": "Terbuka" + }, + "garage_door": { + "off": "Tertutup", + "on": "Terbuka" + }, + "gas": { + "off": "Kosong", + "on": "Terdeteksi" + }, + "heat": { + "off": "Normal", + "on": "Panas" + }, + "lock": { + "off": "Terkunci", + "on": "Terbuka" + }, + "moisture": { + "off": "Kering", + "on": "Basah" + }, + "motion": { + "off": "Tidak ada", + "on": "Terdeteksi" + }, + "occupancy": { + "off": "Tidak ada", + "on": "Terdeteksi" + }, + "opening": { + "off": "Tertutup", + "on": "Terbuka" + }, + "presence": { + "off": "Keluar", + "on": "Rumah" + }, + "problem": { + "off": "Oke", + "on": "Masalah" + }, + "safety": { + "off": "Aman", + "on": "Tidak aman" + }, + "smoke": { + "off": "Tidak ada", + "on": "Terdeteksi" + }, + "sound": { + "off": "Tidak ada", + "on": "Terdeteksi" + }, + "vibration": { + "off": "Tidak ada", + "on": "Terdeteksi" + }, + "window": { + "off": "Tertutup", + "on": "Terbuka" + } + }, + "title": "Sensor biner" +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/translations/is.json b/homeassistant/components/binary_sensor/translations/is.json new file mode 100644 index 0000000000000..f53316ebd73e7 --- /dev/null +++ b/homeassistant/components/binary_sensor/translations/is.json @@ -0,0 +1,80 @@ +{ + "state": { + "_": { + "off": "Sl\u00f6kkt", + "on": "Kveikt" + }, + "battery": { + "off": "Venjulegt", + "on": "L\u00e1gt" + }, + "cold": { + "off": "Venjulegt", + "on": "Kalt" + }, + "connectivity": { + "off": "Aftengdur", + "on": "Tengdur" + }, + "door": { + "off": "Loku\u00f0", + "on": "Opin" + }, + "garage_door": { + "off": "Loku\u00f0", + "on": "Opin" + }, + "gas": { + "off": "Hreinsa", + "on": "Uppg\u00f6tva\u00f0" + }, + "heat": { + "off": "Venjulegt", + "on": "Heitt" + }, + "lock": { + "off": "L\u00e6st", + "on": "Afl\u00e6st" + }, + "moisture": { + "off": "\u00deurrt", + "on": "Blautt" + }, + "motion": { + "off": "Engin hreyfing", + "on": "Hreyfing" + }, + "occupancy": { + "off": "Hreinsa", + "on": "Uppg\u00f6tva\u00f0" + }, + "presence": { + "off": "Fjarverandi", + "on": "Heima" + }, + "problem": { + "off": "\u00cd lagi", + "on": "Vandam\u00e1l" + }, + "safety": { + "off": "\u00d6ruggt", + "on": "\u00d3\u00f6ruggt" + }, + "smoke": { + "off": "Hreinsa", + "on": "Uppg\u00f6tva\u00f0" + }, + "sound": { + "off": "Hreinsa", + "on": "Uppg\u00f6tva\u00f0" + }, + "vibration": { + "on": "Uppg\u00f6tva\u00f0" + }, + "window": { + "off": "Loka", + "on": "Opna" + } + }, + "title": "Tv\u00edundar skynjari" +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/translations/it.json b/homeassistant/components/binary_sensor/translations/it.json new file mode 100644 index 0000000000000..955fe525ad171 --- /dev/null +++ b/homeassistant/components/binary_sensor/translations/it.json @@ -0,0 +1,175 @@ +{ + "device_automation": { + "condition_type": { + "is_bat_low": "{entity_name} la batteria \u00e8 scarica", + "is_cold": "{entity_name} \u00e8 freddo", + "is_connected": "{entity_name} \u00e8 collegato", + "is_gas": "{entity_name} sta rilevando il gas", + "is_hot": "{entity_name} \u00e8 caldo", + "is_light": "{entity_name} sta rilevando la luce", + "is_locked": "{entity_name} \u00e8 bloccato", + "is_moist": "{entity_name} \u00e8 umido", + "is_motion": "{entity_name} sta rilevando il movimento", + "is_moving": "{entity_name} si sta muovendo", + "is_no_gas": "{entity_name} non sta rilevando il gas", + "is_no_light": "{entity_name} non sta rilevando la luce", + "is_no_motion": "{entity_name} non sta rilevando il movimento", + "is_no_problem": "{entity_name} non sta rilevando un problema", + "is_no_smoke": "{entity_name} non sta rilevando il fumo", + "is_no_sound": "{entity_name} non sta rilevando il suono", + "is_no_vibration": "{entity_name} non sta rilevando la vibrazione", + "is_not_bat_low": "{entity_name} la batteria \u00e8 normale", + "is_not_cold": "{entity_name} non \u00e8 freddo", + "is_not_connected": "{entity_name} \u00e8 disconnesso", + "is_not_hot": "{entity_name} non \u00e8 caldo", + "is_not_locked": "{entity_name} \u00e8 sbloccato", + "is_not_moist": "{entity_name} \u00e8 asciutto", + "is_not_moving": "{entity_name} non si sta muovendo", + "is_not_occupied": "{entity_name} non \u00e8 occupato", + "is_not_open": "{entity_name} \u00e8 chiuso", + "is_not_plugged_in": "{entity_name} \u00e8 collegato", + "is_not_powered": "{entity_name} non \u00e8 alimentato", + "is_not_present": "{entity_name} non \u00e8 presente", + "is_not_unsafe": "{entity_name} \u00e8 sicuro", + "is_occupied": "{entity_name} \u00e8 occupato", + "is_off": "{entity_name} \u00e8 spento", + "is_on": "{entity_name} \u00e8 acceso", + "is_open": "{entity_name} \u00e8 aperto", + "is_plugged_in": "{entity_name} \u00e8 collegato", + "is_powered": "{entity_name} \u00e8 alimentato", + "is_present": "{entity_name} \u00e8 presente", + "is_problem": "{entity_name} sta rilevando un problema", + "is_smoke": "{entity_name} sta rilevando il fumo", + "is_sound": "{entity_name} sta rilevando il suono", + "is_unsafe": "{entity_name} non \u00e8 sicuro", + "is_vibration": "{entity_name} sta rilevando la vibrazione" + }, + "trigger_type": { + "bat_low": "{entity_name} batteria scarica", + "cold": "{entity_name} \u00e8 diventato freddo", + "connected": "{entity_name} connesso", + "gas": "{entity_name} ha iniziato a rilevare il gas", + "hot": "{entity_name} \u00e8 diventato caldo", + "light": "{entity_name} ha iniziato a rilevare la luce", + "locked": "{entity_name} bloccato", + "moist": "{entity_name} diventato umido", + "motion": "{entity_name} ha iniziato a rilevare il movimento", + "moving": "{entity_name} ha iniziato a muoversi", + "no_gas": "{entity_name} ha smesso la rilevazione di gas", + "no_light": "{entity_name} smesso il rilevamento di luce", + "no_motion": "{entity_name} ha smesso di rilevare il movimento", + "no_problem": "{entity_name} ha smesso di rilevare un problema", + "no_smoke": "{entity_name} ha smesso la rilevazione di fumo", + "no_sound": "{entity_name} ha smesso di rilevare il suono", + "no_vibration": "{entity_name} ha smesso di rilevare le vibrazioni", + "not_bat_low": "{entity_name} batteria normale", + "not_cold": "{entity_name} non \u00e8 diventato freddo", + "not_connected": "{entity_name} \u00e8 disconnesso", + "not_hot": "{entity_name} non \u00e8 diventato caldo", + "not_locked": "{entity_name} \u00e8 sbloccato", + "not_moist": "{entity_name} \u00e8 diventato asciutto", + "not_moving": "{entity_name} ha smesso di muoversi", + "not_occupied": "{entity_name} non \u00e8 occupato", + "not_opened": "{entity_name} chiuso", + "not_plugged_in": "{entity_name} \u00e8 scollegato", + "not_powered": "{entity_name} non \u00e8 alimentato", + "not_present": "{entity_name} non \u00e8 presente", + "not_unsafe": "{entity_name} \u00e8 diventato sicuro", + "occupied": "{entity_name} \u00e8 diventato occupato", + "opened": "{entity_name} \u00e8 aperto", + "plugged_in": "{entity_name} \u00e8 collegato", + "powered": "{entity_name} \u00e8 alimentato", + "present": "{entity_name} \u00e8 presente", + "problem": "{entity_name} ha iniziato a rilevare un problema", + "smoke": "{entity_name} ha iniziato la rilevazione di fumo", + "sound": "{entity_name} ha iniziato il rilevamento del suono", + "turned_off": "{entity_name} disattivato", + "turned_on": "{entity_name} attivato", + "unsafe": "{entity_name} diventato non sicuro", + "vibration": "{entity_name} iniziato a rilevare le vibrazioni" + } + }, + "state": { + "_": { + "off": "Spento", + "on": "Acceso" + }, + "battery": { + "off": "Normale", + "on": "Basso" + }, + "cold": { + "off": "Normale", + "on": "Freddo" + }, + "connectivity": { + "off": "Disconnesso", + "on": "Connesso" + }, + "door": { + "off": "Chiusa", + "on": "Aperta" + }, + "garage_door": { + "off": "Chiusa", + "on": "Aperta" + }, + "gas": { + "off": "Assente", + "on": "Rilevato" + }, + "heat": { + "off": "Normale", + "on": "Caldo" + }, + "lock": { + "off": "Bloccato", + "on": "Sbloccato" + }, + "moisture": { + "off": "Asciutto", + "on": "Bagnato" + }, + "motion": { + "off": "Assente", + "on": "Rilevato" + }, + "occupancy": { + "off": "Vuoto", + "on": "Rilevato" + }, + "opening": { + "off": "Chiuso", + "on": "Aperto" + }, + "presence": { + "off": "Fuori casa", + "on": "A casa" + }, + "problem": { + "off": "OK", + "on": "Problema" + }, + "safety": { + "off": "Sicuro", + "on": "Non Sicuro" + }, + "smoke": { + "off": "Assente", + "on": "Rilevato" + }, + "sound": { + "off": "Assente", + "on": "Rilevato" + }, + "vibration": { + "off": "Assente", + "on": "Rilevata" + }, + "window": { + "off": "Chiusa", + "on": "Aperta" + } + }, + "title": "Sensore binario" +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/translations/ja.json b/homeassistant/components/binary_sensor/translations/ja.json new file mode 100644 index 0000000000000..5434f8687bf54 --- /dev/null +++ b/homeassistant/components/binary_sensor/translations/ja.json @@ -0,0 +1,84 @@ +{ + "state": { + "_": { + "off": "\u30aa\u30d5", + "on": "\u30aa\u30f3" + }, + "battery": { + "off": "\u901a\u5e38", + "on": "\u4f4e" + }, + "cold": { + "off": "\u901a\u5e38", + "on": "\u4f4e\u6e29" + }, + "connectivity": { + "off": "\u5207\u65ad", + "on": "\u63a5\u7d9a\u6e08" + }, + "door": { + "off": "\u9589\u9396", + "on": "\u958b\u653e" + }, + "garage_door": { + "off": "\u9589\u9396", + "on": "\u958b\u653e" + }, + "gas": { + "off": "\u672a\u691c\u51fa", + "on": "\u691c\u51fa" + }, + "heat": { + "off": "\u6b63\u5e38", + "on": "\u9ad8\u6e29" + }, + "lock": { + "off": "\u30ed\u30c3\u30af\u3055\u308c\u307e\u3057\u305f", + "on": "\u30ed\u30c3\u30af\u3055\u308c\u3066\u3044\u307e\u305b\u3093" + }, + "moisture": { + "off": "\u30c9\u30e9\u30a4", + "on": "\u30a6\u30a7\u30c3\u30c8" + }, + "motion": { + "off": "\u672a\u691c\u51fa", + "on": "\u691c\u51fa" + }, + "occupancy": { + "off": "\u672a\u691c\u51fa", + "on": "\u691c\u51fa" + }, + "opening": { + "off": "\u9589\u9396", + "on": "\u958b\u653e" + }, + "presence": { + "off": "\u5916\u51fa", + "on": "\u5728\u5b85" + }, + "problem": { + "off": "OK" + }, + "safety": { + "off": "\u5b89\u5168", + "on": "\u5371\u967a" + }, + "smoke": { + "off": "\u672a\u691c\u51fa", + "on": "\u691c\u51fa" + }, + "sound": { + "off": "\u672a\u691c\u51fa", + "on": "\u691c\u51fa" + }, + "vibration": { + "off": "\u672a\u691c\u51fa", + "on": "\u691c\u51fa" + }, + "window": { + "off": "\u9589\u9396", + "on": "\u958b\u653e" + } + }, + "title": "\u30d0\u30a4\u30ca\u30ea\u30bb\u30f3\u30b5\u30fc" +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/translations/ko.json b/homeassistant/components/binary_sensor/translations/ko.json new file mode 100644 index 0000000000000..cd7281cbbb0c2 --- /dev/null +++ b/homeassistant/components/binary_sensor/translations/ko.json @@ -0,0 +1,175 @@ +{ + "device_automation": { + "condition_type": { + "is_bat_low": "{entity_name} \uc758 \ubc30\ud130\ub9ac \uc794\ub7c9\uc774 \ubd80\uc871\ud558\uba74", + "is_cold": "{entity_name} \uc774(\uac00) \ucc28\uac00\uc6b0\uba74", + "is_connected": "{entity_name} \uc774(\uac00) \uc5f0\uacb0\ub418\uc5b4 \uc788\uc73c\uba74", + "is_gas": "{entity_name} \uc774(\uac00) \uac00\uc2a4\ub97c \uac10\uc9c0\ud558\uba74", + "is_hot": "{entity_name} \uc774(\uac00) \ub728\uac70\uc6b0\uba74", + "is_light": "{entity_name} \uc774(\uac00) \ube5b\uc744 \uac10\uc9c0\ud558\uba74", + "is_locked": "{entity_name} \uc774(\uac00) \uc7a0\uaca8\uc788\uc73c\uba74", + "is_moist": "{entity_name} \uc774(\uac00) \uc2b5\ud558\uba74", + "is_motion": "{entity_name} \uc774(\uac00) \uc6c0\uc9c1\uc784\uc744 \uac10\uc9c0\ud558\uba74", + "is_moving": "{entity_name} \uc774(\uac00) \uc6c0\uc9c1\uc774\uba74", + "is_no_gas": "{entity_name} \uc774(\uac00) \uac00\uc2a4\ub97c \uac10\uc9c0\ud558\uc9c0 \uc54a\uc73c\uba74", + "is_no_light": "{entity_name} \uc774(\uac00) \ube5b\uc744 \uac10\uc9c0\ud558\uc9c0 \uc54a\uc73c\uba74", + "is_no_motion": "{entity_name} \uc774(\uac00) \uc6c0\uc9c1\uc784\uc744 \uac10\uc9c0\ud558\uc9c0 \uc54a\uc73c\uba74", + "is_no_problem": "{entity_name} \uc774(\uac00) \ubb38\uc81c\ub97c \uac10\uc9c0\ud558\uc9c0 \uc54a\uc73c\uba74", + "is_no_smoke": "{entity_name} \uc774(\uac00) \uc5f0\uae30\ub97c \uac10\uc9c0\ud558\uc9c0 \uc54a\uc73c\uba74", + "is_no_sound": "{entity_name} \uc774(\uac00) \uc18c\ub9ac\ub97c \uac10\uc9c0\ud558\uc9c0 \uc54a\uc73c\uba74", + "is_no_vibration": "{entity_name} \uc774(\uac00) \uc9c4\ub3d9\uc744 \uac10\uc9c0\ud558\uc9c0 \uc54a\uc73c\uba74", + "is_not_bat_low": "{entity_name} \uc758 \ubc30\ud130\ub9ac\uac00 \uc815\uc0c1\uc774\uba74", + "is_not_cold": "{entity_name} \uc774(\uac00) \ucc28\uac11\uc9c0 \uc54a\ub2e4\uba74", + "is_not_connected": "{entity_name} \uc758 \uc5f0\uacb0\uc774 \ub04a\uc5b4\uc838 \uc788\ub2e4\uba74", + "is_not_hot": "{entity_name} \uc774(\uac00) \ub728\uac81\uc9c0 \uc54a\ub2e4\uba74", + "is_not_locked": "{entity_name} \uc774(\uac00) \uc7a0\uaca8\uc788\uc9c0 \uc54a\uc73c\uba74", + "is_not_moist": "{entity_name} \uc774(\uac00) \uac74\uc870\ud558\uba74", + "is_not_moving": "{entity_name} \uc774(\uac00) \uc6c0\uc9c1\uc774\uc9c0 \uc54a\uc73c\uba74", + "is_not_occupied": "{entity_name} \uc774(\uac00) \uc7ac\uc2e4 \uc0c1\ud0dc\uac00 \uc544\ub2c8\uba74", + "is_not_open": "{entity_name} \uc774(\uac00) \ub2eb\ud600 \uc788\uc73c\uba74", + "is_not_plugged_in": "{entity_name} \ud50c\ub7ec\uadf8\uac00 \ubf51\ud600 \uc788\uc73c\uba74", + "is_not_powered": "{entity_name} \uc5d0 \uc804\uc6d0\uc774 \uacf5\uae09\ub418\uc9c0 \uc54a\uc73c\uba74", + "is_not_present": "{entity_name} \uc774(\uac00) \uc678\ucd9c \uc911\uc774\uba74", + "is_not_unsafe": "{entity_name} \uc774(\uac00) \uc548\uc804\ud558\uba74", + "is_occupied": "{entity_name} \uc774(\uac00) \uc7ac\uc2e4 \uc0c1\ud0dc\uc774\uba74", + "is_off": "{entity_name} \uc774(\uac00) \uaebc\uc838 \uc788\uc73c\uba74", + "is_on": "{entity_name} \uc774(\uac00) \ucf1c\uc838 \uc788\uc73c\uba74", + "is_open": "{entity_name} \uc774(\uac00) \uc5f4\ub824 \uc788\uc73c\uba74", + "is_plugged_in": "{entity_name} \ud50c\ub7ec\uadf8\uac00 \uaf3d\ud600 \uc788\uc73c\uba74", + "is_powered": "{entity_name} \uc5d0 \uc804\uc6d0\uc774 \uacf5\uae09\ub418\uace0 \uc788\uc73c\uba74", + "is_present": "{entity_name} \uc774(\uac00) \uc7ac\uc2e4 \uc911\uc774\uba74", + "is_problem": "{entity_name} \uc774(\uac00) \ubb38\uc81c\ub97c \uac10\uc9c0\ud558\uba74", + "is_smoke": "{entity_name} \uc774(\uac00) \uc5f0\uae30\ub97c \uac10\uc9c0\ud558\uba74", + "is_sound": "{entity_name} \uc774(\uac00) \uc18c\ub9ac\ub97c \uac10\uc9c0\ud558\uba74", + "is_unsafe": "{entity_name} \uc740(\ub294) \uc548\uc804\ud558\uc9c0 \uc54a\uc73c\uba74", + "is_vibration": "{entity_name} \uc774(\uac00) \uc9c4\ub3d9\uc744 \uac10\uc9c0\ud558\uba74" + }, + "trigger_type": { + "bat_low": "{entity_name} \ubc30\ud130\ub9ac \uc794\ub7c9\uc774 \ubd80\uc871\ud574\uc9c8 \ub54c", + "cold": "{entity_name} \uc774(\uac00) \ucc28\uac00\uc6cc\uc9c8 \ub54c", + "connected": "{entity_name} \uc774(\uac00) \uc5f0\uacb0\ub420 \ub54c", + "gas": "{entity_name} \uc774(\uac00) \uac00\uc2a4\ub97c \uac10\uc9c0\ud560 \ub54c", + "hot": "{entity_name} \uc774(\uac00) \ub728\uac70\uc6cc\uc9c8 \ub54c", + "light": "{entity_name} \uc774(\uac00) \ube5b\uc744 \uac10\uc9c0\ud560 \ub54c", + "locked": "{entity_name} \uc774(\uac00) \uc7a0\uae38 \ub54c", + "moist": "{entity_name} \uc774(\uac00) \uc2b5\ud574\uc9c8 \ub54c", + "motion": "{entity_name} \uc774(\uac00) \uc6c0\uc9c1\uc784\uc744 \uac10\uc9c0\ud560 \ub54c", + "moving": "{entity_name} \uc774(\uac00) \uc6c0\uc9c1\uc77c \ub54c", + "no_gas": "{entity_name} \uc774(\uac00) \uac00\uc2a4\ub97c \uac10\uc9c0\ud558\uc9c0 \uc54a\uac8c \ub420 \ub54c", + "no_light": "{entity_name} \uc774(\uac00) \ube5b\uc744 \uac10\uc9c0\ud558\uc9c0 \uc54a\uac8c \ub420 \ub54c", + "no_motion": "{entity_name} \uc774(\uac00) \uc6c0\uc9c1\uc784\uc744 \uac10\uc9c0\ud558\uc9c0 \uc54a\uac8c \ub420 \ub54c", + "no_problem": "{entity_name} \uc774(\uac00) \ubb38\uc81c\ub97c \uac10\uc9c0\ud558\uc9c0 \uc54a\uac8c \ub420 \ub54c", + "no_smoke": "{entity_name} \uc774(\uac00) \uc5f0\uae30\ub97c \uac10\uc9c0\ud558\uc9c0 \uc54a\uac8c \ub420 \ub54c", + "no_sound": "{entity_name} \uc774(\uac00) \uc18c\ub9ac\ub97c \uac10\uc9c0\ud558\uc9c0 \uc54a\uac8c \ub420 \ub54c", + "no_vibration": "{entity_name} \uc774(\uac00) \uc9c4\ub3d9\uc744 \uac10\uc9c0\ud558\uc9c0 \uc54a\uac8c \ub420 \ub54c", + "not_bat_low": "{entity_name} \ubc30\ud130\ub9ac\uac00 \uc815\uc0c1\uc774 \ub420 \ub54c", + "not_cold": "{entity_name} \uc774(\uac00) \ucc28\uac11\uc9c0 \uc54a\uac8c \ub420 \ub54c", + "not_connected": "{entity_name} \uc758 \uc5f0\uacb0\uc774 \ub04a\uc5b4\uc9c8 \ub54c", + "not_hot": "{entity_name} \uc774(\uac00) \ub728\uac81\uc9c0 \uc54a\uac8c \ub420 \ub54c", + "not_locked": "{entity_name} \uc758 \uc7a0\uae08\uc774 \ud574\uc81c\ub420 \ub54c", + "not_moist": "{entity_name} \uc774(\uac00) \uac74\uc870\ud574\uc9c8 \ub54c", + "not_moving": "{entity_name} \uc774(\uac00) \uc6c0\uc9c1\uc774\uc9c0 \uc54a\uc744 \ub54c", + "not_occupied": "{entity_name} \uc774(\uac00) \uc7ac\uc2e4 \uc0c1\ud0dc\uac00 \uc544\ub2c8\uac8c \ub420 \ub54c", + "not_opened": "{entity_name} \uc774(\uac00) \ub2eb\ud790 \ub54c", + "not_plugged_in": "{entity_name} \ud50c\ub7ec\uadf8\uac00 \ubf51\ud790 \ub54c", + "not_powered": "{entity_name} \uc5d0 \uc804\uc6d0\uc774 \uacf5\uae09\ub418\uc9c0 \uc54a\uc744 \ub54c", + "not_present": "{entity_name} \uc774(\uac00) \uc678\ucd9c \uc0c1\ud0dc\uac00 \ub420 \ub54c", + "not_unsafe": "{entity_name} \uc740(\ub294) \uc548\uc804\ud574\uc9c8 \ub54c", + "occupied": "{entity_name} \uc774(\uac00) \uc7ac\uc2e4 \uc0c1\ud0dc\uac00 \ub420 \ub54c", + "opened": "{entity_name} \uc774(\uac00) \uc5f4\ub9b4 \ub54c", + "plugged_in": "{entity_name} \ud50c\ub7ec\uadf8\uac00 \uaf3d\ud790 \ub54c", + "powered": "{entity_name} \uc5d0 \uc804\uc6d0\uc774 \uacf5\uae09\ub420 \ub54c", + "present": "{entity_name} \uc774(\uac00) \uc7ac\uc2e4 \uc0c1\ud0dc\uac00 \ub420 \ub54c", + "problem": "{entity_name} \uc774(\uac00) \ubb38\uc81c\ub97c \uac10\uc9c0\ud560 \ub54c", + "smoke": "{entity_name} \uc774(\uac00) \uc5f0\uae30\ub97c \uac10\uc9c0\ud560 \ub54c", + "sound": "{entity_name} \uc774(\uac00) \uc18c\ub9ac\ub97c \uac10\uc9c0\ud560 \ub54c", + "turned_off": "{entity_name} \uc774(\uac00) \uaebc\uc9c8 \ub54c", + "turned_on": "{entity_name} \uc774(\uac00) \ucf1c\uc9c8 \ub54c", + "unsafe": "{entity_name} \uc774(\uac00) \uc548\uc804\ud558\uc9c0 \uc54a\uc744 \ub54c", + "vibration": "{entity_name} \uc774(\uac00) \uc9c4\ub3d9\uc744 \uac10\uc9c0\ud560 \ub54c" + } + }, + "state": { + "_": { + "off": "\uaebc\uc9d0", + "on": "\ucf1c\uc9d0" + }, + "battery": { + "off": "\ubcf4\ud1b5", + "on": "\ub0ae\uc74c" + }, + "cold": { + "off": "\ubcf4\ud1b5", + "on": "\uc800\uc628" + }, + "connectivity": { + "off": "\uc5f0\uacb0\ud574\uc81c\ub428", + "on": "\uc5f0\uacb0\ub428" + }, + "door": { + "off": "\ub2eb\ud798", + "on": "\uc5f4\ub9bc" + }, + "garage_door": { + "off": "\ub2eb\ud798", + "on": "\uc5f4\ub9bc" + }, + "gas": { + "off": "\uc774\uc0c1\uc5c6\uc74c", + "on": "\uac10\uc9c0\ub428" + }, + "heat": { + "off": "\ubcf4\ud1b5", + "on": "\uace0\uc628" + }, + "lock": { + "off": "\uc7a0\uae40", + "on": "\ud574\uc81c" + }, + "moisture": { + "off": "\uac74\uc870\ud568", + "on": "\uc2b5\ud568" + }, + "motion": { + "off": "\uc774\uc0c1\uc5c6\uc74c", + "on": "\uac10\uc9c0\ub428" + }, + "occupancy": { + "off": "\uc774\uc0c1\uc5c6\uc74c", + "on": "\uac10\uc9c0\ub428" + }, + "opening": { + "off": "\ub2eb\ud798", + "on": "\uc5f4\ub9bc" + }, + "presence": { + "off": "\uc678\ucd9c", + "on": "\uc7ac\uc2e4" + }, + "problem": { + "off": "\ubb38\uc81c\uc5c6\uc74c", + "on": "\ubb38\uc81c\uc788\uc74c" + }, + "safety": { + "off": "\uc548\uc804", + "on": "\uc704\ud5d8" + }, + "smoke": { + "off": "\uc774\uc0c1\uc5c6\uc74c", + "on": "\uac10\uc9c0\ub428" + }, + "sound": { + "off": "\uc774\uc0c1\uc5c6\uc74c", + "on": "\uac10\uc9c0\ub428" + }, + "vibration": { + "off": "\uc774\uc0c1\uc5c6\uc74c", + "on": "\uac10\uc9c0\ub428" + }, + "window": { + "off": "\ub2eb\ud798", + "on": "\uc5f4\ub9bc" + } + }, + "title": "\uc774\uc9c4\uc13c\uc11c" +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/translations/lb.json b/homeassistant/components/binary_sensor/translations/lb.json new file mode 100644 index 0000000000000..fc29c0e67a80f --- /dev/null +++ b/homeassistant/components/binary_sensor/translations/lb.json @@ -0,0 +1,175 @@ +{ + "device_automation": { + "condition_type": { + "is_bat_low": "{entity_name} Batterie ass niddereg", + "is_cold": "{entity_name} ass kal", + "is_connected": "{entity_name} ass verbonnen", + "is_gas": "{entity_name} entdeckt Gas", + "is_hot": "{entity_name} ass waarm", + "is_light": "{entity_name} entdeckt Luucht", + "is_locked": "{entity_name} ass gespaart", + "is_moist": "{entity_name} ass fiicht", + "is_motion": "{entity_name} entdeckt Beweegung", + "is_moving": "{entity_name} beweegt sech", + "is_no_gas": "{entity_name} entdeckt kee Gas", + "is_no_light": "{entity_name} entdeckt keng Luucht", + "is_no_motion": "{entity_name} entdeckt keng Beweegung", + "is_no_problem": "{entity_name} entdeckt keng Problemer", + "is_no_smoke": "{entity_name} entdeckt keen Damp", + "is_no_sound": "{entity_name} entdeckt keen Toun", + "is_no_vibration": "{entity_name} entdeckt keng Vibratiounen", + "is_not_bat_low": "{entity_name} Batterie ass normal", + "is_not_cold": "{entity_name} ass net kal", + "is_not_connected": "{entity_name} ass d\u00e9connect\u00e9iert", + "is_not_hot": "{entity_name} ass net waarm", + "is_not_locked": "{entity_name} ass entspaart", + "is_not_moist": "{entity_name} ass dr\u00e9chen", + "is_not_moving": "{entity_name} beweegt sech net", + "is_not_occupied": "{entity_name} ass fr\u00e4i", + "is_not_open": "{entity_name} ass zou", + "is_not_plugged_in": "{entity_name} ass net ugeschloss", + "is_not_powered": "{entity_name} ass net aliment\u00e9iert", + "is_not_present": "{entity_name} ass net pr\u00e4sent", + "is_not_unsafe": "{entity_name} ass s\u00e9cher", + "is_occupied": "{entity_name} ass besat", + "is_off": "{entity_name} ass aus", + "is_on": "{entity_name} ass un", + "is_open": "{entity_name} ass op", + "is_plugged_in": "{entity_name} ass ugeschloss", + "is_powered": "{entity_name} ass aliment\u00e9iert", + "is_present": "{entity_name} ass pr\u00e4sent", + "is_problem": "{entity_name} entdeckt Problemer", + "is_smoke": "{entity_name} entdeckt Damp", + "is_sound": "{entity_name} entdeckt Toun", + "is_unsafe": "{entity_name} ass ons\u00e9cher", + "is_vibration": "{entity_name} entdeckt Vibratiounen" + }, + "trigger_type": { + "bat_low": "{entity_name} Batterie niddereg", + "cold": "{entity_name} gouf kal", + "connected": "{entity_name} ass verbonnen", + "gas": "{entity_name} huet ugefaangen Gas z'entdecken", + "hot": "{entity_name} gouf waarm", + "light": "{entity_name} huet ugefange Luucht z'entdecken", + "locked": "{entity_name} gespaart", + "moist": "{entity_name} gouf fiicht", + "motion": "{entity_name} huet ugefaange Beweegung z'entdecken", + "moving": "{entity_name} huet ugefaangen sech ze beweegen", + "no_gas": "{entity_name} huet opgehale Gas z'entdecken", + "no_light": "{entity_name} huet opgehale Luucht z'entdecken", + "no_motion": "{entity_name} huet opgehale Beweegung z'entdecken", + "no_problem": "{entity_name} huet opgehale Problemer z'entdecken", + "no_smoke": "{entity_name} huet opgehale Damp z'entdecken", + "no_sound": "{entity_name} huet opgehale Toun z'entdecken", + "no_vibration": "{entity_name} huet opgehale Vibratiounen z'entdecken", + "not_bat_low": "{entity_name} Batterie normal", + "not_cold": "{entity_name} gouf net kal", + "not_connected": "{entity_name} d\u00e9connect\u00e9iert", + "not_hot": "{entity_name} gouf net waarm", + "not_locked": "{entity_name} entspaart", + "not_moist": "{entity_name} gouf dr\u00e9chen", + "not_moving": "{entity_name} huet opgehale sech ze beweegen", + "not_occupied": "{entity_name} gouf fr\u00e4i", + "not_opened": "{entity_name} gouf zougemaach", + "not_plugged_in": "{entity_name} net ugeschloss", + "not_powered": "{entity_name} net aliment\u00e9iert", + "not_present": "{entity_name} net pr\u00e4sent", + "not_unsafe": "{entity_name} gouf s\u00e9cher", + "occupied": "{entity_name} gouf besat", + "opened": "{entity_name} gouf opgemaach", + "plugged_in": "{entity_name} ugeschloss", + "powered": "{entity_name} aliment\u00e9iert", + "present": "{entity_name} pr\u00e4sent", + "problem": "{entity_name} huet ugefaange Problemer z'entdecken", + "smoke": "{entity_name} huet ugefaangen Damp z'entdecken", + "sound": "{entity_name} huet ugefaangen Toun z'entdecken", + "turned_off": "{entity_name} gouf ausgeschalt", + "turned_on": "{entity_name} gouf ugeschalt", + "unsafe": "{entity_name} gouf ons\u00e9cher", + "vibration": "{entity_name} huet ugefaange Vibratiounen z'entdecken" + } + }, + "state": { + "_": { + "off": "Aus", + "on": "Un" + }, + "battery": { + "off": "Normal", + "on": "Niddreg" + }, + "cold": { + "off": "Normal", + "on": "Kal" + }, + "connectivity": { + "off": "Net Verbonnen", + "on": "Verbonnen" + }, + "door": { + "off": "Zou", + "on": "Op" + }, + "garage_door": { + "off": "Zou", + "on": "Op" + }, + "gas": { + "off": "Kloer", + "on": "Detekt\u00e9iert" + }, + "heat": { + "off": "Normal", + "on": "Waarm" + }, + "lock": { + "off": "Gespaart", + "on": "Net gespaart" + }, + "moisture": { + "off": "Dr\u00e9chen", + "on": "Naass" + }, + "motion": { + "off": "Roueg", + "on": "Detekt\u00e9iert" + }, + "occupancy": { + "off": "Roueg", + "on": "Detekt\u00e9iert" + }, + "opening": { + "off": "Zou", + "on": "Op" + }, + "presence": { + "off": "\u00cbnnerwee", + "on": "Doheem" + }, + "problem": { + "off": "OK", + "on": "Problem" + }, + "safety": { + "off": "S\u00e9cher", + "on": "Ons\u00e9cher" + }, + "smoke": { + "off": "Kloer", + "on": "Detekt\u00e9iert" + }, + "sound": { + "off": "Roueg", + "on": "Detekt\u00e9iert" + }, + "vibration": { + "off": "Kloer", + "on": "Detekt\u00e9iert" + }, + "window": { + "off": "Zou", + "on": "Op" + } + }, + "title": "Bin\u00e4ren Sensor" +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/translations/lt.json b/homeassistant/components/binary_sensor/translations/lt.json new file mode 100644 index 0000000000000..1214ac5347066 --- /dev/null +++ b/homeassistant/components/binary_sensor/translations/lt.json @@ -0,0 +1,60 @@ +{ + "state": { + "_": { + "off": "I\u0161jungta", + "on": "\u012ejungta" + }, + "connectivity": { + "off": "Atsijung\u0119s", + "on": "Prisijung\u0119s" + }, + "door": { + "off": "U\u017edaryta", + "on": "Atidaryta" + }, + "garage_door": { + "off": "U\u017edaryta", + "on": "Atidaryta" + }, + "gas": { + "off": "Neaptikta", + "on": "Aptikta" + }, + "moisture": { + "off": "Sausa", + "on": "\u0160lapia" + }, + "motion": { + "off": "Nejuda", + "on": "Aptiktas judesys" + }, + "occupancy": { + "off": "Laisva", + "on": "U\u017eimta" + }, + "opening": { + "off": "U\u017edaryta", + "on": "Atidaryta" + }, + "safety": { + "off": "Saugu", + "on": "Nesaugu" + }, + "smoke": { + "off": "Neaptikta", + "on": "Aptikta" + }, + "sound": { + "off": "Tylu", + "on": "Aptikta" + }, + "vibration": { + "off": "Neaptikta", + "on": "Aptikta" + }, + "window": { + "off": "U\u017edaryta", + "on": "Atidaryta" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/translations/lv.json b/homeassistant/components/binary_sensor/translations/lv.json new file mode 100644 index 0000000000000..14f39116c49e5 --- /dev/null +++ b/homeassistant/components/binary_sensor/translations/lv.json @@ -0,0 +1,91 @@ +{ + "device_automation": { + "trigger_type": { + "turned_off": "{entity_name} tika izsl\u0113gta", + "turned_on": "{entity_name} tika iesl\u0113gta" + } + }, + "state": { + "_": { + "off": "Izsl\u0113gts", + "on": "Iesl\u0113gts" + }, + "battery": { + "off": "Norm\u0101ls", + "on": "Zems" + }, + "cold": { + "off": "Norm\u0101ls", + "on": "Auksts" + }, + "connectivity": { + "off": "Atvienots", + "on": "Piesl\u0113dzies" + }, + "door": { + "off": "Aizv\u0113rtas", + "on": "Atv\u0113rtas" + }, + "garage_door": { + "off": "Aizv\u0113rtas", + "on": "Atv\u0113rtas" + }, + "gas": { + "off": "Br\u012bvs", + "on": "Sajusta" + }, + "heat": { + "off": "Norm\u0101ls", + "on": "Karsts" + }, + "lock": { + "off": "Sl\u0113gts", + "on": "Atsl\u0113gts" + }, + "moisture": { + "off": "Sauss", + "on": "Slapj\u0161" + }, + "motion": { + "off": "Br\u012bvs", + "on": "Sajusta" + }, + "occupancy": { + "off": "Br\u012bvs", + "on": "Aiz\u0146emts" + }, + "opening": { + "off": "Aizv\u0113rts", + "on": "Atv\u0113rts" + }, + "presence": { + "off": "Promb\u016btne", + "on": "M\u0101j\u0101s" + }, + "problem": { + "off": "OK", + "on": "Probl\u0113ma" + }, + "safety": { + "off": "Dro\u0161i", + "on": "Nedro\u0161i" + }, + "smoke": { + "off": "Br\u012bvs", + "on": "Sajusta" + }, + "sound": { + "off": "Br\u012bvs", + "on": "Sajusts" + }, + "vibration": { + "off": "Br\u012bvs", + "on": "Sajusts" + }, + "window": { + "off": "Aizv\u0113rts", + "on": "Atv\u0113rts" + } + }, + "title": "Bin\u0101rais sensors" +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/translations/nb.json b/homeassistant/components/binary_sensor/translations/nb.json new file mode 100644 index 0000000000000..76c567136466a --- /dev/null +++ b/homeassistant/components/binary_sensor/translations/nb.json @@ -0,0 +1,85 @@ +{ + "state": { + "_": { + "off": "Av", + "on": "P\u00e5" + }, + "battery": { + "off": "Normalt", + "on": "Lavt" + }, + "cold": { + "off": "", + "on": "Kald" + }, + "connectivity": { + "off": "Frakoblet", + "on": "Tilkoblet" + }, + "door": { + "off": "Lukket", + "on": "\u00c5pen" + }, + "garage_door": { + "off": "Lukket", + "on": "\u00c5pen" + }, + "gas": { + "off": "Klar", + "on": "Oppdaget" + }, + "heat": { + "off": "Normal", + "on": "Varm" + }, + "lock": { + "off": "L\u00e5st", + "on": "Ul\u00e5st" + }, + "moisture": { + "off": "T\u00f8rr", + "on": "Fuktig" + }, + "motion": { + "off": "Klar", + "on": "Oppdaget" + }, + "occupancy": { + "off": "Klar", + "on": "Oppdaget" + }, + "opening": { + "off": "Lukket", + "on": "\u00c5pen" + }, + "presence": { + "off": "Borte", + "on": "Hjemme" + }, + "problem": { + "off": "", + "on": "" + }, + "safety": { + "off": "Sikker", + "on": "Usikker" + }, + "smoke": { + "off": "Klar", + "on": "Oppdaget" + }, + "sound": { + "off": "Klar", + "on": "Oppdaget" + }, + "vibration": { + "off": "Klar", + "on": "Oppdaget" + }, + "window": { + "off": "Lukket", + "on": "\u00c5pent" + } + }, + "title": "Bin\u00e6r sensor" +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/translations/nl.json b/homeassistant/components/binary_sensor/translations/nl.json new file mode 100644 index 0000000000000..e99c41a473c06 --- /dev/null +++ b/homeassistant/components/binary_sensor/translations/nl.json @@ -0,0 +1,175 @@ +{ + "device_automation": { + "condition_type": { + "is_bat_low": "{entity_name} batterij is bijna leeg", + "is_cold": "{entity_name} is koud", + "is_connected": "{entity_name} is verbonden", + "is_gas": "{entity_name} detecteert gas", + "is_hot": "{entity_name} is hot", + "is_light": "{entity_name} detecteert licht", + "is_locked": "{entity_name} is vergrendeld", + "is_moist": "{entity_name} is vochtig", + "is_motion": "{entity_name} detecteert beweging", + "is_moving": "{entity_name} is in beweging", + "is_no_gas": "{entity_name} detecteert geen gas", + "is_no_light": "{entity_name} detecteert geen licht", + "is_no_motion": "{entity_name} detecteert geen beweging", + "is_no_problem": "{entity_name} detecteert geen probleem", + "is_no_smoke": "{entity_name} detecteert geen rook", + "is_no_sound": "{entity_name} detecteert geen geluid", + "is_no_vibration": "{entity_name} detecteert geen trillingen", + "is_not_bat_low": "{entity_name} batterij is normaal", + "is_not_cold": "{entity_name} is niet koud", + "is_not_connected": "{entity_name} is niet verbonden", + "is_not_hot": "{entity_name} is niet heet", + "is_not_locked": "{entity_name} is ontgrendeld", + "is_not_moist": "{entity_name} is droog", + "is_not_moving": "{entity_name} beweegt niet", + "is_not_occupied": "{entity_name} is niet bezet", + "is_not_open": "{entity_name} is gesloten", + "is_not_plugged_in": "{entity_name} is niet aangesloten", + "is_not_powered": "{entity_name} is niet van stroom voorzien...", + "is_not_present": "{entity_name} is niet aanwezig", + "is_not_unsafe": "{entity_name} is veilig", + "is_occupied": "{entity_name} bezet is", + "is_off": "{entity_name} is uitgeschakeld", + "is_on": "{entity_name} is ingeschakeld", + "is_open": "{entity_name} is open", + "is_plugged_in": "{entity_name} is aangesloten", + "is_powered": "{entity_name} is van stroom voorzien....", + "is_present": "{entity_name} is aanwezig", + "is_problem": "{entity_name} detecteert een probleem", + "is_smoke": "{entity_name} detecteert rook", + "is_sound": "{entity_name} detecteert geluid", + "is_unsafe": "{entity_name} is onveilig", + "is_vibration": "{entity_name} detecteert trillingen" + }, + "trigger_type": { + "bat_low": "{entity_name} batterij bijna leeg", + "cold": "{entity_name} werd koud", + "connected": "{entity_name} verbonden", + "gas": "{entity_name} begon gas te detecteren", + "hot": "{entity_name} werd heet", + "light": "{entity_name} begon licht te detecteren", + "locked": "{entity_name} vergrendeld", + "moist": "{entity_name} werd vochtig", + "motion": "{entity_name} begon beweging te detecteren", + "moving": "{entity_name} begon te bewegen", + "no_gas": "{entity_name} is gestopt met het detecteren van gas", + "no_light": "{entity_name} gestopt met het detecteren van licht", + "no_motion": "{entity_name} gestopt met het detecteren van beweging", + "no_problem": "{entity_name} gestopt met het detecteren van het probleem", + "no_smoke": "{entity_name} gestopt met het detecteren van rook", + "no_sound": "{entity_name} gestopt met het detecteren van geluid", + "no_vibration": "{entity_name} gestopt met het detecteren van trillingen", + "not_bat_low": "{entity_name} batterij normaal", + "not_cold": "{entity_name} werd niet koud", + "not_connected": "{entity_name} verbroken", + "not_hot": "{entity_name} werd niet warm", + "not_locked": "{entity_name} ontgrendeld", + "not_moist": "{entity_name} werd droog", + "not_moving": "{entity_name} gestopt met bewegen", + "not_occupied": "{entity_name} werd niet bezet", + "not_opened": "{entity_name} gesloten", + "not_plugged_in": "{entity_name} niet verbonden", + "not_powered": "{entity_name} niet ingeschakeld", + "not_present": "{entity_name} is niet aanwezig", + "not_unsafe": "{entity_name} werd veilig", + "occupied": "{entity_name} werd bezet", + "opened": "{entity_name} geopend", + "plugged_in": "{entity_name} aangesloten", + "powered": "{entity_name} heeft vermogen", + "present": "{entity_name} aanwezig", + "problem": "{entity_name} begonnen met het detecteren van een probleem", + "smoke": "{entity_name} begon rook te detecteren", + "sound": "{entity_name} begon geluid te detecteren", + "turned_off": "{entity_name} uitgeschakeld", + "turned_on": "{entity_name} ingeschakeld", + "unsafe": "{entity_name} werd onveilig", + "vibration": "{entity_name} begon trillingen te detecteren" + } + }, + "state": { + "_": { + "off": "Uit", + "on": "Aan" + }, + "battery": { + "off": "Normaal", + "on": "Laag" + }, + "cold": { + "off": "Normaal", + "on": "Koud" + }, + "connectivity": { + "off": "Verbroken", + "on": "Verbonden" + }, + "door": { + "off": "Dicht", + "on": "Open" + }, + "garage_door": { + "off": "Dicht", + "on": "Open" + }, + "gas": { + "off": "Niet gedetecteerd", + "on": "Gedetecteerd" + }, + "heat": { + "off": "Normaal", + "on": "Heet" + }, + "lock": { + "off": "Vergrendeld", + "on": "Ontgrendeld" + }, + "moisture": { + "off": "Droog", + "on": "Vochtig" + }, + "motion": { + "off": "Niet gedetecteerd", + "on": "Gedetecteerd" + }, + "occupancy": { + "off": "Niet gedetecteerd", + "on": "Gedetecteerd" + }, + "opening": { + "off": "Gesloten", + "on": "Open" + }, + "presence": { + "off": "Afwezig", + "on": "Thuis" + }, + "problem": { + "off": "OK", + "on": "Probleem" + }, + "safety": { + "off": "Veilig", + "on": "Onveilig" + }, + "smoke": { + "off": "Niet gedetecteerd", + "on": "Gedetecteerd" + }, + "sound": { + "off": "Niet gedetecteerd", + "on": "Gedetecteerd" + }, + "vibration": { + "off": "Niet gedetecteerd", + "on": "Gedetecteerd" + }, + "window": { + "off": "Dicht", + "on": "Open" + } + }, + "title": "Binaire sensor" +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/translations/nn.json b/homeassistant/components/binary_sensor/translations/nn.json new file mode 100644 index 0000000000000..740f55076f4e4 --- /dev/null +++ b/homeassistant/components/binary_sensor/translations/nn.json @@ -0,0 +1,85 @@ +{ + "state": { + "_": { + "off": "Av", + "on": "P\u00e5" + }, + "battery": { + "off": "Normalt", + "on": "L\u00e5gt" + }, + "cold": { + "off": "Normal", + "on": "Kald" + }, + "connectivity": { + "off": "Fr\u00e5kopla", + "on": "Tilkopla" + }, + "door": { + "off": "Lukka", + "on": "Open" + }, + "garage_door": { + "off": "Lukka", + "on": "Open" + }, + "gas": { + "off": "Ikkje oppdaga", + "on": "Oppdaga" + }, + "heat": { + "off": "Normal", + "on": "Varm" + }, + "lock": { + "off": "L\u00e5st", + "on": "Ul\u00e5st" + }, + "moisture": { + "off": "T\u00f8rr", + "on": "V\u00e5t" + }, + "motion": { + "off": "Ikkje oppdaga", + "on": "Oppdaga" + }, + "occupancy": { + "off": "Ikkje oppdaga", + "on": "Oppdaga" + }, + "opening": { + "off": "Lukka", + "on": "Open" + }, + "presence": { + "off": "Borte", + "on": "Heime" + }, + "problem": { + "off": "Ok", + "on": "Problem" + }, + "safety": { + "off": "Sikker", + "on": "Usikker" + }, + "smoke": { + "off": "Ikkje oppdaga", + "on": "Oppdaga" + }, + "sound": { + "off": "Ikkje oppdaga", + "on": "Oppdaga" + }, + "vibration": { + "off": "Ikkje oppdaga", + "on": "Oppdaga" + }, + "window": { + "off": "Lukka", + "on": "Open" + } + }, + "title": "Bin\u00e6rsensor" +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/translations/no.json b/homeassistant/components/binary_sensor/translations/no.json new file mode 100644 index 0000000000000..80ac73264dd2e --- /dev/null +++ b/homeassistant/components/binary_sensor/translations/no.json @@ -0,0 +1,121 @@ +{ + "device_automation": { + "condition_type": { + "is_bat_low": "{entity_name} batteriniv\u00e5et er lavt", + "is_cold": "{entity_name} er kald", + "is_connected": "{entity_name} er tilkoblet", + "is_gas": "{entity_name} registrerer gass", + "is_hot": "{entity_name} er varm", + "is_light": "{entity_name} registrerer lys", + "is_locked": "{entity_name} er l\u00e5st", + "is_moist": "{entity_name} er fuktig", + "is_motion": "{entity_name} registrerer bevegelse", + "is_moving": "{entity_name} er i bevegelse", + "is_no_gas": "{entity_name} registrerer ikke gass", + "is_no_light": "{entity_name} registrerer ikke lys", + "is_no_motion": "{entity_name} registrerer ikke bevegelse", + "is_no_problem": "{entity_name} registrerer ikke et problem", + "is_no_smoke": "{entity_name} registrerer ikke r\u00f8yk", + "is_no_sound": "{entity_name} registrerer ikke lyd", + "is_no_vibration": "{entity_name} registrerer ikke bevegelse", + "is_not_bat_low": "{entity_name} batteri er normalt", + "is_not_cold": "{entity_name} er ikke kald", + "is_not_connected": "{entity_name} er frakoblet", + "is_not_hot": "{entity_name} er ikke varm", + "is_not_locked": "{entity_name} er ul\u00e5st", + "is_not_moist": "{entity_name} er t\u00f8rr", + "is_not_moving": "{entity_name} er ikke i bevegelse", + "is_not_occupied": "{entity_name} er ledig", + "is_not_open": "{entity_name} er lukket", + "is_not_plugged_in": "{entity_name} er koblet fra", + "is_not_powered": "{entity_name} er spenningsl\u00f8s", + "is_not_present": "{entity_name} er ikke tilstede", + "is_not_unsafe": "{entity_name} er trygg", + "is_occupied": "{entity_name} er opptatt", + "is_off": "{entity_name} er sl\u00e5tt av", + "is_on": "{entity_name} er sl\u00e5tt p\u00e5", + "is_open": "{entity_name} er \u00e5pen", + "is_plugged_in": "{entity_name} er koblet til", + "is_powered": "{entity_name} er spenningssatt", + "is_present": "{entity_name} er tilstede", + "is_problem": "{entity_name} registrerer et problem", + "is_smoke": "{entity_name} registrerer r\u00f8yk", + "is_sound": "{entity_name} registrerer lyd", + "is_unsafe": "{entity_name} er utrygg", + "is_vibration": "{entity_name} registrerer vibrasjon" + }, + "trigger_type": { + "bat_low": "{entity_name} lavt batteri", + "cold": "{entity_name} ble kald", + "connected": "{entity_name} tilkoblet", + "gas": "{entity_name} begynte \u00e5 registrere gass", + "hot": "{entity_name} ble varm", + "light": "{entity_name} begynte \u00e5 registrere lys", + "locked": "{entity_name} l\u00e5st", + "moist": "{entity_name} ble fuktig", + "motion": "{entity_name} begynte \u00e5 registrere bevegelse", + "moving": "{entity_name} begynte \u00e5 bevege seg", + "no_gas": "{entity_name} sluttet \u00e5 registrere gass", + "no_light": "{entity_name} sluttet \u00e5 registrere lys", + "no_motion": "{entity_name} sluttet \u00e5 registrere bevegelse", + "no_problem": "{entity_name} sluttet \u00e5 registrere problem", + "no_smoke": "{entity_name} sluttet \u00e5 registrere r\u00f8yk", + "no_sound": "{entity_name} sluttet \u00e5 registrere lyd", + "no_vibration": "{entity_name} sluttet \u00e5 registrere vibrasjon", + "not_bat_low": "{entity_name} batteri normalt", + "not_cold": "{entity_name} ble ikke lenger kald", + "not_connected": "{entity_name} koblet fra", + "not_hot": "{entity_name} ble ikke lenger varm", + "not_locked": "{entity_name} l\u00e5st opp", + "not_moist": "{entity_name} ble t\u00f8rr", + "not_moving": "{entity_name} sluttet \u00e5 bevege seg", + "not_occupied": "{entity_name} ble ledig", + "not_opened": "{entity_name} stengt", + "not_plugged_in": "{entity_name} koblet fra", + "not_powered": "{entity_name} spenningsl\u00f8s", + "not_present": "{entity_name} ikke til stede", + "not_unsafe": "{entity_name} ble trygg", + "occupied": "{entity_name} ble opptatt", + "opened": "{entity_name} \u00e5pnet", + "plugged_in": "{entity_name} koblet til", + "powered": "{entity_name} spenningssatt", + "present": "{entity_name} tilstede", + "problem": "{entity_name} begynte \u00e5 registrere et problem", + "smoke": "{entity_name} begynte \u00e5 registrere r\u00f8yk", + "sound": "{entity_name} begynte \u00e5 registrere lyd", + "turned_off": "{entity_name} sl\u00e5tt av", + "turned_on": "{entity_name} sl\u00e5tt p\u00e5", + "unsafe": "{entity_name} ble usikker", + "vibration": "{entity_name} begynte \u00e5 oppdage vibrasjon" + } + }, + "state": { + "battery": { + "off": "Normalt", + "on": "Lavt" + }, + "cold": { + "on": "Kald" + }, + "gas": { + "off": "Klar", + "on": "Oppdaget" + }, + "heat": { + "on": "Varm" + }, + "moisture": { + "off": "T\u00f8rr", + "on": "V\u00e5t" + }, + "problem": { + "off": "", + "on": "" + }, + "safety": { + "off": "Sikker", + "on": "Usikker" + } + }, + "title": "Bin\u00e6r sensor" +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/translations/pl.json b/homeassistant/components/binary_sensor/translations/pl.json new file mode 100644 index 0000000000000..67af6898c39df --- /dev/null +++ b/homeassistant/components/binary_sensor/translations/pl.json @@ -0,0 +1,175 @@ +{ + "device_automation": { + "condition_type": { + "is_bat_low": "bateria {entity_name} jest roz\u0142adowana", + "is_cold": "sensor {entity_name} wykrywa zimno", + "is_connected": "sensor {entity_name} raportuje po\u0142\u0105czenie", + "is_gas": "sensor {entity_name} wykrywa gaz", + "is_hot": "sensor {entity_name} wykrywa gor\u0105co", + "is_light": "sensor {entity_name} wykrywa \u015bwiat\u0142o", + "is_locked": "sensor {entity_name} wykrywa zamkni\u0119cie", + "is_moist": "sensor {entity_name} wykrywa wilgo\u0107", + "is_motion": "sensor {entity_name} wykrywa ruch", + "is_moving": "sensor {entity_name} porusza si\u0119", + "is_no_gas": "sensor {entity_name} nie wykrywa gazu", + "is_no_light": "sensor {entity_name} nie wykrywa \u015bwiat\u0142a", + "is_no_motion": "sensor {entity_name} nie wykrywa ruchu", + "is_no_problem": "sensor {entity_name} nie wykrywa problemu", + "is_no_smoke": "sensor {entity_name} nie wykrywa dymu", + "is_no_sound": "sensor {entity_name} nie wykrywa d\u017awi\u0119ku", + "is_no_vibration": "sensor {entity_name} nie wykrywa wibracji", + "is_not_bat_low": "bateria {entity_name} nie jest roz\u0142adowana", + "is_not_cold": "sensor {entity_name} nie wykrywa zimna", + "is_not_connected": "sensor {entity_name} nie wykrywa roz\u0142\u0105czenia", + "is_not_hot": "sensor {entity_name} nie wykrywa gor\u0105ca", + "is_not_locked": "sensor {entity_name} nie wykrywa otwarcia", + "is_not_moist": "sensor {entity_name} nie wykrywa wilgoci", + "is_not_moving": "sensor {entity_name} nie porusza si\u0119", + "is_not_occupied": "sensor {entity_name} nie jest zaj\u0119ty", + "is_not_open": "sensor {entity_name} jest zamkni\u0119ty", + "is_not_plugged_in": "sensor {entity_name} wykrywa od\u0142\u0105czenie", + "is_not_powered": "sensor {entity_name} nie wykrywa zasilania", + "is_not_present": "sensor {entity_name} nie wykrywa obecno\u015bci", + "is_not_unsafe": "sensor {entity_name} nie wykrywa niebezpiecze\u0144stwa", + "is_occupied": "sensor {entity_name} jest zaj\u0119ty", + "is_off": "sensor {entity_name} jest wy\u0142\u0105czony", + "is_on": "sensor {entity_name} jest w\u0142\u0105czony", + "is_open": "sensor {entity_name} jest otwarty", + "is_plugged_in": "sensor {entity_name} wykrywa pod\u0142\u0105czenie", + "is_powered": "sensor {entity_name} wykrywa zasilanie", + "is_present": "sensor {entity_name} wykrywa obecno\u015b\u0107", + "is_problem": "sensor {entity_name} wykrywa problem", + "is_smoke": "sensor {entity_name} wykrywa dym", + "is_sound": "sensor {entity_name} wykrywa d\u017awi\u0119k", + "is_unsafe": "sensor {entity_name} wykrywa niebezpiecze\u0144stwo", + "is_vibration": "sensor {entity_name} wykrywa wibracje" + }, + "trigger_type": { + "bat_low": "nast\u0105pi roz\u0142adowanie baterii {entity_name}", + "cold": "sensor {entity_name} wykryje zimno", + "connected": "nast\u0105pi pod\u0142\u0105czenie {entity_name}", + "gas": "sensor {entity_name} wykryje gaz", + "hot": "sensor {entity_name} wykryje gor\u0105co", + "light": "sensor {entity_name} wykryje \u015bwiat\u0142o", + "locked": "nast\u0105pi zamkni\u0119cie {entity_name}", + "moist": "nast\u0105pi wykrycie wilgoci {entity_name}", + "motion": "sensor {entity_name} wykryje ruch", + "moving": "sensor {entity_name} zacznie porusza\u0107 si\u0119", + "no_gas": "sensor {entity_name} przestanie wykrywa\u0107 gaz", + "no_light": "sensor {entity_name} przestanie wykrywa\u0107 \u015bwiat\u0142o", + "no_motion": "sensor {entity_name} przestanie wykrywa\u0107 ruch", + "no_problem": "sensor {entity_name} przestanie wykrywa\u0107 problem", + "no_smoke": "sensor {entity_name} przestanie wykrywa\u0107 dym", + "no_sound": "sensor {entity_name} przestanie wykrywa\u0107 d\u017awi\u0119k", + "no_vibration": "sensor {entity_name} przestanie wykrywa\u0107 wibracje", + "not_bat_low": "nast\u0105pi na\u0142adowanie baterii {entity_name}", + "not_cold": "sensor {entity_name} przestanie wykrywa\u0107 zimno", + "not_connected": "nast\u0105pi roz\u0142\u0105czenie {entity_name}", + "not_hot": "sensor {entity_name} przestanie wykrywa\u0107 gor\u0105co", + "not_locked": "nast\u0105pi otwarcie {entity_name}", + "not_moist": "sensor {entity_name} przestanie wykrywa\u0107 wilgo\u0107", + "not_moving": "sensor {entity_name} przestanie porusza\u0107 si\u0119", + "not_occupied": "sensor {entity_name} przestanie by\u0107 zaj\u0119ty", + "not_opened": "nast\u0105pi zamkni\u0119cie {entity_name}", + "not_plugged_in": "nast\u0105pi od\u0142\u0105czenie {entity_name}", + "not_powered": "nast\u0105pi od\u0142\u0105czenie zasilania {entity_name}", + "not_present": "sensor {entity_name} przestanie wykrywa\u0107 obecno\u015b\u0107", + "not_unsafe": "sensor {entity_name} przestanie wykrywa\u0107 niebezpiecze\u0144stwo", + "occupied": "sensor {entity_name} stanie si\u0119 zaj\u0119ty", + "opened": "nast\u0105pi otwarcie {entity_name}", + "plugged_in": "nast\u0105pi pod\u0142\u0105czenie {entity_name}", + "powered": "nast\u0105pi pod\u0142\u0105czenie zasilenia {entity_name}", + "present": "sensor {entity_name} wykryje obecno\u015b\u0107", + "problem": "sensor {entity_name} wykryje problem", + "smoke": "sensor {entity_name} wykryje dym", + "sound": "sensor {entity_name} wykryje d\u017awi\u0119k", + "turned_off": "nast\u0105pi wy\u0142\u0105czenie {entity_name}", + "turned_on": "nast\u0105pi w\u0142\u0105czenie {entity_name}", + "unsafe": "sensor {entity_name} wykryje niebezpiecze\u0144stwo", + "vibration": "sensor {entity_name} wykryje wibracje" + } + }, + "state": { + "_": { + "off": "wy\u0142\u0105czony", + "on": "w\u0142\u0105czony" + }, + "battery": { + "off": "na\u0142adowana", + "on": "roz\u0142adowana" + }, + "cold": { + "off": "normalnie", + "on": "zimno" + }, + "connectivity": { + "off": "offline", + "on": "online" + }, + "door": { + "off": "zamkni\u0119te", + "on": "otwarte" + }, + "garage_door": { + "off": "zamkni\u0119ta", + "on": "otwarta" + }, + "gas": { + "off": "brak", + "on": "wykryto" + }, + "heat": { + "off": "normalnie", + "on": "gor\u0105co" + }, + "lock": { + "off": "zamkni\u0119ty", + "on": "otwarty" + }, + "moisture": { + "off": "brak wilgoci", + "on": "wilgo\u0107" + }, + "motion": { + "off": "brak", + "on": "wykryto" + }, + "occupancy": { + "off": "brak", + "on": "wykryto" + }, + "opening": { + "off": "zamkni\u0119te", + "on": "otwarte" + }, + "presence": { + "off": "poza domem", + "on": "w domu" + }, + "problem": { + "off": "ok", + "on": "problem" + }, + "safety": { + "off": "brak zagro\u017cenia", + "on": "zagro\u017cenie" + }, + "smoke": { + "off": "brak", + "on": "wykryto" + }, + "sound": { + "off": "brak", + "on": "wykryto" + }, + "vibration": { + "off": "brak", + "on": "wykryto" + }, + "window": { + "off": "zamkni\u0119te", + "on": "otwarte" + } + }, + "title": "Sensor binarny" +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/translations/pt-BR.json b/homeassistant/components/binary_sensor/translations/pt-BR.json new file mode 100644 index 0000000000000..52671ca0425ed --- /dev/null +++ b/homeassistant/components/binary_sensor/translations/pt-BR.json @@ -0,0 +1,85 @@ +{ + "state": { + "_": { + "off": "Desligado", + "on": "Ligado" + }, + "battery": { + "off": "Normal", + "on": "Fraca" + }, + "cold": { + "off": "Normal", + "on": "Frio" + }, + "connectivity": { + "off": "Desconectado", + "on": "Conectado" + }, + "door": { + "off": "Fechado", + "on": "Aberto" + }, + "garage_door": { + "off": "Fechado", + "on": "Aberto" + }, + "gas": { + "off": "Limpo", + "on": "Detectado" + }, + "heat": { + "off": "Normal", + "on": "Quente" + }, + "lock": { + "off": "Trancado", + "on": "Desbloqueado" + }, + "moisture": { + "off": "Seco", + "on": "Molhado" + }, + "motion": { + "off": "Desligado", + "on": "Detectado" + }, + "occupancy": { + "off": "Desocupado", + "on": "Detectado" + }, + "opening": { + "off": "Fechado", + "on": "Aberto" + }, + "presence": { + "off": "Ausente", + "on": "Em casa" + }, + "problem": { + "off": "OK", + "on": "Problema" + }, + "safety": { + "off": "Seguro", + "on": "N\u00e3o seguro" + }, + "smoke": { + "off": "Limpo", + "on": "Detectado" + }, + "sound": { + "off": "Limpo", + "on": "Detectado" + }, + "vibration": { + "off": "Limpo", + "on": "Detectado" + }, + "window": { + "off": "Fechado", + "on": "Aberto" + } + }, + "title": "Sensor bin\u00e1rio" +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/translations/pt.json b/homeassistant/components/binary_sensor/translations/pt.json new file mode 100644 index 0000000000000..c71f43eca7288 --- /dev/null +++ b/homeassistant/components/binary_sensor/translations/pt.json @@ -0,0 +1,166 @@ +{ + "device_automation": { + "condition_type": { + "is_bat_low": "a bateria {entity_name} est\u00e1 baixa", + "is_cold": "{entity_name} est\u00e1 frio", + "is_connected": "{entity_name} est\u00e1 ligado", + "is_gas": "{entity_name} est\u00e1 a detectar g\u00e1s", + "is_hot": "{entity_name} est\u00e1 quente", + "is_light": "{entity_name} est\u00e1 a detectar luz", + "is_locked": "{entity_name} est\u00e1 fechado", + "is_moist": "{entity_name} est\u00e1 h\u00famido", + "is_motion": "{entity_name} est\u00e1 a detectar movimento", + "is_moving": "{entity_name} est\u00e1 a mexer", + "is_no_gas": "{entity_name} n\u00e3o est\u00e1 a detectar g\u00e1s", + "is_no_light": "{entity_name} n\u00e3o est\u00e1 a detectar a luz", + "is_no_motion": "{entity_name} n\u00e3o est\u00e1 a detectar movimento", + "is_no_problem": "{entity_name} n\u00e3o est\u00e1 a detectar o problema", + "is_no_smoke": "{entity_name} n\u00e3o est\u00e1 a detectar fumo", + "is_no_sound": "{entity_name} n\u00e3o est\u00e1 a detectar som", + "is_no_vibration": "{entity_name} n\u00e3o est\u00e1 a detectar vibra\u00e7\u00f5es", + "is_not_bat_low": "{entity_name} a bateria est\u00e1 normal", + "is_not_cold": "{entity_name} n\u00e3o est\u00e1 frio", + "is_not_connected": "{entity_name} est\u00e1 desligado", + "is_not_hot": "{entity_name} n\u00e3o est\u00e1 quente", + "is_not_locked": "{entity_name} est\u00e1 destrancado", + "is_not_moist": "{entity_name} est\u00e1 seco", + "is_not_moving": "{entity_name} n\u00e3o est\u00e1 a mexer", + "is_not_occupied": "{entity_name} n\u00e3o est\u00e1 ocupado", + "is_not_open": "{entity_name} est\u00e1 fechada", + "is_not_plugged_in": "{entity_name} est\u00e1 desconectado", + "is_not_powered": "{entity_name} n\u00e3o est\u00e1 alimentado", + "is_not_present": "{entity_name} n\u00e3o est\u00e1 presente", + "is_not_unsafe": "{entity_name} est\u00e1 seguro", + "is_occupied": "{entity_name} est\u00e1 ocupado", + "is_off": "{entity_name} est\u00e1 desligado", + "is_on": "{entity_name} est\u00e1 ligado", + "is_open": "{entity_name} est\u00e1 aberto", + "is_plugged_in": "{entity_name} est\u00e1 conectado", + "is_powered": "{entity_name} est\u00e1 alimentado", + "is_present": "{entity_name} est\u00e1 presente", + "is_problem": "{entity_name} est\u00e1 a detectar um problema", + "is_smoke": "{entity_name} est\u00e1 a detectar fumo", + "is_sound": "{entity_name} est\u00e1 a detectar som", + "is_vibration": "{entity_name} est\u00e1 a detectar vibra\u00e7\u00f5es" + }, + "trigger_type": { + "moist": "ficou h\u00famido {entity_name}", + "moving": "{entity_name} come\u00e7ou a mover-se", + "no_gas": "{entity_name} deixou de detectar g\u00e1s", + "no_light": "{entity_name} deixou de detectar luz", + "no_motion": "{entity_name} deixou de detectar movimento", + "no_problem": "{entity_name} deixou de detectar problemas", + "no_smoke": "{entity_name} deixou de detectar fumo", + "no_sound": "{entity_name} deixou de detectar som", + "no_vibration": "{entity_name} deixou de detectar vibra\u00e7\u00e3o", + "not_bat_low": "{entity_name} bateria normal", + "not_cold": "{entity_name} deixou de estar frio", + "not_connected": "{entity_name} est\u00e1 desligado", + "not_hot": "{entity_name} deixou de estar quente", + "not_locked": "{entity_name} destrancado", + "not_moist": "{entity_name} ficou seco", + "not_moving": "{entity_name} deixou de se mover", + "not_occupied": "{entity_name} deixou de estar ocupado", + "not_opened": "fechado {entity_name}", + "not_plugged_in": "{entity_name} desligado", + "not_powered": "{entity_name} n\u00e3o alimentado", + "not_present": "ausente {entity_name}", + "not_unsafe": "ficou seguro {entity_name}", + "occupied": "ficou ocupado {entity_name}", + "opened": "{entity_name} aberto", + "plugged_in": "{entity_name} ligado", + "powered": "{entity_name} alimentado", + "present": "{entity_name} presente", + "problem": "foi detectado problema em {entity_name}", + "smoke": "foi detectado fumo em {entity_name}", + "sound": "foram detectadas sons em {entity_name}", + "turned_off": "foi desligado {entity_name}", + "turned_on": "foi ligado {entity_name}", + "unsafe": "ficou inseguro {entity_name}", + "vibration": "foram detectadas vibra\u00e7\u00f5es em {entity_name}" + } + }, + "state": { + "_": { + "off": "Desligado", + "on": "Ligado" + }, + "battery": { + "off": "Normal", + "on": "Baixo" + }, + "cold": { + "off": "Normal", + "on": "Frio" + }, + "connectivity": { + "off": "Desligado", + "on": "Ligado" + }, + "door": { + "off": "Fechada", + "on": "Aberta" + }, + "garage_door": { + "off": "Fechada", + "on": "Aberta" + }, + "gas": { + "off": "Limpo", + "on": "Detectado" + }, + "heat": { + "off": "Normal", + "on": "Quente" + }, + "lock": { + "off": "Trancada", + "on": "Destrancada" + }, + "moisture": { + "off": "Seco", + "on": "H\u00famido" + }, + "motion": { + "off": "Limpo", + "on": "Detectado" + }, + "occupancy": { + "off": "Limpo", + "on": "Detectado" + }, + "opening": { + "off": "Fechado", + "on": "Aberto" + }, + "presence": { + "off": "Fora", + "on": "Casa" + }, + "problem": { + "off": "OK", + "on": "Problema" + }, + "safety": { + "off": "Seguro", + "on": "Inseguro" + }, + "smoke": { + "off": "Limpo", + "on": "Detectado" + }, + "sound": { + "off": "Limpo", + "on": "Detectado" + }, + "vibration": { + "off": "Limpo", + "on": "Detetado" + }, + "window": { + "off": "Fechada", + "on": "Aberta" + } + }, + "title": "Sensor bin\u00e1rio" +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/translations/ro.json b/homeassistant/components/binary_sensor/translations/ro.json new file mode 100644 index 0000000000000..4ad892d234a49 --- /dev/null +++ b/homeassistant/components/binary_sensor/translations/ro.json @@ -0,0 +1,128 @@ +{ + "device_automation": { + "condition_type": { + "is_off": "{entity_name} oprit", + "is_on": "{entity_name} pornit" + }, + "trigger_type": { + "gas": "{entity_name} a \u00eenceput s\u0103 detecteze gaz", + "hot": "{entity_name} a devenit fierbinte", + "locked": "{entity_name} blocat", + "motion": "{entity_name} a \u00eenceput s\u0103 detecteze mi\u0219care", + "moving": "{entity_name} a \u00eenceput s\u0103 se mi\u0219te", + "no_light": "{entity_name} a oprit detectarea luminii", + "no_motion": "{entity_name} a oprit detectarea mi\u0219c\u0103rii", + "no_problem": "{entity_name} a oprit detectarea problemei", + "no_smoke": "{entity_name} a oprit detectarea fumului", + "no_sound": "{entity_name} a oprit detectarea de sunet", + "no_vibration": "{entity_name} a oprit detectarea vibra\u021biilor", + "not_bat_low": "{entity_name} baterie normal\u0103", + "not_cold": "{entity_name} nu mai este rece", + "not_connected": "{entity_name} deconectat", + "not_hot": "{entity_name} nu mai este fierbinte", + "not_locked": "{entity_name} deblocat", + "not_moist": "{entity_name} a devenit uscat", + "not_moving": "{entity_name} a \u00eencetat mi\u0219carea", + "not_occupied": "{entity_name} a devenit neocupat", + "not_plugged_in": "{entity_name} deconectat", + "not_powered": "{entity_name} nu este alimentat", + "not_present": "{entity_name} nu este prezent", + "not_unsafe": "{entity_name} a devenit sigur", + "occupied": "{entity_name} a devenit ocupat", + "opened": "{entity_name} deschis", + "plugged_in": "{entity_name} conectat", + "powered": "{entity_name} alimentat", + "present": "{entity_name} prezent", + "problem": "{entity_name} a \u00eenceput detectarea unei probleme", + "smoke": "{entity_name} a \u00eenceput s\u0103 detecteze fum", + "sound": "{entity_name} a \u00eenceput s\u0103 detecteze sunetul", + "turned_off": "{entity_name} oprit", + "turned_on": "{entity_name} pornit", + "unsafe": "{entity_name} a devenit nesigur", + "vibration": "{entity_name} a \u00eenceput s\u0103 detecteze vibra\u021biile" + } + }, + "state": { + "_": { + "off": "Oprit", + "on": "Pornit" + }, + "battery": { + "off": "Normal", + "on": "Sc\u0103zuta" + }, + "cold": { + "off": "Normal", + "on": "Rece" + }, + "connectivity": { + "off": "Deconectat", + "on": "Conectat" + }, + "door": { + "off": "\u00cenchis", + "on": "Deschis" + }, + "garage_door": { + "off": "\u00cenchis", + "on": "Deschis" + }, + "gas": { + "off": "Liber", + "on": "Detec\u021bie" + }, + "heat": { + "off": "Normal", + "on": "Fierbinte" + }, + "lock": { + "off": "Blocat", + "on": "Deblocat" + }, + "moisture": { + "off": "Uscat", + "on": "Umed" + }, + "motion": { + "off": "Liber", + "on": "Detec\u021bie" + }, + "occupancy": { + "off": "Liber", + "on": "Detec\u021bie" + }, + "opening": { + "off": "\u00cenchis", + "on": "Deschis" + }, + "presence": { + "off": "Plecat", + "on": "Acas\u0103" + }, + "problem": { + "off": "OK", + "on": "Problem\u0103" + }, + "safety": { + "off": "Sigur", + "on": "Nesigur" + }, + "smoke": { + "off": "Liber", + "on": "Detec\u021bie" + }, + "sound": { + "off": "Liber", + "on": "Detec\u021bie" + }, + "vibration": { + "off": "Liber", + "on": "Detec\u021bie" + }, + "window": { + "off": "\u00cenchis", + "on": "Deschis" + } + }, + "title": "Senzor binar" +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/translations/ru.json b/homeassistant/components/binary_sensor/translations/ru.json new file mode 100644 index 0000000000000..c3906cdc88c45 --- /dev/null +++ b/homeassistant/components/binary_sensor/translations/ru.json @@ -0,0 +1,175 @@ +{ + "device_automation": { + "condition_type": { + "is_bat_low": "{entity_name} \u0432 \u0440\u0430\u0437\u0440\u044f\u0436\u0435\u043d\u043d\u043e\u043c \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0438", + "is_cold": "{entity_name} \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u043e\u0445\u043b\u0430\u0436\u0434\u0435\u043d\u0438\u0435", + "is_connected": "{entity_name} \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435", + "is_gas": "{entity_name} \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u0433\u0430\u0437", + "is_hot": "{entity_name} \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u043d\u0430\u0433\u0440\u0435\u0432", + "is_light": "{entity_name} \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u0441\u0432\u0435\u0442", + "is_locked": "{entity_name} \u0432 \u0437\u0430\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u043e\u043c \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0438", + "is_moist": "{entity_name} \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u0432\u043b\u0430\u0433\u0443", + "is_motion": "{entity_name} \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u0434\u0432\u0438\u0436\u0435\u043d\u0438\u0435", + "is_moving": "{entity_name} \u043f\u0435\u0440\u0435\u043c\u0435\u0449\u0430\u0435\u0442\u0441\u044f", + "is_no_gas": "{entity_name} \u043d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u0433\u0430\u0437", + "is_no_light": "{entity_name} \u043d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u0441\u0432\u0435\u0442", + "is_no_motion": "{entity_name} \u043d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u0434\u0432\u0438\u0436\u0435\u043d\u0438\u0435", + "is_no_problem": "{entity_name} \u043d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u043f\u0440\u043e\u0431\u043b\u0435\u043c\u0443", + "is_no_smoke": "{entity_name} \u043d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u0434\u044b\u043c", + "is_no_sound": "{entity_name} \u043d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u0437\u0432\u0443\u043a", + "is_no_vibration": "{entity_name} \u043d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u0432\u0438\u0431\u0440\u0430\u0446\u0438\u044e", + "is_not_bat_low": "{entity_name} \u0432 \u043d\u043e\u0440\u043c\u0430\u043b\u044c\u043d\u043e\u043c \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0438", + "is_not_cold": "{entity_name} \u043d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u043e\u0445\u043b\u0430\u0436\u0434\u0435\u043d\u0438\u0435", + "is_not_connected": "{entity_name} \u043d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435", + "is_not_hot": "{entity_name} \u043d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u043d\u0430\u0433\u0440\u0435\u0432", + "is_not_locked": "{entity_name} \u0432 \u0440\u0430\u0437\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u043e\u043c \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0438", + "is_not_moist": "{entity_name} \u043d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u0432\u043b\u0430\u0433\u0443", + "is_not_moving": "{entity_name} \u043d\u0435 \u043f\u0435\u0440\u0435\u043c\u0435\u0449\u0430\u0435\u0442\u0441\u044f", + "is_not_occupied": "{entity_name} \u043d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u043f\u0440\u0438\u0441\u0443\u0442\u0441\u0442\u0432\u0438\u0435", + "is_not_open": "{entity_name} \u0432 \u0437\u0430\u043a\u0440\u044b\u0442\u043e\u043c \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0438", + "is_not_plugged_in": "{entity_name} \u043d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435", + "is_not_powered": "{entity_name} \u043d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u043f\u0438\u0442\u0430\u043d\u0438\u0435", + "is_not_present": "{entity_name} \u043d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u043f\u0440\u0438\u0441\u0443\u0442\u0441\u0442\u0432\u0438\u0435", + "is_not_unsafe": "{entity_name} \u0432 \u0431\u0435\u0437\u043e\u043f\u0430\u0441\u043d\u043e\u043c \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0438", + "is_occupied": "{entity_name} \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u043f\u0440\u0438\u0441\u0443\u0442\u0441\u0442\u0432\u0438\u0435", + "is_off": "{entity_name} \u0432 \u0432\u044b\u043a\u043b\u044e\u0447\u0435\u043d\u043d\u043e\u043c \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0438", + "is_on": "{entity_name} \u0432\u043e \u0432\u043a\u043b\u044e\u0447\u0435\u043d\u043d\u043e\u043c \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0438", + "is_open": "{entity_name} \u0432 \u043e\u0442\u043a\u0440\u044b\u0442\u043e\u043c \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0438", + "is_plugged_in": "{entity_name} \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435", + "is_powered": "{entity_name} \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u043f\u0438\u0442\u0430\u043d\u0438\u0435", + "is_present": "{entity_name} \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u043f\u0440\u0438\u0441\u0443\u0442\u0441\u0442\u0432\u0438\u0435", + "is_problem": "{entity_name} \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u043f\u0440\u043e\u0431\u043b\u0435\u043c\u0443", + "is_smoke": "{entity_name} \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u0434\u044b\u043c", + "is_sound": "{entity_name} \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u0437\u0432\u0443\u043a", + "is_unsafe": "{entity_name} \u0432 \u043d\u0435\u0431\u0435\u0437\u043e\u043f\u0430\u0441\u043d\u043e\u043c \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0438", + "is_vibration": "{entity_name} \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0435\u0442 \u0432\u0438\u0431\u0440\u0430\u0446\u0438\u044e" + }, + "trigger_type": { + "bat_low": "{entity_name} \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u0443\u0435\u0442 \u043d\u0438\u0437\u043a\u0438\u0439 \u0437\u0430\u0440\u044f\u0434", + "cold": "{entity_name} \u043e\u0445\u043b\u0430\u0436\u0434\u0430\u0435\u0442\u0441\u044f", + "connected": "{entity_name} \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0430\u0435\u0442\u0441\u044f", + "gas": "{entity_name} \u043d\u0430\u0447\u0438\u043d\u0430\u0435\u0442 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0442\u044c \u0433\u0430\u0437", + "hot": "{entity_name} \u043d\u0430\u0433\u0440\u0435\u0432\u0430\u0435\u0442\u0441\u044f", + "light": "{entity_name} \u043d\u0430\u0447\u0438\u043d\u0430\u0435\u0442 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0442\u044c \u0441\u0432\u0435\u0442", + "locked": "{entity_name} \u0431\u043b\u043e\u043a\u0438\u0440\u0443\u0435\u0442\u0441\u044f", + "moist": "{entity_name} \u043d\u0430\u0447\u0438\u043d\u0430\u0435\u0442 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0442\u044c \u0432\u043b\u0430\u0433\u0443", + "motion": "{entity_name} \u043d\u0430\u0447\u0438\u043d\u0430\u0435\u0442 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0442\u044c \u0434\u0432\u0438\u0436\u0435\u043d\u0438\u0435", + "moving": "{entity_name} \u043d\u0430\u0447\u0438\u043d\u0430\u0435\u0442 \u043f\u0435\u0440\u0435\u043c\u0435\u0449\u0435\u043d\u0438\u0435", + "no_gas": "{entity_name} \u043f\u0440\u0435\u043a\u0440\u0430\u0449\u0430\u0435\u0442 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0442\u044c \u0433\u0430\u0437", + "no_light": "{entity_name} \u043f\u0440\u0435\u043a\u0440\u0430\u0449\u0430\u0435\u0442 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0442\u044c \u0441\u0432\u0435\u0442", + "no_motion": "{entity_name} \u043f\u0440\u0435\u043a\u0440\u0430\u0449\u0430\u0435\u0442 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0442\u044c \u0434\u0432\u0438\u0436\u0435\u043d\u0438\u0435", + "no_problem": "{entity_name} \u043f\u0440\u0435\u043a\u0440\u0430\u0449\u0430\u0435\u0442 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0442\u044c \u043f\u0440\u043e\u0431\u043b\u0435\u043c\u0443", + "no_smoke": "{entity_name} \u043f\u0440\u0435\u043a\u0440\u0430\u0449\u0430\u0435\u0442 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0442\u044c \u0434\u044b\u043c", + "no_sound": "{entity_name} \u043f\u0440\u0435\u043a\u0440\u0430\u0449\u0430\u0435\u0442 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0442\u044c \u0437\u0432\u0443\u043a", + "no_vibration": "{entity_name} \u043f\u0440\u0435\u043a\u0440\u0430\u0449\u0430\u0435\u0442 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0442\u044c \u0432\u0438\u0431\u0440\u0430\u0446\u0438\u044e", + "not_bat_low": "{entity_name} \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u0443\u0435\u0442 \u043d\u043e\u0440\u043c\u0430\u043b\u044c\u043d\u044b\u0439 \u0437\u0430\u0440\u044f\u0434", + "not_cold": "{entity_name} \u043f\u0440\u0435\u043a\u0440\u0430\u0449\u0430\u0435\u0442 \u043e\u0445\u043b\u0430\u0436\u0434\u0430\u0442\u044c\u0441\u044f", + "not_connected": "{entity_name} \u043e\u0442\u043a\u043b\u044e\u0447\u0430\u0435\u0442\u0441\u044f", + "not_hot": "{entity_name} \u043f\u0440\u0435\u043a\u0440\u0430\u0449\u0430\u0435\u0442 \u043d\u0430\u0433\u0440\u0435\u0432\u0430\u0442\u044c\u0441\u044f", + "not_locked": "{entity_name} \u0440\u0430\u0437\u0431\u043b\u043e\u043a\u0438\u0440\u0443\u0435\u0442\u0441\u044f", + "not_moist": "{entity_name} \u043f\u0440\u0435\u043a\u0440\u0430\u0449\u0430\u0435\u0442 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0442\u044c \u0432\u043b\u0430\u0433\u0443", + "not_moving": "{entity_name} \u043f\u0440\u0435\u043a\u0440\u0430\u0449\u0430\u0435\u0442 \u043f\u0435\u0440\u0435\u043c\u0435\u0449\u0435\u043d\u0438\u0435", + "not_occupied": "{entity_name} \u043f\u0440\u0435\u043a\u0440\u0430\u0449\u0430\u0435\u0442 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0442\u044c \u043f\u0440\u0438\u0441\u0443\u0442\u0441\u0442\u0432\u0438\u0435", + "not_opened": "{entity_name} \u0437\u0430\u043a\u0440\u044b\u0432\u0430\u0435\u0442\u0441\u044f", + "not_plugged_in": "{entity_name} \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u0443\u0435\u0442 \u043e\u0442\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435", + "not_powered": "{entity_name} \u043d\u0435 \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u0443\u0435\u0442 \u043d\u0430\u043b\u0438\u0447\u0438\u0435 \u043f\u0438\u0442\u0430\u043d\u0438\u044f", + "not_present": "{entity_name} \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u0443\u0435\u0442 \u043e\u0442\u0441\u0443\u0442\u0441\u0442\u0432\u0438\u0435", + "not_unsafe": "{entity_name} \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u0443\u0435\u0442 \u0431\u0435\u0437\u043e\u043f\u0430\u0441\u043d\u043e\u0441\u0442\u044c", + "occupied": "{entity_name} \u043d\u0430\u0447\u0438\u043d\u0430\u0435\u0442 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0442\u044c \u043f\u0440\u0438\u0441\u0443\u0442\u0441\u0442\u0432\u0438\u0435", + "opened": "{entity_name} \u043e\u0442\u043a\u0440\u044b\u0432\u0430\u0435\u0442\u0441\u044f", + "plugged_in": "{entity_name} \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u0443\u0435\u0442 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435", + "powered": "{entity_name} \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u0443\u0435\u0442 \u043d\u0430\u043b\u0438\u0447\u0438\u0435 \u043f\u0438\u0442\u0430\u043d\u0438\u044f", + "present": "{entity_name} \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u0443\u0435\u0442 \u043f\u0440\u0438\u0441\u0443\u0442\u0441\u0442\u0432\u0438\u0435", + "problem": "{entity_name} \u043d\u0430\u0447\u0438\u043d\u0430\u0435\u0442 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0442\u044c \u043f\u0440\u043e\u0431\u043b\u0435\u043c\u0443", + "smoke": "{entity_name} \u043d\u0430\u0447\u0438\u043d\u0430\u0435\u0442 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0442\u044c \u0434\u044b\u043c", + "sound": "{entity_name} \u043d\u0430\u0447\u0438\u043d\u0430\u0435\u0442 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0442\u044c \u0437\u0432\u0443\u043a", + "turned_off": "{entity_name} \u0432\u044b\u043a\u043b\u044e\u0447\u0430\u0435\u0442\u0441\u044f", + "turned_on": "{entity_name} \u0432\u043a\u043b\u044e\u0447\u0430\u0435\u0442\u0441\u044f", + "unsafe": "{entity_name} \u043d\u0435 \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u0443\u0435\u0442 \u0431\u0435\u0437\u043e\u043f\u0430\u0441\u043d\u043e\u0441\u0442\u044c", + "vibration": "{entity_name} \u043d\u0430\u0447\u0438\u043d\u0430\u0435\u0442 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0432\u0430\u0442\u044c \u0432\u0438\u0431\u0440\u0430\u0446\u0438\u044e" + } + }, + "state": { + "_": { + "off": "\u0412\u044b\u043a\u043b", + "on": "\u0412\u043a\u043b" + }, + "battery": { + "off": "\u041d\u043e\u0440\u043c\u0430\u043b\u044c\u043d\u044b\u0439", + "on": "\u041d\u0438\u0437\u043a\u0438\u0439" + }, + "cold": { + "off": "\u041d\u043e\u0440\u043c\u0430", + "on": "\u041e\u0445\u043b\u0430\u0436\u0434\u0435\u043d\u0438\u0435" + }, + "connectivity": { + "off": "\u041e\u0442\u043a\u043b\u044e\u0447\u0435\u043d\u043e", + "on": "\u041f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u043e" + }, + "door": { + "off": "\u0417\u0430\u043a\u0440\u044b\u0442\u0430", + "on": "\u041e\u0442\u043a\u0440\u044b\u0442\u0430" + }, + "garage_door": { + "off": "\u0417\u0430\u043a\u0440\u044b\u0442\u044b", + "on": "\u041e\u0442\u043a\u0440\u044b\u0442\u044b" + }, + "gas": { + "off": "\u041d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d", + "on": "\u041e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d" + }, + "heat": { + "off": "\u041d\u043e\u0440\u043c\u0430", + "on": "\u041d\u0430\u0433\u0440\u0435\u0432" + }, + "lock": { + "off": "\u0417\u0430\u043a\u0440\u044b\u0442", + "on": "\u041e\u0442\u043a\u0440\u044b\u0442" + }, + "moisture": { + "off": "\u0421\u0443\u0445\u043e", + "on": "\u0412\u043b\u0430\u0436\u043d\u043e" + }, + "motion": { + "off": "\u041d\u0435\u0442 \u0434\u0432\u0438\u0436\u0435\u043d\u0438\u044f ", + "on": "\u0414\u0432\u0438\u0436\u0435\u043d\u0438\u0435" + }, + "occupancy": { + "off": "\u041d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u043e", + "on": "\u041e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u043e" + }, + "opening": { + "off": "\u0417\u0430\u043a\u0440\u044b\u0442\u043e", + "on": "\u041e\u0442\u043a\u0440\u044b\u0442\u043e" + }, + "presence": { + "off": "\u041d\u0435 \u0434\u043e\u043c\u0430", + "on": "\u0414\u043e\u043c\u0430" + }, + "problem": { + "off": "\u041e\u041a", + "on": "\u041f\u0440\u043e\u0431\u043b\u0435\u043c\u0430" + }, + "safety": { + "off": "\u0411\u0435\u0437\u043e\u043f\u0430\u0441\u043d\u043e", + "on": "\u041d\u0435\u0431\u0435\u0437\u043e\u043f\u0430\u0441\u043d\u043e" + }, + "smoke": { + "off": "\u041d\u0435\u0442 \u0434\u044b\u043c\u0430", + "on": "\u0414\u044b\u043c" + }, + "sound": { + "off": "\u041d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d", + "on": "\u041e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d" + }, + "vibration": { + "off": "\u041d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u0430", + "on": "\u041e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u0430" + }, + "window": { + "off": "\u0417\u0430\u043a\u0440\u044b\u0442\u043e", + "on": "\u041e\u0442\u043a\u0440\u044b\u0442\u043e" + } + }, + "title": "\u0411\u0438\u043d\u0430\u0440\u043d\u044b\u0439 \u0441\u0435\u043d\u0441\u043e\u0440" +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/translations/sk.json b/homeassistant/components/binary_sensor/translations/sk.json new file mode 100644 index 0000000000000..5cff82615ae01 --- /dev/null +++ b/homeassistant/components/binary_sensor/translations/sk.json @@ -0,0 +1,85 @@ +{ + "state": { + "_": { + "off": "Neakt\u00edvny", + "on": "Akt\u00edvny" + }, + "battery": { + "off": "Norm\u00e1lna", + "on": "Slab\u00e1" + }, + "cold": { + "off": "Norm\u00e1lny", + "on": "Studen\u00fd" + }, + "connectivity": { + "off": "Odpojen\u00fd", + "on": "Pripojen\u00fd" + }, + "door": { + "off": "Zatvoren\u00e9", + "on": "Otvoren\u00e9" + }, + "garage_door": { + "off": "Zatvoren\u00e9", + "on": "Otvoren\u00e9" + }, + "gas": { + "off": "\u017diadny plyn", + "on": "Zachyten\u00fd plyn" + }, + "heat": { + "off": "Norm\u00e1lny", + "on": "Hor\u00faci" + }, + "lock": { + "off": "Zamknut\u00fd", + "on": "Odomknut\u00fd" + }, + "moisture": { + "off": "Sucho", + "on": "Vlhko" + }, + "motion": { + "off": "K\u013eud", + "on": "Pohyb" + }, + "occupancy": { + "off": "Vo\u013en\u00e9", + "on": "Obsaden\u00e9" + }, + "opening": { + "off": "Zatvoren\u00e9", + "on": "Otvoren\u00e9" + }, + "presence": { + "off": "Pre\u010d", + "on": "Doma" + }, + "problem": { + "off": "OK", + "on": "Probl\u00e9m" + }, + "safety": { + "off": "Zabezpe\u010den\u00e9", + "on": "Nezabezpe\u010den\u00e9" + }, + "smoke": { + "off": "\u017diadny dym", + "on": "Zachyten\u00fd dym" + }, + "sound": { + "off": "Ticho", + "on": "Zachyten\u00fd zvuk" + }, + "vibration": { + "off": "K\u013eud", + "on": "Zachyten\u00e9 vibr\u00e1cie" + }, + "window": { + "off": "Zatvoren\u00e9", + "on": "Otvoren\u00e9" + } + }, + "title": "Bin\u00e1rny senzor" +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/translations/sl.json b/homeassistant/components/binary_sensor/translations/sl.json new file mode 100644 index 0000000000000..a340b62ac9970 --- /dev/null +++ b/homeassistant/components/binary_sensor/translations/sl.json @@ -0,0 +1,175 @@ +{ + "device_automation": { + "condition_type": { + "is_bat_low": "{entity_name} ima prazno baterijo", + "is_cold": "{entity_name} je hladen", + "is_connected": "{entity_name} je povezan", + "is_gas": "{entity_name} zaznava plin", + "is_hot": "{entity_name} je vro\u010d", + "is_light": "{entity_name} zaznava svetlobo", + "is_locked": "{entity_name} je zaklenjen", + "is_moist": "{entity_name} je vla\u017een", + "is_motion": "{entity_name} zaznava gibanje", + "is_moving": "{entity_name} se premika", + "is_no_gas": "{entity_name} ne zaznava plina", + "is_no_light": "{entity_name} ne zaznava svetlobe", + "is_no_motion": "{entity_name} ne zaznava gibanja", + "is_no_problem": "{entity_name} ne zaznava te\u017eav", + "is_no_smoke": "{entity_name} ne zaznava dima", + "is_no_sound": "{entity_name} ne zaznava zvoka", + "is_no_vibration": "{entity_name} ne zazna vibracij", + "is_not_bat_low": "{entity_name} baterija je polna", + "is_not_cold": "{entity_name} ni hladen", + "is_not_connected": "{entity_name} ni povezan", + "is_not_hot": "{entity_name} ni vro\u010d", + "is_not_locked": "{entity_name} je odklenjen", + "is_not_moist": "{entity_name} je suh", + "is_not_moving": "{entity_name} se ne premika", + "is_not_occupied": "{entity_name} ni zaseden", + "is_not_open": "{entity_name} je zaprt", + "is_not_plugged_in": "{entity_name} je odklopljen", + "is_not_powered": "{entity_name} ni napajan", + "is_not_present": "{entity_name} ni prisoten", + "is_not_unsafe": "{entity_name} je varen", + "is_occupied": "{entity_name} je zaseden", + "is_off": "{entity_name} je izklopljen", + "is_on": "{entity_name} je vklopljen", + "is_open": "{entity_name} je odprt", + "is_plugged_in": "{entity_name} je priklju\u010den", + "is_powered": "{entity_name} je vklopljen", + "is_present": "{entity_name} je prisoten", + "is_problem": "{entity_name} zaznava te\u017eavo", + "is_smoke": "{entity_name} zaznava dim", + "is_sound": "{entity_name} zaznava zvok", + "is_unsafe": "{entity_name} ni varen", + "is_vibration": "{entity_name} zaznava vibracije" + }, + "trigger_type": { + "bat_low": "{entity_name} ima prazno baterijo", + "cold": "{entity_name} je postal hladen", + "connected": "{entity_name} povezan", + "gas": "{entity_name} za\u010del zaznavati plin", + "hot": "{entity_name} je postal vro\u010d", + "light": "{entity_name} za\u010del zaznavati svetlobo", + "locked": "{entity_name} zaklenjen", + "moist": "{entity_name} postal vla\u017een", + "motion": "{entity_name} za\u010del zaznavati gibanje", + "moving": "{entity_name} se je za\u010del premikati", + "no_gas": "{entity_name} prenehal zaznavati plin", + "no_light": "{entity_name} prenehal zaznavati svetlobo", + "no_motion": "{entity_name} prenehal zaznavati gibanje", + "no_problem": "{entity_name} prenehal odkrivati te\u017eavo", + "no_smoke": "{entity_name} prenehal zaznavati dim", + "no_sound": "{entity_name} prenehal zaznavati zvok", + "no_vibration": "{entity_name} prenehal zaznavati vibracije", + "not_bat_low": "{entity_name} ima polno baterijo", + "not_cold": "{entity_name} ni ve\u010d hladen", + "not_connected": "{entity_name} prekinjen", + "not_hot": "{entity_name} ni ve\u010d vro\u010d", + "not_locked": "{entity_name} odklenjen", + "not_moist": "{entity_name} je postalo suh", + "not_moving": "{entity_name} se je prenehal premikati", + "not_occupied": "{entity_name} ni zaseden", + "not_opened": "{entity_name} zaprto", + "not_plugged_in": "{entity_name} odklopljen", + "not_powered": "{entity_name} ni napajan", + "not_present": "{entity_name} ni prisoten", + "not_unsafe": "{entity_name} je postal varen", + "occupied": "{entity_name} postal zaseden", + "opened": "{entity_name} odprl", + "plugged_in": "{entity_name} priklju\u010den", + "powered": "{entity_name} priklopljen", + "present": "{entity_name} prisoten", + "problem": "{entity_name} za\u010del odkrivati te\u017eavo", + "smoke": "{entity_name} za\u010del zaznavati dim", + "sound": "{entity_name} za\u010del zaznavati zvok", + "turned_off": "{entity_name} izklopljen", + "turned_on": "{entity_name} vklopljen", + "unsafe": "{entity_name} je postal nevaren", + "vibration": "{entity_name} je za\u010del odkrivat vibracije" + } + }, + "state": { + "_": { + "off": "Izklju\u010den", + "on": "Vklopljen" + }, + "battery": { + "off": "Normalno", + "on": "Nizko" + }, + "cold": { + "off": "Normalno", + "on": "Hladno" + }, + "connectivity": { + "off": "Povezava prekinjena", + "on": "Povezan" + }, + "door": { + "off": "Zaprto", + "on": "Odprto" + }, + "garage_door": { + "off": "Zaprto", + "on": "Odprto" + }, + "gas": { + "off": "\u010cisto", + "on": "Zaznano" + }, + "heat": { + "off": "Normalno", + "on": "Vro\u010de" + }, + "lock": { + "off": "Zaklenjeno", + "on": "Odklenjeno" + }, + "moisture": { + "off": "Suho", + "on": "Mokro" + }, + "motion": { + "off": "\u010cisto", + "on": "Zaznano" + }, + "occupancy": { + "off": "\u010cisto", + "on": "Zaznano" + }, + "opening": { + "off": "Zaprto", + "on": "Odprto" + }, + "presence": { + "off": "Odsoten", + "on": "Doma" + }, + "problem": { + "off": "OK", + "on": "Te\u017eava" + }, + "safety": { + "off": "Varno", + "on": "Nevarno" + }, + "smoke": { + "off": "\u010cisto", + "on": "Zaznano" + }, + "sound": { + "off": "\u010cisto", + "on": "Zaznano" + }, + "vibration": { + "off": "\u010cisto", + "on": "Zaznano" + }, + "window": { + "off": "Zaprto", + "on": "Odprto" + } + }, + "title": "Binarni senzor" +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/translations/sv.json b/homeassistant/components/binary_sensor/translations/sv.json new file mode 100644 index 0000000000000..c651d895fdaa2 --- /dev/null +++ b/homeassistant/components/binary_sensor/translations/sv.json @@ -0,0 +1,175 @@ +{ + "device_automation": { + "condition_type": { + "is_bat_low": "{entity_name}-batteriet \u00e4r l\u00e5gt", + "is_cold": "{entity_name} \u00e4r kall", + "is_connected": "{entity_name} \u00e4r ansluten", + "is_gas": "{entity_name} detekterar gas", + "is_hot": "{entity_name} \u00e4r varm", + "is_light": "{entity_name} uppt\u00e4cker ljus", + "is_locked": "{entity_name} \u00e4r l\u00e5st", + "is_moist": "{entity_name} \u00e4r fuktig", + "is_motion": "{entity_name} detekterar r\u00f6relse", + "is_moving": "{entity_name} r\u00f6r sig", + "is_no_gas": "{entity_name} uppt\u00e4cker inte gas", + "is_no_light": "{entity_name} uppt\u00e4cker inte ljus", + "is_no_motion": "{entity_name} detekterar inte r\u00f6relse", + "is_no_problem": "{entity_name} uppt\u00e4cker inte problem", + "is_no_smoke": "{entity_name} detekterar inte r\u00f6k", + "is_no_sound": "{entity_name} uppt\u00e4cker inte ljud", + "is_no_vibration": "{entity_name} uppt\u00e4cker inte vibrationer", + "is_not_bat_low": "{entity_name} batteri \u00e4r normalt", + "is_not_cold": "{entity_name} \u00e4r inte kall", + "is_not_connected": "{entity_name} \u00e4r fr\u00e5nkopplad", + "is_not_hot": "{entity_name} \u00e4r inte varm", + "is_not_locked": "{entity_name} \u00e4r ol\u00e5st", + "is_not_moist": "{entity_name} \u00e4r torr", + "is_not_moving": "{entity_name} r\u00f6r sig inte", + "is_not_occupied": "{entity_name} \u00e4r inte upptagen", + "is_not_open": "{entity_name} \u00e4r st\u00e4ngd", + "is_not_plugged_in": "{entity_name} \u00e4r urkopplad", + "is_not_powered": "{entity_name} \u00e4r inte str\u00f6mf\u00f6rd", + "is_not_present": "{entity_name} finns inte", + "is_not_unsafe": "{entity_name} \u00e4r s\u00e4ker", + "is_occupied": "{entity_name} \u00e4r upptagen", + "is_off": "{entity_name} \u00e4r avst\u00e4ngd", + "is_on": "{entity_name} \u00e4r p\u00e5", + "is_open": "{entity_name} \u00e4r \u00f6ppen", + "is_plugged_in": "{entity_name} \u00e4r ansluten", + "is_powered": "{entity_name} \u00e4r str\u00f6mf\u00f6rd", + "is_present": "{entity_name} \u00e4r n\u00e4rvarande", + "is_problem": "{entity_name} uppt\u00e4cker problem", + "is_smoke": "{entity_name} detekterar r\u00f6k", + "is_sound": "{entity_name} uppt\u00e4cker ljud", + "is_unsafe": "{entity_name} \u00e4r os\u00e4ker", + "is_vibration": "{entity_name} uppt\u00e4cker vibrationer" + }, + "trigger_type": { + "bat_low": "{entity_name} batteri l\u00e5gt", + "cold": "{entity_name} blev kall", + "connected": "{entity_name} ansluten", + "gas": "{entity_name} b\u00f6rjade detektera gas", + "hot": "{entity_name} blev varm", + "light": "{entity_name} b\u00f6rjade uppt\u00e4cka ljus", + "locked": "{entity_name} l\u00e5st", + "moist": "{entity_name} blev fuktig", + "motion": "{entity_name} b\u00f6rjade detektera r\u00f6relse", + "moving": "{entity_name} b\u00f6rjade r\u00f6ra sig", + "no_gas": "{entity_name} slutade uppt\u00e4cka gas", + "no_light": "{entity_name} slutade uppt\u00e4cka ljus", + "no_motion": "{entity_name} slutade uppt\u00e4cka r\u00f6relse", + "no_problem": "{entity_name} slutade uppt\u00e4cka problem", + "no_smoke": "{entity_name} slutade detektera r\u00f6k", + "no_sound": "{entity_name} slutade uppt\u00e4cka ljud", + "no_vibration": "{entity_name} slutade uppt\u00e4cka vibrationer", + "not_bat_low": "{entity_name} batteri normalt", + "not_cold": "{entity_name} blev inte kall", + "not_connected": "{entity_name} fr\u00e5nkopplad", + "not_hot": "{entity_name} blev inte varm", + "not_locked": "{entity_name} ol\u00e5st", + "not_moist": "{entity_name} blev torr", + "not_moving": "{entity_name} slutade r\u00f6ra sig", + "not_occupied": "{entity_name} blev inte upptagen", + "not_opened": "{entity_name} st\u00e4ngd", + "not_plugged_in": "{entity_name} urkopplad", + "not_powered": "{entity_name} inte str\u00f6mf\u00f6rd", + "not_present": "{entity_name} inte n\u00e4rvarande", + "not_unsafe": "{entity_name} blev s\u00e4ker", + "occupied": "{entity_name} blev upptagen", + "opened": "{entity_name} \u00f6ppnades", + "plugged_in": "{entity_name} ansluten", + "powered": "{entity_name} str\u00f6mf\u00f6rd", + "present": "{entity_name} n\u00e4rvarande", + "problem": "{entity_name} b\u00f6rjade uppt\u00e4cka problem", + "smoke": "{entity_name} b\u00f6rjade detektera r\u00f6k", + "sound": "{entity_name} b\u00f6rjade uppt\u00e4cka ljud", + "turned_off": "{entity_name} st\u00e4ngdes av", + "turned_on": "{entity_name} slogs p\u00e5", + "unsafe": "{entity_name} blev os\u00e4ker", + "vibration": "{entity_name} b\u00f6rjade detektera vibrationer" + } + }, + "state": { + "_": { + "off": "Av", + "on": "P\u00e5" + }, + "battery": { + "off": "Normal", + "on": "L\u00e5g" + }, + "cold": { + "off": "Normal", + "on": "Kallt" + }, + "connectivity": { + "off": "Fr\u00e5nkopplad", + "on": "Ansluten" + }, + "door": { + "off": "St\u00e4ngd", + "on": "\u00d6ppen" + }, + "garage_door": { + "off": "St\u00e4ngd", + "on": "\u00d6ppen" + }, + "gas": { + "off": "Klart", + "on": "Detekterad" + }, + "heat": { + "off": "Normal", + "on": "Varmt" + }, + "lock": { + "off": "L\u00e5st", + "on": "Ol\u00e5st" + }, + "moisture": { + "off": "Torr", + "on": "Bl\u00f6t" + }, + "motion": { + "off": "Klart", + "on": "Detekterad" + }, + "occupancy": { + "off": "Tomt", + "on": "Detekterad" + }, + "opening": { + "off": "St\u00e4ngd", + "on": "\u00d6ppen" + }, + "presence": { + "off": "Borta", + "on": "Hemma" + }, + "problem": { + "off": "Ok", + "on": "Problem" + }, + "safety": { + "off": "S\u00e4ker", + "on": "Os\u00e4ker" + }, + "smoke": { + "off": "Klart", + "on": "Detekterad" + }, + "sound": { + "off": "Klart", + "on": "Detekterad" + }, + "vibration": { + "off": "Klart", + "on": "Detekterad" + }, + "window": { + "off": "St\u00e4ngt", + "on": "\u00d6ppet" + } + }, + "title": "Bin\u00e4r sensor" +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/translations/ta.json b/homeassistant/components/binary_sensor/translations/ta.json new file mode 100644 index 0000000000000..a720b61c69c72 --- /dev/null +++ b/homeassistant/components/binary_sensor/translations/ta.json @@ -0,0 +1,60 @@ +{ + "state": { + "_": { + "off": "\u0b86\u0b83\u0baa\u0bcd", + "on": "\u0b86\u0ba9\u0bcd " + }, + "gas": { + "off": "\u0ba4\u0bc6\u0bb3\u0bbf\u0bb5\u0bc1", + "on": "\u0b95\u0ba3\u0bcd\u0b9f\u0bb1\u0bbf\u0baf\u0baa\u0bcd\u0baa\u0b9f\u0bcd\u0b9f\u0ba4\u0bc1" + }, + "heat": { + "off": "\u0b86\u0b83\u0baa\u0bcd", + "on": "\u0b9a\u0bc2\u0b9f\u0bbe\u0ba9" + }, + "moisture": { + "off": "\u0b89\u0bb2\u0bb0\u0bcd", + "on": "\u0b88\u0bb0\u0bae\u0bcd" + }, + "motion": { + "off": "\u0ba4\u0bc6\u0bb3\u0bbf\u0bb5\u0bc1 ", + "on": "\u0b95\u0ba3\u0bcd\u0b9f\u0bb1\u0bbf\u0baf\u0baa\u0bcd\u0baa\u0b9f\u0bcd\u0b9f\u0ba4\u0bc1" + }, + "occupancy": { + "off": "\u0ba4\u0bc6\u0bb3\u0bbf\u0bb5\u0bc1 ", + "on": "\u0b95\u0ba3\u0bcd\u0b9f\u0bb1\u0bbf\u0baf\u0baa\u0bcd\u0baa\u0b9f\u0bcd\u0b9f\u0ba4\u0bc1" + }, + "opening": { + "off": "\u0bae\u0bc2\u0b9f\u0baa\u0bcd\u0baa\u0b9f\u0bcd\u0b9f\u0ba4\u0bc1", + "on": "\u0ba4\u0bbf\u0bb1\u0b95\u0bcd\u0b95\u0baa\u0bcd\u0baa\u0b9f\u0bcd\u0b9f\u0bc1\u0bb3\u0bcd\u0bb3\u0ba4\u0bc1" + }, + "presence": { + "off": "\u0ba4\u0bca\u0bb2\u0bc8\u0bb5\u0bbf\u0bb2\u0bcd", + "on": "\u0bae\u0bc1\u0b95\u0baa\u0bcd\u0baa\u0bc1" + }, + "problem": { + "off": "\u0b9a\u0bb0\u0bbf", + "on": "\u0b9a\u0bbf\u0b95\u0bcd\u0b95\u0bb2\u0bcd" + }, + "safety": { + "off": "\u0baa\u0bbe\u0ba4\u0bc1\u0b95\u0bbe\u0baa\u0bcd\u0baa\u0bbe\u0ba9", + "on": "\u0baa\u0bbe\u0ba4\u0bc1\u0b95\u0bbe\u0baa\u0bcd\u0baa\u0bb1\u0bcd\u0bb1" + }, + "smoke": { + "off": "\u0ba4\u0bc6\u0bb3\u0bbf\u0bb5\u0bc1 ", + "on": "\u0b95\u0ba3\u0bcd\u0b9f\u0bb1\u0bbf\u0baf\u0baa\u0bcd\u0baa\u0b9f\u0bcd\u0b9f\u0ba4\u0bc1" + }, + "sound": { + "off": "\u0ba4\u0bc6\u0bb3\u0bbf\u0bb5\u0bc1 ", + "on": "\u0b95\u0ba3\u0bcd\u0b9f\u0bb1\u0bbf\u0baf\u0baa\u0bcd\u0baa\u0b9f\u0bcd\u0b9f\u0ba4\u0bc1" + }, + "vibration": { + "off": "\u0ba4\u0bc6\u0bb3\u0bbf\u0bb5\u0bc1 ", + "on": "\u0b95\u0ba3\u0bcd\u0b9f\u0bb1\u0bbf\u0baf\u0baa\u0bcd\u0baa\u0b9f\u0bcd\u0b9f\u0ba4\u0bc1" + }, + "window": { + "off": "\u0bae\u0bc2\u0b9f\u0baa\u0bcd\u0baa\u0b9f\u0bcd\u0b9f\u0bc1\u0bb3\u0bcd\u0bb3\u0ba4\u0bc1", + "on": "\u0ba4\u0bbf\u0bb1\u0b95\u0bcd\u0b95\u0baa\u0bcd\u0baa\u0b9f\u0bcd\u0b9f\u0bc1\u0bb3\u0bcd\u0bb3\u0ba4\u0bc1" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/translations/te.json b/homeassistant/components/binary_sensor/translations/te.json new file mode 100644 index 0000000000000..4d5817d74927f --- /dev/null +++ b/homeassistant/components/binary_sensor/translations/te.json @@ -0,0 +1,84 @@ +{ + "state": { + "_": { + "off": "\u0c06\u0c2b\u0c4d", + "on": "\u0c06\u0c28\u0c4d" + }, + "battery": { + "off": "\u0c38\u0c3e\u0c27\u0c3e\u0c30\u0c23", + "on": "\u0c24\u0c15\u0c4d\u0c15\u0c41\u0c35" + }, + "cold": { + "on": "\u0c1a\u0c32\u0c4d\u0c32\u0c28\u0c3f" + }, + "connectivity": { + "off": "\u0c21\u0c3f\u0c38\u0c4d\u0c15\u0c28\u0c46\u0c15\u0c4d\u0c1f\u0c4d", + "on": "\u0c15\u0c28\u0c46\u0c15\u0c4d\u0c1f\u0c4d" + }, + "door": { + "off": "\u0c2e\u0c42\u0c38\u0c41\u0c15\u0c41\u0c02\u0c26\u0c3f", + "on": "\u0c24\u0c46\u0c30\u0c3f\u0c1a\u0c3f\u0c35\u0c41\u0c02\u0c26\u0c3f" + }, + "garage_door": { + "off": "\u0c2e\u0c42\u0c38\u0c41\u0c15\u0c41\u0c02\u0c26\u0c3f", + "on": "\u0c24\u0c46\u0c30\u0c3f\u0c1a\u0c3f\u0c35\u0c41\u0c02\u0c26\u0c3f" + }, + "gas": { + "off": "\u0c17\u0c4d\u0c2f\u0c3e\u0c38\u0c4d \u0c06\u0c2b\u0c4d", + "on": "\u0c17\u0c4d\u0c2f\u0c3e\u0c38\u0c4d \u0c06\u0c28\u0c4d" + }, + "heat": { + "off": "\u0c38\u0c3e\u0c27\u0c3e\u0c30\u0c23", + "on": "\u0c35\u0c47\u0c21\u0c3f" + }, + "lock": { + "off": "\u0c32\u0c3e\u0c15\u0c4d \u0c1a\u0c47\u0c2f\u0c2c\u0c21\u0c3f\u0c02\u0c26\u0c3f", + "on": "\u0c32\u0c3e\u0c15\u0c4d \u0c1a\u0c47\u0c2f\u0c2c\u0c21\u0c32\u0c47\u0c26\u0c41" + }, + "moisture": { + "off": "\u0c2a\u0c4a\u0c21\u0c3f", + "on": "\u0c24\u0c21\u0c3f" + }, + "motion": { + "off": "\u0c15\u0c26\u0c32\u0c3f\u0c15 \u0c32\u0c47\u0c26\u0c41", + "on": "\u0c15\u0c26\u0c32\u0c3f\u0c15 \u0c35\u0c41\u0c02\u0c26\u0c3f" + }, + "occupancy": { + "off": "\u0c09\u0c28\u0c3f\u0c15\u0c3f\u0c21\u0c3f \u0c32\u0c47\u0c26\u0c41", + "on": "\u0c09\u0c28\u0c3f\u0c15\u0c3f\u0c21\u0c3f \u0c09\u0c02\u0c26\u0c3f" + }, + "opening": { + "off": "\u0c2e\u0c42\u0c38\u0c3f\u0c35\u0c41\u0c02\u0c26\u0c3f", + "on": "\u0c24\u0c46\u0c30\u0c41\u0c1a\u0c41\u0c15\u0c41\u0c02\u0c1f\u0c4b\u0c02\u0c26\u0c3f" + }, + "presence": { + "off": "\u0c2c\u0c2f\u0c1f", + "on": "\u0c07\u0c02\u0c1f" + }, + "problem": { + "off": "OK", + "on": "\u0c38\u0c2e\u0c38\u0c4d\u0c2f" + }, + "safety": { + "off": "\u0c15\u0c4d\u0c37\u0c47\u0c2e\u0c02", + "on": "\u0c15\u0c4d\u0c37\u0c47\u0c2e\u0c02 \u0c15\u0c3e\u0c26\u0c41" + }, + "smoke": { + "off": "\u0c2a\u0c4a\u0c17 \u0c32\u0c47\u0c26\u0c41", + "on": "\u0c2a\u0c4a\u0c17 \u0c35\u0c41\u0c02\u0c26\u0c3f" + }, + "sound": { + "off": "\u0c36\u0c2c\u0c4d\u0c27\u0c02 \u0c32\u0c47\u0c26\u0c41", + "on": "\u0c36\u0c2c\u0c4d\u0c27\u0c02 \u0c35\u0c41\u0c02\u0c26\u0c3f" + }, + "vibration": { + "off": "\u0c15\u0c26\u0c32\u0c1f\u0c4d\u0c32\u0c47\u0c26\u0c41", + "on": "\u0c15\u0c26\u0c41\u0c32\u0c41\u0c24\u0c4b\u0c02\u0c26\u0c3f" + }, + "window": { + "off": "\u0c2e\u0c42\u0c38\u0c41\u0c15\u0c41\u0c02\u0c26\u0c3f", + "on": "\u0c24\u0c46\u0c30\u0c3f\u0c1a\u0c3f\u0c35\u0c41\u0c02\u0c26\u0c3f" + } + }, + "title": "\u0c2c\u0c48\u0c28\u0c30\u0c40 \u0c38\u0c46\u0c28\u0c4d\u0c38\u0c3e\u0c30\u0c4d" +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/translations/th.json b/homeassistant/components/binary_sensor/translations/th.json new file mode 100644 index 0000000000000..b8f41eb2b73a1 --- /dev/null +++ b/homeassistant/components/binary_sensor/translations/th.json @@ -0,0 +1,85 @@ +{ + "state": { + "_": { + "off": "\u0e1b\u0e34\u0e14", + "on": "\u0e40\u0e1b\u0e34\u0e14" + }, + "battery": { + "off": "\u0e1b\u0e01\u0e15\u0e34", + "on": "\u0e15\u0e48\u0e33" + }, + "cold": { + "off": "\u0e1b\u0e01\u0e15\u0e34", + "on": "\u0e2b\u0e19\u0e32\u0e27" + }, + "connectivity": { + "off": "\u0e15\u0e31\u0e14\u0e01\u0e32\u0e23\u0e40\u0e0a\u0e37\u0e48\u0e2d\u0e21\u0e15\u0e48\u0e2d", + "on": "\u0e40\u0e0a\u0e37\u0e48\u0e2d\u0e21\u0e15\u0e48\u0e2d\u0e41\u0e25\u0e49\u0e27" + }, + "door": { + "off": "\u0e1b\u0e34\u0e14\u0e41\u0e25\u0e49\u0e27", + "on": "\u0e40\u0e1b\u0e34\u0e14" + }, + "garage_door": { + "off": "\u0e1b\u0e34\u0e14\u0e41\u0e25\u0e49\u0e27", + "on": "\u0e40\u0e1b\u0e34\u0e14" + }, + "gas": { + "off": "\u0e44\u0e21\u0e48\u0e1e\u0e1a\u0e41\u0e01\u0e4a\u0e2a", + "on": "\u0e15\u0e23\u0e27\u0e08\u0e1e\u0e1a\u0e41\u0e01\u0e4a\u0e2a" + }, + "heat": { + "off": "\u0e1b\u0e01\u0e15\u0e34", + "on": "\u0e23\u0e49\u0e2d\u0e19" + }, + "lock": { + "off": "\u0e25\u0e47\u0e2d\u0e04\u0e2d\u0e22\u0e39\u0e48", + "on": "\u0e1b\u0e25\u0e14\u0e25\u0e47\u0e2d\u0e04\u0e41\u0e25\u0e49\u0e27" + }, + "moisture": { + "off": "\u0e41\u0e2b\u0e49\u0e07", + "on": "\u0e40\u0e1b\u0e35\u0e22\u0e01" + }, + "motion": { + "off": "\u0e44\u0e21\u0e48\u0e1e\u0e1a\u0e01\u0e32\u0e23\u0e40\u0e04\u0e25\u0e37\u0e48\u0e2d\u0e19\u0e44\u0e2b\u0e27", + "on": "\u0e1e\u0e1a\u0e01\u0e32\u0e23\u0e40\u0e04\u0e25\u0e37\u0e48\u0e2d\u0e19\u0e44\u0e2b\u0e27" + }, + "occupancy": { + "off": "\u0e44\u0e21\u0e48\u0e1e\u0e1a", + "on": "\u0e1e\u0e1a" + }, + "opening": { + "off": "\u0e1b\u0e34\u0e14", + "on": "\u0e40\u0e1b\u0e34\u0e14" + }, + "presence": { + "off": "\u0e44\u0e21\u0e48\u0e2d\u0e22\u0e39\u0e48", + "on": "\u0e2d\u0e22\u0e39\u0e48\u0e1a\u0e49\u0e32\u0e19" + }, + "problem": { + "off": "\u0e15\u0e01\u0e25\u0e07", + "on": "\u0e1b\u0e31\u0e0d\u0e2b\u0e32" + }, + "safety": { + "off": "\u0e1b\u0e34\u0e14", + "on": "\u0e40\u0e1b\u0e34\u0e14" + }, + "smoke": { + "off": "\u0e44\u0e21\u0e48\u0e1e\u0e1a\u0e04\u0e27\u0e31\u0e19", + "on": "\u0e1e\u0e1a\u0e04\u0e27\u0e31\u0e19" + }, + "sound": { + "off": "\u0e44\u0e21\u0e48\u0e44\u0e14\u0e49\u0e22\u0e34\u0e19", + "on": "\u0e44\u0e14\u0e49\u0e22\u0e34\u0e19" + }, + "vibration": { + "off": "\u0e44\u0e21\u0e48\u0e1e\u0e1a\u0e01\u0e32\u0e23\u0e2a\u0e31\u0e48\u0e19", + "on": "\u0e1e\u0e1a\u0e01\u0e32\u0e23\u0e2a\u0e31\u0e48\u0e19" + }, + "window": { + "off": "\u0e1b\u0e34\u0e14\u0e41\u0e25\u0e49\u0e27", + "on": "\u0e40\u0e1b\u0e34\u0e14" + } + }, + "title": "\u0e40\u0e0b\u0e47\u0e19\u0e40\u0e0b\u0e2d\u0e23\u0e4c\u0e41\u0e1a\u0e1a\u0e44\u0e1a\u0e19\u0e32\u0e23\u0e35" +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/translations/tr.json b/homeassistant/components/binary_sensor/translations/tr.json new file mode 100644 index 0000000000000..582668c179d4c --- /dev/null +++ b/homeassistant/components/binary_sensor/translations/tr.json @@ -0,0 +1,85 @@ +{ + "state": { + "_": { + "off": "Kapal\u0131", + "on": "A\u00e7\u0131k" + }, + "battery": { + "off": "Normal", + "on": "D\u00fc\u015f\u00fck" + }, + "cold": { + "off": "Normal", + "on": "So\u011fuk" + }, + "connectivity": { + "off": "Ba\u011flant\u0131 kesildi", + "on": "Ba\u011fl\u0131" + }, + "door": { + "off": "Kapal\u0131", + "on": "A\u00e7\u0131k" + }, + "garage_door": { + "off": "Kapal\u0131", + "on": "A\u00e7\u0131k" + }, + "gas": { + "off": "Temiz", + "on": "Alg\u0131land\u0131" + }, + "heat": { + "off": "Normal", + "on": "S\u0131cak" + }, + "lock": { + "off": "Kilit kapal\u0131", + "on": "Kilit a\u00e7\u0131k" + }, + "moisture": { + "off": "Kuru", + "on": "Islak" + }, + "motion": { + "off": "Temiz", + "on": "Alg\u0131land\u0131" + }, + "occupancy": { + "off": "Temiz", + "on": "Alg\u0131land\u0131" + }, + "opening": { + "off": "Kapal\u0131", + "on": "A\u00e7\u0131k" + }, + "presence": { + "off": "D\u0131\u015farda", + "on": "Evde" + }, + "problem": { + "off": "Tamam", + "on": "Sorun" + }, + "safety": { + "off": "G\u00fcvenli", + "on": "G\u00fcvensiz" + }, + "smoke": { + "off": "Temiz", + "on": "Alg\u0131land\u0131" + }, + "sound": { + "off": "Temiz", + "on": "Alg\u0131land\u0131" + }, + "vibration": { + "off": "Temiz", + "on": "Alg\u0131land\u0131" + }, + "window": { + "off": "Kapal\u0131", + "on": "A\u00e7\u0131k" + } + }, + "title": "\u0130kili sens\u00f6r" +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/translations/uk.json b/homeassistant/components/binary_sensor/translations/uk.json new file mode 100644 index 0000000000000..7b01acae4fb55 --- /dev/null +++ b/homeassistant/components/binary_sensor/translations/uk.json @@ -0,0 +1,85 @@ +{ + "state": { + "_": { + "off": "\u0412\u0438\u043c\u043a\u043d\u0435\u043d\u043e", + "on": "\u0423\u0432\u0456\u043c\u043a\u043d\u0435\u043d\u043e" + }, + "battery": { + "off": "\u041d\u043e\u0440\u043c\u0430\u043b\u044c\u043d\u0438\u0439", + "on": "\u041d\u0438\u0437\u044c\u043a\u0438\u0439" + }, + "cold": { + "off": "\u041d\u043e\u0440\u043c\u0430", + "on": "\u041e\u0445\u043e\u043b\u043e\u0434\u0436\u0435\u043d\u043d\u044f" + }, + "connectivity": { + "off": "\u0412\u0456\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u043e", + "on": "\u041f\u0456\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u043e" + }, + "door": { + "off": "\u0417\u0430\u0447\u0438\u043d\u0435\u043d\u0456", + "on": "\u0412\u0456\u0434\u0447\u0438\u043d\u0435\u043d\u0456" + }, + "garage_door": { + "off": "\u0417\u0430\u0447\u0438\u043d\u0435\u043d\u0406", + "on": "\u0412\u0456\u0434\u043a\u0440\u0438\u0442\u0456" + }, + "gas": { + "off": "\u0427\u0438\u0441\u0442\u043e", + "on": "\u0412\u0438\u044f\u0432\u043b\u0435\u043d\u043e \u0433\u0430\u0437" + }, + "heat": { + "off": "\u041d\u043e\u0440\u043c\u0430", + "on": "\u041d\u0430\u0433\u0440\u0456\u0432\u0430\u043d\u043d\u044f" + }, + "lock": { + "off": "\u0417\u0430\u0431\u043b\u043e\u043a\u043e\u0432\u0430\u043d\u043e", + "on": "\u0420\u043e\u0437\u0431\u043b\u043e\u043a\u043e\u0432\u0430\u043d\u043e" + }, + "moisture": { + "off": "\u0421\u0443\u0445\u043e", + "on": "\u0412\u043e\u043b\u043e\u0433\u043e" + }, + "motion": { + "off": "\u041d\u0435\u043c\u0430\u0454 \u0440\u0443\u0445\u0443", + "on": "\u0412\u0438\u044f\u0432\u043b\u0435\u043d\u043e \u0440\u0443\u0445" + }, + "occupancy": { + "off": "\u0427\u0438\u0441\u0442\u043e", + "on": "\u0412\u0438\u044f\u0432\u043b\u0435\u043d\u043e \u043f\u0440\u0438\u0441\u0443\u0442\u043d\u0456\u0441\u0442\u044c" + }, + "opening": { + "off": "\u0417\u0430\u043a\u0440\u0438\u0442\u043e", + "on": "\u0412\u0456\u0434\u043a\u0440\u0438\u0442\u0438\u0439" + }, + "presence": { + "off": "\u041d\u0435 \u0432\u0434\u043e\u043c\u0430", + "on": "\u0412\u0434\u043e\u043c\u0430" + }, + "problem": { + "off": "\u041e\u041a", + "on": "\u041f\u0440\u043e\u0431\u043b\u0435\u043c\u0430" + }, + "safety": { + "off": "\u0411\u0435\u0437\u043f\u0435\u0447\u043d\u043e", + "on": "\u041d\u0435\u0431\u0435\u0437\u043f\u0435\u0447\u043d\u043e" + }, + "smoke": { + "off": "\u0427\u0438\u0441\u0442\u043e", + "on": "\u0412\u0438\u044f\u0432\u043b\u0435\u043d\u043e \u0434\u0438\u043c" + }, + "sound": { + "off": "\u0427\u0438\u0441\u0442\u043e", + "on": "\u0412\u0438\u044f\u0432\u043b\u0435\u043d\u043e \u0437\u0432\u0443\u043a" + }, + "vibration": { + "off": "\u041d\u0435 \u0432\u0438\u044f\u0432\u043b\u0435\u043d\u043e", + "on": "\u0412\u0438\u044f\u0432\u043b\u0435\u043d\u0430 \u0432\u0456\u0431\u0440\u0430\u0446\u0456\u044f" + }, + "window": { + "off": "\u0417\u0430\u0447\u0438\u043d\u0435\u043d\u0435", + "on": "\u0412\u0456\u0434\u0447\u0438\u043d\u0435\u043d\u0435" + } + }, + "title": "\u0411\u0456\u043d\u0430\u0440\u043d\u0438\u0439 \u0434\u0430\u0442\u0447\u0438\u043a" +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/translations/vi.json b/homeassistant/components/binary_sensor/translations/vi.json new file mode 100644 index 0000000000000..d74bda4673066 --- /dev/null +++ b/homeassistant/components/binary_sensor/translations/vi.json @@ -0,0 +1,85 @@ +{ + "state": { + "_": { + "off": "T\u1eaft", + "on": "B\u1eadt" + }, + "battery": { + "off": "B\u00ecnh th\u01b0\u1eddng", + "on": "Th\u1ea5p" + }, + "cold": { + "off": "B\u00ecnh th\u01b0\u1eddng", + "on": "L\u1ea1nh" + }, + "connectivity": { + "off": "\u0110\u00e3 ng\u1eaft k\u1ebft n\u1ed1i", + "on": "\u0110\u00e3 k\u1ebft n\u1ed1i" + }, + "door": { + "off": "\u0110\u00f3ng", + "on": "M\u1edf" + }, + "garage_door": { + "off": "\u0110\u00f3ng", + "on": "M\u1edf" + }, + "gas": { + "off": "Tr\u00f4\u0341ng tra\u0309i", + "on": "Ph\u00e1t hi\u1ec7n" + }, + "heat": { + "off": "B\u00ecnh th\u01b0\u1eddng", + "on": "N\u00f3ng" + }, + "lock": { + "off": "\u0110\u00e3 kho\u00e1", + "on": "M\u1edf kho\u00e1" + }, + "moisture": { + "off": "Kh\u00f4", + "on": "\u01af\u1edbt" + }, + "motion": { + "off": "Tr\u00f4\u0341ng tra\u0309i", + "on": "Ph\u00e1t hi\u1ec7n" + }, + "occupancy": { + "off": "Tr\u00f4\u0341ng tra\u0309i", + "on": "Ph\u00e1t hi\u1ec7n" + }, + "opening": { + "off": "\u0110\u00e3 \u0111\u00f3ng", + "on": "M\u1edf" + }, + "presence": { + "off": "\u0110i v\u1eafng", + "on": "\u1ede nh\u00e0" + }, + "problem": { + "off": "OK", + "on": "C\u00f3 v\u1ea5n \u0111\u1ec1" + }, + "safety": { + "off": "An to\u00e0n", + "on": "Kh\u00f4ng an to\u00e0n" + }, + "smoke": { + "off": "Tr\u00f4\u0341ng tra\u0309i", + "on": "Ph\u00e1t hi\u1ec7n" + }, + "sound": { + "off": "Tr\u00f4\u0341ng tra\u0309i", + "on": "Ph\u00e1t hi\u1ec7n" + }, + "vibration": { + "off": "Tr\u00f4\u0341ng tra\u0309i", + "on": "Ph\u00e1t hi\u1ec7n" + }, + "window": { + "off": "\u0110\u00f3ng", + "on": "M\u1edf" + } + }, + "title": "C\u1ea3m bi\u1ebfn nh\u1ecb ph\u00e2n" +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/translations/zh-Hans.json b/homeassistant/components/binary_sensor/translations/zh-Hans.json new file mode 100644 index 0000000000000..d2edb26163fe9 --- /dev/null +++ b/homeassistant/components/binary_sensor/translations/zh-Hans.json @@ -0,0 +1,137 @@ +{ + "device_automation": { + "condition_type": { + "is_bat_low": "{entity_name} \u7535\u6c60\u7535\u91cf\u4f4e", + "is_cold": "{entity_name} \u8fc7\u51b7", + "is_connected": "{entity_name} \u5df2\u8fde\u63a5", + "is_gas": "{entity_name} \u68c0\u6d4b\u5230\u71c3\u6c14\u6cc4\u6f0f", + "is_hot": "{entity_name} \u8fc7\u70ed", + "is_light": "{entity_name} \u68c0\u6d4b\u5230\u5149\u7ebf", + "is_locked": "{entity_name} \u5df2\u9501\u5b9a", + "is_moist": "{entity_name} \u6f6e\u6e7f", + "is_motion": "{entity_name} \u68c0\u6d4b\u5230\u6709\u4eba", + "is_moving": "{entity_name} \u6b63\u5728\u79fb\u52a8", + "is_no_gas": "{entity_name} \u672a\u68c0\u6d4b\u5230\u71c3\u6c14\u6cc4\u6f0f", + "is_no_light": "{entity_name} \u672a\u68c0\u6d4b\u5230\u5149\u7ebf", + "is_no_motion": "{entity_name} \u672a\u68c0\u6d4b\u5230\u6709\u4eba", + "is_no_problem": "{entity_name} \u672a\u53d1\u73b0\u95ee\u9898", + "is_no_smoke": "{entity_name} \u672a\u68c0\u6d4b\u5230\u70df\u96fe", + "is_no_sound": "{entity_name} \u672a\u68c0\u6d4b\u5230\u58f0\u97f3", + "is_no_vibration": "{entity_name} \u672a\u68c0\u6d4b\u5230\u632f\u52a8", + "is_not_bat_low": "{entity_name} \u7535\u6c60\u7535\u91cf\u6b63\u5e38", + "is_not_cold": "{entity_name} \u4e0d\u51b7", + "is_not_connected": "{entity_name} \u5df2\u65ad\u5f00", + "is_not_hot": "{entity_name} \u4e0d\u70ed", + "is_not_locked": "{entity_name} \u5df2\u89e3\u9501", + "is_not_moist": "{entity_name} \u5e72\u71e5", + "is_not_moving": "{entity_name} \u9759\u6b62", + "is_not_open": "{entity_name} \u5df2\u5173\u95ed", + "is_not_plugged_in": "{entity_name} \u672a\u63d2\u5165", + "is_not_powered": "{entity_name} \u672a\u901a\u7535", + "is_not_present": "{entity_name} \u4e0d\u5728\u5bb6", + "is_not_unsafe": "{entity_name} \u5b89\u5168", + "is_off": "{entity_name} \u5df2\u5173\u95ed", + "is_on": "{entity_name} \u5df2\u5f00\u542f", + "is_open": "{entity_name} \u5df2\u6253\u5f00", + "is_plugged_in": "{entity_name} \u5df2\u63d2\u5165", + "is_powered": "{entity_name} \u5df2\u901a\u7535", + "is_present": "{entity_name} \u5728\u5bb6", + "is_problem": "{entity_name} \u53d1\u73b0\u95ee\u9898", + "is_smoke": "{entity_name} \u68c0\u6d4b\u5230\u70df\u96fe", + "is_sound": "{entity_name} \u68c0\u6d4b\u5230\u58f0\u97f3", + "is_unsafe": "{entity_name} \u4e0d\u5b89\u5168", + "is_vibration": "{entity_name} \u68c0\u6d4b\u5230\u632f\u52a8" + }, + "trigger_type": { + "bat_low": "{entity_name} \u7535\u6c60\u7535\u91cf\u4f4e", + "cold": "{entity_name} \u53d8\u51b7", + "connected": "{entity_name} \u5df2\u8fde\u63a5", + "gas": "{entity_name} \u5f00\u59cb\u68c0\u6d4b\u5230\u71c3\u6c14\u6cc4\u6f0f", + "hot": "{entity_name} \u53d8\u70ed", + "light": "{entity_name} \u5f00\u59cb\u68c0\u6d4b\u5230\u5149\u7ebf" + } + }, + "state": { + "_": { + "off": "\u5173\u95ed", + "on": "\u5f00\u542f" + }, + "battery": { + "off": "\u6b63\u5e38", + "on": "\u4f4e" + }, + "cold": { + "off": "\u6b63\u5e38", + "on": "\u8fc7\u51b7" + }, + "connectivity": { + "off": "\u5df2\u65ad\u5f00", + "on": "\u5df2\u8fde\u63a5" + }, + "door": { + "off": "\u5173\u95ed", + "on": "\u5f00\u542f" + }, + "garage_door": { + "off": "\u5173\u95ed", + "on": "\u5f00\u542f" + }, + "gas": { + "off": "\u6b63\u5e38", + "on": "\u89e6\u53d1" + }, + "heat": { + "off": "\u6b63\u5e38", + "on": "\u8fc7\u70ed" + }, + "lock": { + "off": "\u4e0a\u9501", + "on": "\u89e3\u9501" + }, + "moisture": { + "off": "\u5e72\u71e5", + "on": "\u6e7f\u6da6" + }, + "motion": { + "off": "\u672a\u89e6\u53d1", + "on": "\u89e6\u53d1" + }, + "occupancy": { + "off": "\u672a\u89e6\u53d1", + "on": "\u5df2\u89e6\u53d1" + }, + "opening": { + "off": "\u5173\u95ed", + "on": "\u5f00\u542f" + }, + "presence": { + "off": "\u79bb\u5f00", + "on": "\u5728\u5bb6" + }, + "problem": { + "off": "\u6b63\u5e38", + "on": "\u5f02\u5e38" + }, + "safety": { + "off": "\u5b89\u5168", + "on": "\u5371\u9669" + }, + "smoke": { + "off": "\u6b63\u5e38", + "on": "\u89e6\u53d1" + }, + "sound": { + "off": "\u6b63\u5e38", + "on": "\u89e6\u53d1" + }, + "vibration": { + "off": "\u6b63\u5e38", + "on": "\u89e6\u53d1" + }, + "window": { + "off": "\u5173\u95ed", + "on": "\u5f00\u542f" + } + }, + "title": "\u4e8c\u5143\u4f20\u611f\u5668" +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/translations/zh-Hant.json b/homeassistant/components/binary_sensor/translations/zh-Hant.json new file mode 100644 index 0000000000000..a78ecdcf8ef32 --- /dev/null +++ b/homeassistant/components/binary_sensor/translations/zh-Hant.json @@ -0,0 +1,175 @@ +{ + "device_automation": { + "condition_type": { + "is_bat_low": "{entity_name}\u96fb\u91cf\u904e\u4f4e", + "is_cold": "{entity_name}\u51b7", + "is_connected": "{entity_name}\u5df2\u9023\u7dda", + "is_gas": "{entity_name}\u5075\u6e2c\u5230\u6c23\u9ad4", + "is_hot": "{entity_name}\u71b1", + "is_light": "{entity_name}\u5075\u6e2c\u5230\u5149\u7dda\u4e2d", + "is_locked": "{entity_name}\u5df2\u4e0a\u9396", + "is_moist": "{entity_name}\u6f6e\u6fd5", + "is_motion": "{entity_name}\u5075\u6e2c\u5230\u52d5\u4f5c\u4e2d", + "is_moving": "{entity_name}\u79fb\u52d5\u4e2d", + "is_no_gas": "{entity_name}\u672a\u5075\u6e2c\u5230\u6c23\u9ad4", + "is_no_light": "{entity_name}\u672a\u5075\u6e2c\u5230\u5149\u7dda", + "is_no_motion": "{entity_name}\u672a\u5075\u6e2c\u5230\u52d5\u4f5c", + "is_no_problem": "{entity_name}\u672a\u5075\u6e2c\u5230\u554f\u984c", + "is_no_smoke": "{entity_name}\u672a\u5075\u6e2c\u5230\u7159\u9727", + "is_no_sound": "{entity_name}\u672a\u5075\u6e2c\u5230\u8072\u97f3", + "is_no_vibration": "{entity_name}\u672a\u5075\u6e2c\u5230\u9707\u52d5", + "is_not_bat_low": "{entity_name}\u96fb\u91cf\u6b63\u5e38", + "is_not_cold": "{entity_name}\u4e0d\u51b7", + "is_not_connected": "{entity_name}\u65b7\u7dda", + "is_not_hot": "{entity_name}\u4e0d\u71b1", + "is_not_locked": "{entity_name}\u89e3\u9396", + "is_not_moist": "{entity_name}\u4e7e\u71e5", + "is_not_moving": "{entity_name}\u672a\u5728\u79fb\u52d5", + "is_not_occupied": "{entity_name}\u672a\u6709\u4eba", + "is_not_open": "{entity_name}\u95dc\u9589", + "is_not_plugged_in": "{entity_name}\u672a\u63d2\u5165", + "is_not_powered": "{entity_name}\u672a\u901a\u96fb", + "is_not_present": "{entity_name}\u672a\u51fa\u73fe", + "is_not_unsafe": "{entity_name}\u5b89\u5168", + "is_occupied": "{entity_name}\u6709\u4eba", + "is_off": "{entity_name}\u95dc\u9589", + "is_on": "{entity_name}\u958b\u555f", + "is_open": "{entity_name}\u958b\u555f", + "is_plugged_in": "{entity_name}\u63d2\u5165", + "is_powered": "{entity_name}\u901a\u96fb", + "is_present": "{entity_name}\u51fa\u73fe", + "is_problem": "{entity_name}\u6b63\u5075\u6e2c\u5230\u554f\u984c", + "is_smoke": "{entity_name}\u6b63\u5075\u6e2c\u5230\u7159\u9727", + "is_sound": "{entity_name}\u6b63\u5075\u6e2c\u5230\u8072\u97f3", + "is_unsafe": "{entity_name}\u4e0d\u5b89\u5168", + "is_vibration": "{entity_name}\u6b63\u5075\u6e2c\u5230\u9707\u52d5" + }, + "trigger_type": { + "bat_low": "{entity_name}\u96fb\u91cf\u4f4e", + "cold": "{entity_name}\u5df2\u8b8a\u51b7", + "connected": "{entity_name}\u5df2\u9023\u7dda", + "gas": "{entity_name}\u5df2\u958b\u59cb\u5075\u6e2c\u6c23\u9ad4", + "hot": "{entity_name}\u5df2\u8b8a\u71b1", + "light": "{entity_name}\u5df2\u958b\u59cb\u5075\u6e2c\u5149\u7dda", + "locked": "{entity_name}\u5df2\u4e0a\u9396", + "moist": "{entity_name}\u5df2\u8b8a\u6f6e\u6fd5", + "motion": "{entity_name}\u5df2\u5075\u6e2c\u5230\u52d5\u4f5c", + "moving": "{entity_name}\u958b\u59cb\u79fb\u52d5", + "no_gas": "{entity_name}\u5df2\u505c\u6b62\u5075\u6e2c\u6c23\u9ad4", + "no_light": "{entity_name}\u5df2\u505c\u6b62\u5075\u6e2c\u5149\u7dda", + "no_motion": "{entity_name}\u5df2\u505c\u6b62\u5075\u6e2c\u52d5\u4f5c", + "no_problem": "{entity_name}\u5df2\u505c\u6b62\u5075\u6e2c\u554f\u984c", + "no_smoke": "{entity_name}\u5df2\u505c\u6b62\u5075\u6e2c\u7159\u9727", + "no_sound": "{entity_name}\u5df2\u505c\u6b62\u5075\u6e2c\u8072\u97f3", + "no_vibration": "{entity_name}\u5df2\u505c\u6b62\u5075\u6e2c\u9707\u52d5", + "not_bat_low": "{entity_name}\u96fb\u91cf\u6b63\u5e38", + "not_cold": "{entity_name}\u5df2\u4e0d\u51b7", + "not_connected": "{entity_name}\u5df2\u65b7\u7dda", + "not_hot": "{entity_name}\u5df2\u4e0d\u71b1", + "not_locked": "{entity_name}\u5df2\u89e3\u9396", + "not_moist": "{entity_name}\u5df2\u8b8a\u4e7e", + "not_moving": "{entity_name}\u505c\u6b62\u79fb\u52d5", + "not_occupied": "{entity_name}\u672a\u6709\u4eba", + "not_opened": "{entity_name}\u5df2\u95dc\u9589", + "not_plugged_in": "{entity_name}\u672a\u63d2\u5165", + "not_powered": "{entity_name}\u672a\u901a\u96fb", + "not_present": "{entity_name}\u672a\u51fa\u73fe", + "not_unsafe": "{entity_name}\u5df2\u5b89\u5168", + "occupied": "{entity_name}\u8b8a\u6210\u6709\u4eba", + "opened": "{entity_name}\u5df2\u958b\u555f", + "plugged_in": "{entity_name}\u5df2\u63d2\u5165", + "powered": "{entity_name}\u5df2\u901a\u96fb", + "present": "{entity_name}\u5df2\u51fa\u73fe", + "problem": "{entity_name}\u5df2\u5075\u6e2c\u5230\u554f\u984c", + "smoke": "{entity_name}\u5df2\u5075\u6e2c\u5230\u7159\u9727", + "sound": "{entity_name}\u5df2\u5075\u6e2c\u5230\u8072\u97f3", + "turned_off": "{entity_name}\u5df2\u95dc\u9589", + "turned_on": "{entity_name}\u5df2\u958b\u555f", + "unsafe": "{entity_name}\u5df2\u4e0d\u5b89\u5168", + "vibration": "{entity_name}\u5df2\u5075\u6e2c\u5230\u9707\u52d5" + } + }, + "state": { + "_": { + "off": "\u95dc\u9589", + "on": "\u958b\u555f" + }, + "battery": { + "off": "\u96fb\u91cf\u6b63\u5e38", + "on": "\u96fb\u91cf\u4f4e" + }, + "cold": { + "off": "\u6b63\u5e38", + "on": "\u51b7" + }, + "connectivity": { + "off": "\u5df2\u65b7\u7dda", + "on": "\u5df2\u9023\u7dda" + }, + "door": { + "off": "\u5df2\u95dc\u9589", + "on": "\u5df2\u958b\u555f" + }, + "garage_door": { + "off": "\u95dc\u9589", + "on": "\u5df2\u958b\u555f" + }, + "gas": { + "off": "\u672a\u89f8\u767c", + "on": "\u5df2\u89f8\u767c" + }, + "heat": { + "off": "\u6b63\u5e38", + "on": "\u71b1" + }, + "lock": { + "off": "\u5df2\u4e0a\u9396", + "on": "\u5df2\u89e3\u9396" + }, + "moisture": { + "off": "\u4e7e\u71e5", + "on": "\u6fd5\u6f64" + }, + "motion": { + "off": "\u7121\u4eba", + "on": "\u6709\u4eba" + }, + "occupancy": { + "off": "\u672a\u89f8\u767c", + "on": "\u5df2\u89f8\u767c" + }, + "opening": { + "off": "\u95dc\u9589", + "on": "\u958b\u555f" + }, + "presence": { + "off": "\u96e2\u5bb6", + "on": "\u5728\u5bb6" + }, + "problem": { + "off": "\u78ba\u5b9a", + "on": "\u7570\u5e38" + }, + "safety": { + "off": "\u5b89\u5168", + "on": "\u5371\u96aa" + }, + "smoke": { + "off": "\u672a\u89f8\u767c", + "on": "\u5df2\u89f8\u767c" + }, + "sound": { + "off": "\u672a\u89f8\u767c", + "on": "\u5df2\u89f8\u767c" + }, + "vibration": { + "off": "\u672a\u5075\u6e2c", + "on": "\u5075\u6e2c" + }, + "window": { + "off": "\u95dc\u9589", + "on": "\u958b\u555f" + } + }, + "title": "\u4e8c\u9032\u4f4d\u50b3\u611f\u5668" +} \ No newline at end of file diff --git a/homeassistant/components/bitcoin/manifest.json b/homeassistant/components/bitcoin/manifest.json index 85da99a68850c..e198813dbee78 100644 --- a/homeassistant/components/bitcoin/manifest.json +++ b/homeassistant/components/bitcoin/manifest.json @@ -1,12 +1,7 @@ { "domain": "bitcoin", "name": "Bitcoin", - "documentation": "https://www.home-assistant.io/components/bitcoin", - "requirements": [ - "blockchain==1.4.4" - ], - "dependencies": [], - "codeowners": [ - "@fabaff" - ] + "documentation": "https://www.home-assistant.io/integrations/bitcoin", + "requirements": ["blockchain==1.4.4"], + "codeowners": ["@fabaff"] } diff --git a/homeassistant/components/bitcoin/sensor.py b/homeassistant/components/bitcoin/sensor.py index 6ccb10f50e611..c748b2f72f94e 100644 --- a/homeassistant/components/bitcoin/sensor.py +++ b/homeassistant/components/bitcoin/sensor.py @@ -1,61 +1,69 @@ -"""Bitcoin information service that uses blockchain.info.""" -import logging +"""Bitcoin information service that uses blockchain.com.""" from datetime import timedelta +import logging +from blockchain import exchangerates, statistics import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_DISPLAY_OPTIONS, ATTR_ATTRIBUTION, CONF_CURRENCY) + ATTR_ATTRIBUTION, + CONF_CURRENCY, + CONF_DISPLAY_OPTIONS, + TIME_MINUTES, + TIME_SECONDS, +) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) -ATTRIBUTION = "Data provided by blockchain.info" +ATTRIBUTION = "Data provided by blockchain.com" -DEFAULT_CURRENCY = 'USD' +DEFAULT_CURRENCY = "USD" -ICON = 'mdi:currency-btc' +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'] + "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", TIME_MINUTES], + "number_of_transactions": ["No. of Transactions", None], + "hash_rate": ["Hash rate", f"PH/{TIME_SECONDS}"], + "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, -}) +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) + currency = config[CONF_CURRENCY] if currency not in exchangerates.get_ticker(): _LOGGER.warning("Currency %s is not available. Using USD", currency) @@ -104,9 +112,7 @@ def icon(self): @property def device_state_attributes(self): """Return the state attributes of the sensor.""" - return { - ATTR_ATTRIBUTION: ATTRIBUTION, - } + return {ATTR_ATTRIBUTION: ATTRIBUTION} def update(self): """Get the latest data and updates the states.""" @@ -114,52 +120,49 @@ def update(self): stats = self.data.stats ticker = self.data.ticker - if self.type == 'exchangerate': + 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': + elif self.type == "trade_volume_btc": + self._state = f"{stats.trade_volume_btc:.1f}" + elif self.type == "miners_revenue_usd": + self._state = f"{stats.miners_revenue_usd:.0f}" + elif self.type == "btc_mined": + self._state = str(stats.btc_mined * 0.00000001) + elif self.type == "trade_volume_usd": + self._state = f"{stats.trade_volume_usd:.1f}" + elif self.type == "difficulty": + self._state = f"{stats.difficulty:.0f}" + elif self.type == "minutes_between_blocks": + self._state = f"{stats.minutes_between_blocks:.2f}" + elif self.type == "number_of_transactions": + self._state = str(stats.number_of_transactions) + elif self.type == "hash_rate": + self._state = f"{stats.hash_rate * 0.000001:.1f}" + 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) + elif self.type == "mined_blocks": + self._state = str(stats.mined_blocks) + elif self.type == "blocks_size": + self._state = f"{stats.blocks_size:.1f}" + elif self.type == "total_fees_btc": + self._state = f"{stats.total_fees_btc * 0.00000001:.2f}" + elif self.type == "total_btc_sent": + self._state = f"{stats.total_btc_sent * 0.00000001:.2f}" + elif self.type == "estimated_btc_sent": + self._state = f"{stats.estimated_btc_sent * 0.00000001:.2f}" + elif self.type == "total_btc": + self._state = f"{stats.total_btc * 0.00000001:.2f}" + elif self.type == "total_blocks": + self._state = f"{stats.total_blocks:.0f}" + elif self.type == "next_retarget": + self._state = f"{stats.next_retarget:.2f}" + elif self.type == "estimated_transaction_volume_usd": + self._state = f"{stats.estimated_transaction_volume_usd:.2f}" + elif self.type == "miners_revenue_btc": + self._state = f"{stats.miners_revenue_btc * 0.00000001:.1f}" + elif self.type == "market_price_usd": + self._state = f"{stats.market_price_usd:.2f}" class BitcoinData: @@ -171,8 +174,7 @@ def __init__(self): self.ticker = None def update(self): - """Get the latest data from blockchain.info.""" - from blockchain import statistics, exchangerates + """Get the latest data from blockchain.com.""" self.stats = statistics.get() self.ticker = exchangerates.get_ticker() diff --git a/homeassistant/components/bizkaibus/manifest.json b/homeassistant/components/bizkaibus/manifest.json index 98cbbc9be5629..d403d96ce6fbc 100644 --- a/homeassistant/components/bizkaibus/manifest.json +++ b/homeassistant/components/bizkaibus/manifest.json @@ -1,8 +1,7 @@ { "domain": "bizkaibus", "name": "Bizkaibus", - "documentation": "https://www.home-assistant.io/components/bizkaibus", - "dependencies": [], + "documentation": "https://www.home-assistant.io/integrations/bizkaibus", "codeowners": ["@UgaitzEtxebarria"], "requirements": ["bizkaibus==0.1.1"] } diff --git a/homeassistant/components/bizkaibus/sensor.py b/homeassistant/components/bizkaibus/sensor.py old mode 100755 new mode 100644 index 96e6ee5d56f7d..0b8e2682f30b4 --- a/homeassistant/components/bizkaibus/sensor.py +++ b/homeassistant/components/bizkaibus/sensor.py @@ -2,34 +2,35 @@ import logging -import voluptuous as vol from bizkaibus.bizkaibus import BizkaibusData -import homeassistant.helpers.config_validation as cv +import voluptuous as vol -from homeassistant.const import CONF_NAME from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_NAME, TIME_MINUTES +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity - _LOGGER = logging.getLogger(__name__) -ATTR_DUE_IN = 'Due in' +ATTR_DUE_IN = "Due in" -CONF_STOP_ID = 'stopid' -CONF_ROUTE = 'route' +CONF_STOP_ID = "stopid" +CONF_ROUTE = "route" -DEFAULT_NAME = 'Next bus' +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, -}) +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) + name = config[CONF_NAME] stop = config[CONF_STOP_ID] route = config[CONF_ROUTE] @@ -61,7 +62,7 @@ def state(self): @property def unit_of_measurement(self): """Return the unit of measurement of the sensor.""" - return 'minutes' + return TIME_MINUTES def update(self): """Get the latest data from the webservice.""" diff --git a/homeassistant/components/blackbird/const.py b/homeassistant/components/blackbird/const.py new file mode 100644 index 0000000000000..aa8d7e7d514e8 --- /dev/null +++ b/homeassistant/components/blackbird/const.py @@ -0,0 +1,3 @@ +"""Constants for the Monoprice Blackbird Matrix Switch component.""" +DOMAIN = "blackbird" +SERVICE_SETALLZONES = "set_all_zones" diff --git a/homeassistant/components/blackbird/manifest.json b/homeassistant/components/blackbird/manifest.json index 9e3e41290ea37..f094109ba8492 100644 --- a/homeassistant/components/blackbird/manifest.json +++ b/homeassistant/components/blackbird/manifest.json @@ -1,10 +1,7 @@ { "domain": "blackbird", - "name": "Blackbird", - "documentation": "https://www.home-assistant.io/components/blackbird", - "requirements": [ - "pyblackbird==0.5" - ], - "dependencies": [], + "name": "Monoprice Blackbird Matrix Switch", + "documentation": "https://www.home-assistant.io/integrations/blackbird", + "requirements": ["pyblackbird==0.5"], "codeowners": [] } diff --git a/homeassistant/components/blackbird/media_player.py b/homeassistant/components/blackbird/media_player.py index be0538a89e94d..9ae696a5276be 100644 --- a/homeassistant/components/blackbird/media_player.py +++ b/homeassistant/components/blackbird/media_player.py @@ -2,41 +2,49 @@ import logging import socket +from pyblackbird import get_blackbird +from serial import SerialException import voluptuous as vol -from homeassistant.components.media_player import ( - MediaPlayerDevice, MEDIA_PLAYER_SCHEMA, PLATFORM_SCHEMA) +from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerEntity from homeassistant.components.media_player.const import ( - DOMAIN, SUPPORT_SELECT_SOURCE, - SUPPORT_TURN_OFF, SUPPORT_TURN_ON) + 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) + ATTR_ENTITY_ID, + CONF_HOST, + CONF_NAME, + CONF_PORT, + CONF_TYPE, + STATE_OFF, + STATE_ON, +) import homeassistant.helpers.config_validation as cv +from .const import DOMAIN, SERVICE_SETALLZONES + _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, -}) +MEDIA_PLAYER_SCHEMA = vol.Schema({ATTR_ENTITY_ID: cv.comp_entity_ids}) + +ZONE_SCHEMA = vol.Schema({vol.Required(CONF_NAME): cv.string}) -SOURCE_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' +CONF_ZONES = "zones" +CONF_SOURCES = "sources" -DATA_BLACKBIRD = 'blackbird' +DATA_BLACKBIRD = "blackbird" -SERVICE_SETALLZONES = 'blackbird_set_all_zones' -ATTR_SOURCE = 'source' +ATTR_SOURCE = "source" -BLACKBIRD_SETALLZONES_SCHEMA = MEDIA_PLAYER_SCHEMA.extend({ - vol.Required(ATTR_SOURCE): cv.string -}) +BLACKBIRD_SETALLZONES_SCHEMA = MEDIA_PLAYER_SCHEMA.extend( + {vol.Required(ATTR_SOURCE): cv.string} +) # Valid zone ids: 1-8 @@ -47,12 +55,15 @@ 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}), - })) + 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): @@ -63,9 +74,6 @@ def setup_platform(hass, config, add_entities, discovery_info=None): 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: @@ -83,13 +91,14 @@ def setup_platform(hass, config, add_entities, discovery_info=None): _LOGGER.error("Error connecting to the Blackbird controller") return - sources = {source_id: extra[CONF_NAME] for source_id, extra - in config[CONF_SOURCES].items()} + 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) + unique_id = f"{connection}-{zone_id}" device = BlackbirdZone(blackbird, sources, zone_id, extra[CONF_NAME]) hass.data[DATA_BLACKBIRD][unique_id] = device devices.append(device) @@ -101,8 +110,11 @@ def service_handle(service): 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] + devices = [ + device + for device in hass.data[DATA_BLACKBIRD].values() + if device.entity_id in entity_ids + ] else: devices = hass.data[DATA_BLACKBIRD].values() @@ -111,11 +123,12 @@ def service_handle(service): if service.service == SERVICE_SETALLZONES: device.set_all_zones(source) - hass.services.register(DOMAIN, SERVICE_SETALLZONES, service_handle, - schema=BLACKBIRD_SETALLZONES_SCHEMA) + hass.services.register( + DOMAIN, SERVICE_SETALLZONES, service_handle, schema=BLACKBIRD_SETALLZONES_SCHEMA + ) -class BlackbirdZone(MediaPlayerDevice): +class BlackbirdZone(MediaPlayerEntity): """Representation of a Blackbird matrix zone.""" def __init__(self, blackbird, sources, zone_id, zone_name): @@ -126,8 +139,9 @@ def __init__(self, blackbird, sources, zone_id, zone_name): # 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._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 diff --git a/homeassistant/components/blackbird/services.yaml b/homeassistant/components/blackbird/services.yaml index e69de29bb2d1d..a783dff241bf5 100644 --- a/homeassistant/components/blackbird/services.yaml +++ b/homeassistant/components/blackbird/services.yaml @@ -0,0 +1,9 @@ +set_all_zones: + description: Set all Blackbird zones to a single source. + fields: + entity_id: + description: Name of any blackbird zone. + example: "media_player.zone_1" + source: + description: Name of source to switch to. + example: "Source 1" diff --git a/homeassistant/components/blink/__init__.py b/homeassistant/components/blink/__init__.py index 397ee097cae24..e233a8b21d819 100644 --- a/homeassistant/components/blink/__init__.py +++ b/homeassistant/components/blink/__init__.py @@ -1,102 +1,125 @@ """Support for Blink Home Camera System.""" -import logging from datetime import timedelta +import logging + +from blinkpy import blinkpy 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, TEMP_FAHRENHEIT) + CONF_BINARY_SENSORS, + CONF_FILENAME, + CONF_MODE, + CONF_MONITORED_CONDITIONS, + CONF_NAME, + CONF_OFFSET, + CONF_PASSWORD, + CONF_SCAN_INTERVAL, + CONF_SENSORS, + CONF_USERNAME, + TEMP_FAHRENHEIT, +) +from homeassistant.helpers import config_validation as cv, discovery _LOGGER = logging.getLogger(__name__) -DOMAIN = 'blink' -BLINK_DATA = 'blink' +DOMAIN = "blink" +BLINK_DATA = "blink" -CONF_CAMERA = 'camera' -CONF_ALARM_CONTROL_PANEL = 'alarm_control_panel' +CONF_CAMERA = "camera" +CONF_ALARM_CONTROL_PANEL = "alarm_control_panel" -DEFAULT_BRAND = 'Blink' +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' +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' +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'], + 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'], + 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)]) -}) +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)]) -}) +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_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, -}) +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, - }) + 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) + 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] - hass.data[BLINK_DATA] = blinkpy.Blink(username=username, - password=password) + 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]), + ("alarm_control_panel", {}), + ("binary_sensor", conf[CONF_BINARY_SENSORS]), + ("camera", {}), + ("sensor", conf[CONF_SENSORS]), ] for component, schema in platforms: @@ -119,14 +142,12 @@ async def async_save_video(call): 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) + 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 @@ -135,8 +156,7 @@ async def async_handle_save_video_service(hass, call): 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) + _LOGGER.error("Can't write %s, no access to path!", video_path) return def _write_video(camera_name, video_path): @@ -146,7 +166,6 @@ def _write_video(camera_name, video_path): all_cameras[camera_name].video_to_file(video_path) try: - await hass.async_add_executor_job( - _write_video, camera_name, video_path) + 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 index 8cc89d90b2f88..e6af3780aafa8 100644 --- a/homeassistant/components/blink/alarm_control_panel.py +++ b/homeassistant/components/blink/alarm_control_panel.py @@ -1,15 +1,19 @@ """Support for Blink Alarm Control Panel.""" import logging -from homeassistant.components.alarm_control_panel import AlarmControlPanel +from homeassistant.components.alarm_control_panel import AlarmControlPanelEntity +from homeassistant.components.alarm_control_panel.const import SUPPORT_ALARM_ARM_AWAY from homeassistant.const import ( - ATTR_ATTRIBUTION, STATE_ALARM_ARMED_AWAY, STATE_ALARM_DISARMED) + ATTR_ATTRIBUTION, + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_DISARMED, +) from . import BLINK_DATA, DEFAULT_ATTRIBUTION _LOGGER = logging.getLogger(__name__) -ICON = 'mdi:security' +ICON = "mdi:security" def setup_platform(hass, config, add_entities, discovery_info=None): @@ -24,7 +28,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): add_entities(sync_modules, True) -class BlinkSyncModule(AlarmControlPanel): +class BlinkSyncModule(AlarmControlPanelEntity): """Representation of a Blink Alarm Control Panel.""" def __init__(self, data, name, sync): @@ -49,17 +53,22 @@ def state(self): """Return the state of the device.""" return self._state + @property + def supported_features(self) -> int: + """Return the list of supported features.""" + return SUPPORT_ALARM_ARM_AWAY + @property def name(self): """Return the name of the panel.""" - return "{} {}".format(BLINK_DATA, self._name) + return f"{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["network_info"] = self.data.networks + attr["associated_cameras"] = list(self.sync.cameras.keys()) attr[ATTR_ATTRIBUTION] = DEFAULT_ATTRIBUTION return attr diff --git a/homeassistant/components/blink/binary_sensor.py b/homeassistant/components/blink/binary_sensor.py index 4c268989d32ba..219b9fb8cd36d 100644 --- a/homeassistant/components/blink/binary_sensor.py +++ b/homeassistant/components/blink/binary_sensor.py @@ -1,5 +1,5 @@ """Support for Blink system camera control.""" -from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.const import CONF_MONITORED_CONDITIONS from . import BINARY_SENSORS, BLINK_DATA @@ -18,7 +18,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): add_entities(devs, True) -class BlinkBinarySensor(BinarySensorDevice): +class BlinkBinarySensor(BinarySensorEntity): """Representation of a Blink binary sensor.""" def __init__(self, data, camera, sensor_type): @@ -26,11 +26,11 @@ def __init__(self, data, camera, sensor_type): self.data = data self._type = sensor_type name, icon = BINARY_SENSORS[sensor_type] - self._name = "{} {} {}".format(BLINK_DATA, camera, name) + self._name = f"{BLINK_DATA} {camera} {name}" self._icon = icon self._camera = data.cameras[camera] self._state = None - self._unique_id = "{}-{}".format(self._camera.serial, self._type) + self._unique_id = f"{self._camera.serial}-{self._type}" @property def name(self): diff --git a/homeassistant/components/blink/camera.py b/homeassistant/components/blink/camera.py index d1301319a8126..52043324a40f9 100644 --- a/homeassistant/components/blink/camera.py +++ b/homeassistant/components/blink/camera.py @@ -7,8 +7,8 @@ _LOGGER = logging.getLogger(__name__) -ATTR_VIDEO_CLIP = 'video' -ATTR_IMAGE = 'image' +ATTR_VIDEO_CLIP = "video" +ATTR_IMAGE = "image" def setup_platform(hass, config, add_entities, discovery_info=None): @@ -30,9 +30,9 @@ def __init__(self, data, name, camera): """Initialize a camera.""" super().__init__() self.data = data - self._name = "{} {}".format(BLINK_DATA, name) + self._name = f"{BLINK_DATA} {name}" self._camera = camera - self._unique_id = "{}-camera".format(camera.serial) + self._unique_id = f"{camera.serial}-camera" self.response = None self.current_image = None self.last_image = None diff --git a/homeassistant/components/blink/manifest.json b/homeassistant/components/blink/manifest.json index 7be44f95a5335..d55510c44ad72 100644 --- a/homeassistant/components/blink/manifest.json +++ b/homeassistant/components/blink/manifest.json @@ -1,12 +1,7 @@ { "domain": "blink", "name": "Blink", - "documentation": "https://www.home-assistant.io/components/blink", - "requirements": [ - "blinkpy==0.13.1" - ], - "dependencies": [], - "codeowners": [ - "@fronzbot" - ] + "documentation": "https://www.home-assistant.io/integrations/blink", + "requirements": ["blinkpy==0.14.3"], + "codeowners": ["@fronzbot"] } diff --git a/homeassistant/components/blink/sensor.py b/homeassistant/components/blink/sensor.py index 6fb8be8e4ea71..81616b463ecfb 100644 --- a/homeassistant/components/blink/sensor.py +++ b/homeassistant/components/blink/sensor.py @@ -28,8 +28,7 @@ class BlinkSensor(Entity): 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._name = f"{BLINK_DATA} {camera} {name}" self._camera_name = name self._type = sensor_type self.data = data @@ -37,10 +36,10 @@ def __init__(self, data, camera, sensor_type): self._state = None self._unit_of_measurement = units self._icon = icon - self._unique_id = "{}-{}".format(self._camera.serial, self._type) + self._unique_id = f"{self._camera.serial}-{self._type}" self._sensor_key = self._type - if self._type == 'temperature': - self._sensor_key = 'temperature_calibrated' + if self._type == "temperature": + self._sensor_key = "temperature_calibrated" @property def name(self): @@ -75,5 +74,5 @@ def update(self): except KeyError: self._state = None _LOGGER.error( - "%s not a valid camera attribute. Did the API change?", - self._sensor_key) + "%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 index fc042b0d5986c..37595837c1187 100644 --- a/homeassistant/components/blink/services.yaml +++ b/homeassistant/components/blink/services.yaml @@ -1,21 +1,21 @@ # Describes the format for available Blink services blink_update: - description: Force a refresh. + 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' + 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' + 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/light.py b/homeassistant/components/blinksticklight/light.py index 8eab6afaeb728..56009c90da373 100644 --- a/homeassistant/components/blinksticklight/light.py +++ b/homeassistant/components/blinksticklight/light.py @@ -1,42 +1,49 @@ """Support for Blinkstick lights.""" import logging +from blinkstick import blinkstick import voluptuous as vol from homeassistant.components.light import ( - ATTR_BRIGHTNESS, ATTR_HS_COLOR, SUPPORT_BRIGHTNESS, SUPPORT_COLOR, Light, - PLATFORM_SCHEMA) + ATTR_BRIGHTNESS, + ATTR_HS_COLOR, + PLATFORM_SCHEMA, + SUPPORT_BRIGHTNESS, + SUPPORT_COLOR, + LightEntity, +) 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' +CONF_SERIAL = "serial" -DEFAULT_NAME = 'Blinkstick' +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, -}) +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) + name = config[CONF_NAME] + serial = config[CONF_SERIAL] stick = blinkstick.find_by_serial(serial) add_entities([BlinkStickLight(stick, name)], True) -class BlinkStickLight(Light): +class BlinkStickLight(LightEntity): """Representation of a BlinkStick light.""" def __init__(self, stick, name): @@ -94,9 +101,9 @@ def turn_on(self, **kwargs): 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]) + 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.""" diff --git a/homeassistant/components/blinksticklight/manifest.json b/homeassistant/components/blinksticklight/manifest.json index a5277c97d9938..07726bc8cb08b 100644 --- a/homeassistant/components/blinksticklight/manifest.json +++ b/homeassistant/components/blinksticklight/manifest.json @@ -1,10 +1,7 @@ { "domain": "blinksticklight", - "name": "Blinksticklight", - "documentation": "https://www.home-assistant.io/components/blinksticklight", - "requirements": [ - "blinkstick==1.1.8" - ], - "dependencies": [], + "name": "BlinkStick", + "documentation": "https://www.home-assistant.io/integrations/blinksticklight", + "requirements": ["blinkstick==1.1.8"], "codeowners": [] } diff --git a/homeassistant/components/blinkt/light.py b/homeassistant/components/blinkt/light.py index cb3e854b3888a..768ca92d9d2c4 100644 --- a/homeassistant/components/blinkt/light.py +++ b/homeassistant/components/blinkt/light.py @@ -4,40 +4,45 @@ 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) + ATTR_BRIGHTNESS, + ATTR_HS_COLOR, + PLATFORM_SCHEMA, + SUPPORT_BRIGHTNESS, + SUPPORT_COLOR, + LightEntity, +) from homeassistant.const import CONF_NAME +import homeassistant.helpers.config_validation as cv import homeassistant.util.color as color_util _LOGGER = logging.getLogger(__name__) -SUPPORT_BLINKT = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR) +SUPPORT_BLINKT = SUPPORT_BRIGHTNESS | SUPPORT_COLOR -DEFAULT_NAME = 'blinkt' +DEFAULT_NAME = "blinkt" -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, -}) +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') + blinkt = importlib.import_module("blinkt") # ensure that the lights are off when exiting blinkt.set_clear_on_exit() - name = config.get(CONF_NAME) + name = config[CONF_NAME] - add_entities([ - BlinktLight(blinkt, name, index) for index in range(blinkt.NUM_PIXELS) - ]) + add_entities( + [BlinktLight(blinkt, name, index) for index in range(blinkt.NUM_PIXELS)] + ) -class BlinktLight(Light): +class BlinktLight(LightEntity): """Representation of a Blinkt! Light.""" def __init__(self, blinkt, name, index): @@ -46,7 +51,7 @@ def __init__(self, blinkt, name, index): Default brightness and white color. """ self._blinkt = blinkt - self._name = "{}_{}".format(name, index) + self._name = f"{name}_{index}" self._index = index self._is_on = False self._brightness = 255 @@ -97,13 +102,11 @@ def turn_on(self, **kwargs): if ATTR_BRIGHTNESS in kwargs: self._brightness = kwargs[ATTR_BRIGHTNESS] - percent_bright = (self._brightness / 255) + 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.set_pixel( + self._index, rgb_color[0], rgb_color[1], rgb_color[2], percent_bright + ) self._blinkt.show() diff --git a/homeassistant/components/blinkt/manifest.json b/homeassistant/components/blinkt/manifest.json index c11583ed59ec3..4759a356d9d66 100644 --- a/homeassistant/components/blinkt/manifest.json +++ b/homeassistant/components/blinkt/manifest.json @@ -1,10 +1,7 @@ { "domain": "blinkt", - "name": "Blinkt", - "documentation": "https://www.home-assistant.io/components/blinkt", - "requirements": [ - "blinkt==0.1.0" - ], - "dependencies": [], + "name": "Blinkt!", + "documentation": "https://www.home-assistant.io/integrations/blinkt", + "requirements": ["blinkt==0.1.0"], "codeowners": [] } diff --git a/homeassistant/components/blockchain/manifest.json b/homeassistant/components/blockchain/manifest.json index 8a2a9f7b71f00..f30f7d041a0fc 100644 --- a/homeassistant/components/blockchain/manifest.json +++ b/homeassistant/components/blockchain/manifest.json @@ -1,10 +1,7 @@ { "domain": "blockchain", - "name": "Blockchain", - "documentation": "https://www.home-assistant.io/components/blockchain", - "requirements": [ - "python-blockchain-api==0.0.2" - ], - "dependencies": [], + "name": "Blockchain.com", + "documentation": "https://www.home-assistant.io/integrations/blockchain", + "requirements": ["python-blockchain-api==0.0.2"], "codeowners": [] } diff --git a/homeassistant/components/blockchain/sensor.py b/homeassistant/components/blockchain/sensor.py index 436e2979a6e06..feb9d582cff0f 100644 --- a/homeassistant/components/blockchain/sensor.py +++ b/homeassistant/components/blockchain/sensor.py @@ -1,38 +1,40 @@ -"""Support for Blockchain.info sensors.""" -import logging +"""Support for Blockchain.com sensors.""" from datetime import timedelta +import logging +from pyblockchain import get_balance, validate_address 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.const import ATTR_ATTRIBUTION, CONF_NAME +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) -ATTRIBUTION = "Data provided by blockchain.info" +ATTRIBUTION = "Data provided by blockchain.com" -CONF_ADDRESSES = 'addresses' +CONF_ADDRESSES = "addresses" -DEFAULT_NAME = 'Bitcoin Balance' +DEFAULT_NAME = "Bitcoin Balance" -ICON = 'mdi:currency-btc' +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, -}) +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 + """Set up the Blockchain.com sensors.""" - addresses = config.get(CONF_ADDRESSES) - name = config.get(CONF_NAME) + addresses = config[CONF_ADDRESSES] + name = config[CONF_NAME] for address in addresses: if not validate_address(address): @@ -43,14 +45,14 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class BlockchainSensor(Entity): - """Representation of a Blockchain.info sensor.""" + """Representation of a Blockchain.com sensor.""" def __init__(self, name, addresses): """Initialize the sensor.""" self._name = name self.addresses = addresses self._state = None - self._unit_of_measurement = 'BTC' + self._unit_of_measurement = "BTC" @property def name(self): @@ -75,11 +77,9 @@ def icon(self): @property def device_state_attributes(self): """Return the state attributes of the sensor.""" - return { - ATTR_ATTRIBUTION: ATTRIBUTION, - } + 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/__init__.py b/homeassistant/components/bloomsky/__init__.py index 7f9249296626f..929f8218144ae 100644 --- a/homeassistant/components/bloomsky/__init__.py +++ b/homeassistant/components/bloomsky/__init__.py @@ -6,39 +6,37 @@ import requests import voluptuous as vol -from homeassistant.const import CONF_API_KEY +from homeassistant.const import CONF_API_KEY, HTTP_OK 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'] +BLOOMSKY_TYPE = ["camera", "binary_sensor", "sensor"] -DOMAIN = 'bloomsky' +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) +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) + bloomsky = BloomSky(api_key, hass.config.units.is_metric) except RuntimeError: return False + hass.data[DOMAIN] = bloomsky + for component in BLOOMSKY_TYPE: discovery.load_platform(hass, component, DOMAIN, {}, config) @@ -49,12 +47,14 @@ 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' + API_URL = "http://api.bloomsky.com/api/skydata" - def __init__(self, api_key): + def __init__(self, api_key, is_metric): """Initialize the BookSky.""" self._api_key = api_key + self._endpoint_argument = "unit=intl" if is_metric else "" self.devices = {} + self.is_metric = is_metric _LOGGER.debug("Initial BloomSky device load...") self.refresh_devices() @@ -63,13 +63,17 @@ 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) + f"{self.API_URL}?{self._endpoint_argument}", + headers={AUTHORIZATION: self._api_key}, + timeout=10, + ) if response.status_code == 401: raise RuntimeError("Invalid API_KEY") - if response.status_code != 200: + if response.status_code == 405: + _LOGGER.error("You have no bloomsky devices configured") + return + if response.status_code != HTTP_OK: _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() - }) + 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 index b17c4e4c25781..077171006bf80 100644 --- a/homeassistant/components/bloomsky/binary_sensor.py +++ b/homeassistant/components/bloomsky/binary_sensor.py @@ -3,48 +3,50 @@ import voluptuous as vol -from homeassistant.components.binary_sensor import ( - BinarySensorDevice, PLATFORM_SCHEMA) +from homeassistant.components.binary_sensor import PLATFORM_SCHEMA, BinarySensorEntity from homeassistant.const import CONF_MONITORED_CONDITIONS import homeassistant.helpers.config_validation as cv -from . import BLOOMSKY +from . import DOMAIN _LOGGER = logging.getLogger(__name__) -SENSOR_TYPES = { - 'Rain': 'moisture', - 'Night': None, -} +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)]), -}) +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) + if discovery_info is not None: + return - for device in BLOOMSKY.devices.values(): + sensors = config[CONF_MONITORED_CONDITIONS] + bloomsky = hass.data[DOMAIN] + + for device in bloomsky.devices.values(): for variable in sensors: - add_entities( - [BloomSkySensor(BLOOMSKY, device, variable)], True) + add_entities([BloomSkySensor(bloomsky, device, variable)], True) -class BloomSkySensor(BinarySensorDevice): +class BloomSkySensor(BinarySensorEntity): """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._device_id = device["DeviceID"] self._sensor_name = sensor_name - self._name = '{} {}'.format(device['DeviceName'], sensor_name) + self._name = f"{device['DeviceName']} {sensor_name}" self._state = None - self._unique_id = '{}-{}'.format(self._device_id, self._sensor_name) + self._unique_id = f"{self._device_id}-{self._sensor_name}" @property def unique_id(self): @@ -70,5 +72,4 @@ 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] + 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 index a748ff2b5b890..e14e2f5c68bf1 100644 --- a/homeassistant/components/bloomsky/camera.py +++ b/homeassistant/components/bloomsky/camera.py @@ -5,13 +5,18 @@ from homeassistant.components.camera import Camera -from . import BLOOMSKY +from . import DOMAIN 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)]) + if discovery_info is not None: + return + + bloomsky = hass.data[DOMAIN] + + for device in bloomsky.devices.values(): + add_entities([BloomSkyCamera(bloomsky, device)]) class BloomSkyCamera(Camera): @@ -19,9 +24,9 @@ class BloomSkyCamera(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'] + super().__init__() + self._name = device["DeviceName"] + self._id = device["DeviceID"] self._bloomsky = bs self._url = "" self._last_url = "" @@ -34,7 +39,7 @@ def __init__(self, bs, device): def camera_image(self): """Update the camera's image if it has changed.""" try: - self._url = self._bloomsky.devices[self._id]['Data']['ImageURL'] + 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: diff --git a/homeassistant/components/bloomsky/manifest.json b/homeassistant/components/bloomsky/manifest.json index 3a780507dd59c..8dda93b16b90d 100644 --- a/homeassistant/components/bloomsky/manifest.json +++ b/homeassistant/components/bloomsky/manifest.json @@ -1,8 +1,6 @@ { "domain": "bloomsky", - "name": "Bloomsky", - "documentation": "https://www.home-assistant.io/components/bloomsky", - "requirements": [], - "dependencies": [], + "name": "BloomSky", + "documentation": "https://www.home-assistant.io/integrations/bloomsky", "codeowners": [] } diff --git a/homeassistant/components/bloomsky/sensor.py b/homeassistant/components/bloomsky/sensor.py index e7d4bc5c8eb0e..0a2c19a8cd8a3 100644 --- a/homeassistant/components/bloomsky/sensor.py +++ b/homeassistant/components/bloomsky/sensor.py @@ -4,47 +4,71 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import (TEMP_FAHRENHEIT, CONF_MONITORED_CONDITIONS) -from homeassistant.helpers.entity import Entity +from homeassistant.const import ( + CONF_MONITORED_CONDITIONS, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, + UNIT_PERCENTAGE, +) import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity -from . import BLOOMSKY +from . import DOMAIN LOGGER = logging.getLogger(__name__) # These are the available sensors -SENSOR_TYPES = ['Temperature', - 'Humidity', - 'Pressure', - 'Luminance', - 'UVIndex', - 'Voltage'] +SENSOR_TYPES = [ + "Temperature", + "Humidity", + "Pressure", + "Luminance", + "UVIndex", + "Voltage", +] # Sensor units - these do not currently align with the API documentation -SENSOR_UNITS = {'Temperature': TEMP_FAHRENHEIT, - 'Humidity': '%', - 'Pressure': 'inHg', - 'Luminance': 'cd/m²', - 'Voltage': 'mV'} +SENSOR_UNITS_IMPERIAL = { + "Temperature": TEMP_FAHRENHEIT, + "Humidity": UNIT_PERCENTAGE, + "Pressure": "inHg", + "Luminance": "cd/m²", + "Voltage": "mV", +} + +# Metric units +SENSOR_UNITS_METRIC = { + "Temperature": TEMP_CELSIUS, + "Humidity": UNIT_PERCENTAGE, + "Pressure": "mbar", + "Luminance": "cd/m²", + "Voltage": "mV", +} # Which sensors to format numerically -FORMAT_NUMBERS = ['Temperature', 'Pressure', 'Voltage'] +FORMAT_NUMBERS = ["Temperature", "Pressure", "Voltage"] -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_MONITORED_CONDITIONS, default=SENSOR_TYPES): - vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), -}) +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) + if discovery_info is not None: + return + + sensors = config[CONF_MONITORED_CONDITIONS] + bloomsky = hass.data[DOMAIN] - for device in BLOOMSKY.devices.values(): + for device in bloomsky.devices.values(): for variable in sensors: - add_entities( - [BloomSkySensor(BLOOMSKY, device, variable)], True) + add_entities([BloomSkySensor(bloomsky, device, variable)], True) class BloomSkySensor(Entity): @@ -53,11 +77,11 @@ class BloomSkySensor(Entity): def __init__(self, bs, device, sensor_name): """Initialize a BloomSky sensor.""" self._bloomsky = bs - self._device_id = device['DeviceID'] + self._device_id = device["DeviceID"] self._sensor_name = sensor_name - self._name = '{} {}'.format(device['DeviceName'], sensor_name) + self._name = f"{device['DeviceName']} {sensor_name}" self._state = None - self._unique_id = '{}-{}'.format(self._device_id, self._sensor_name) + self._unique_id = f"{self._device_id}-{self._sensor_name}" @property def unique_id(self): @@ -77,16 +101,17 @@ def state(self): @property def unit_of_measurement(self): """Return the sensor units.""" - return SENSOR_UNITS.get(self._sensor_name, None) + if self._bloomsky.is_metric: + return SENSOR_UNITS_METRIC.get(self._sensor_name, None) + return SENSOR_UNITS_IMPERIAL.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] + state = self._bloomsky.devices[self._device_id]["Data"][self._sensor_name] if self._sensor_name in FORMAT_NUMBERS: - self._state = '{0:.2f}'.format(state) + self._state = f"{state:.2f}" else: self._state = state diff --git a/homeassistant/components/bluesound/const.py b/homeassistant/components/bluesound/const.py new file mode 100644 index 0000000000000..af1a8e5187c2c --- /dev/null +++ b/homeassistant/components/bluesound/const.py @@ -0,0 +1,6 @@ +"""Constants for the Bluesound HiFi wireless speakers and audio integrations component.""" +DOMAIN = "bluesound" +SERVICE_CLEAR_TIMER = "clear_sleep_timer" +SERVICE_JOIN = "join" +SERVICE_SET_TIMER = "set_sleep_timer" +SERVICE_UNJOIN = "unjoin" diff --git a/homeassistant/components/bluesound/manifest.json b/homeassistant/components/bluesound/manifest.json index 7731f845005de..9ea32a9e5df06 100644 --- a/homeassistant/components/bluesound/manifest.json +++ b/homeassistant/components/bluesound/manifest.json @@ -1,10 +1,7 @@ { "domain": "bluesound", "name": "Bluesound", - "documentation": "https://www.home-assistant.io/components/bluesound", - "requirements": [ - "xmltodict==0.12.0" - ], - "dependencies": [], + "documentation": "https://www.home-assistant.io/integrations/bluesound", + "requirements": ["xmltodict==0.12.0"], "codeowners": [] } diff --git a/homeassistant/components/bluesound/media_player.py b/homeassistant/components/bluesound/media_player.py index 080afeea28042..c0088cb4a2a7a 100644 --- a/homeassistant/components/bluesound/media_player.py +++ b/homeassistant/components/bluesound/media_player.py @@ -1,27 +1,49 @@ """Support for Bluesound devices.""" import asyncio -from asyncio.futures import CancelledError +from asyncio import CancelledError from datetime import timedelta import logging +from urllib import parse import aiohttp from aiohttp.client_exceptions import ClientError from aiohttp.hdrs import CONNECTION, KEEP_ALIVE import async_timeout import voluptuous as vol +import xmltodict -from homeassistant.components.media_player import ( - MediaPlayerDevice, PLATFORM_SCHEMA) +from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerEntity 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) + ATTR_MEDIA_ENQUEUE, + MEDIA_TYPE_MUSIC, + SUPPORT_CLEAR_PLAYLIST, + SUPPORT_NEXT_TRACK, + SUPPORT_PAUSE, + SUPPORT_PLAY, + SUPPORT_PLAY_MEDIA, + SUPPORT_PREVIOUS_TRACK, + SUPPORT_SEEK, + SUPPORT_SELECT_SOURCE, + SUPPORT_SHUFFLE_SET, + SUPPORT_STOP, + SUPPORT_VOLUME_MUTE, + SUPPORT_VOLUME_SET, + 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) + ATTR_ENTITY_ID, + CONF_HOST, + CONF_HOSTS, + CONF_NAME, + CONF_PORT, + EVENT_HOMEASSISTANT_START, + EVENT_HOMEASSISTANT_STOP, + HTTP_OK, + 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 @@ -29,56 +51,56 @@ from homeassistant.util import Throttle import homeassistant.util.dt as dt_util +from .const import ( + DOMAIN, + SERVICE_CLEAR_TIMER, + SERVICE_JOIN, + SERVICE_SET_TIMER, + SERVICE_UNJOIN, +) + _LOGGER = logging.getLogger(__name__) -ATTR_MASTER = 'master' +ATTR_BLUESOUND_GROUP = "bluesound_group" +ATTR_MASTER = "master" -DATA_BLUESOUND = 'bluesound' +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' +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, -}) +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} + 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}, } @@ -111,8 +133,7 @@ def _add_player_cb(): if hass.is_running: _start_polling() else: - hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_START, _start_polling) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _start_polling) hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop_polling) @@ -125,23 +146,30 @@ def _add_player_cb(): hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _init_player) -async def async_setup_platform( - hass, config, async_add_entities, discovery_info=None): +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)) + _add_player( + hass, + async_add_entities, + discovery_info.get(CONF_HOST), + discovery_info.get(CONF_PORT), + ) return - hosts = config.get(CONF_HOSTS, None) + hosts = config.get(CONF_HOSTS) if hosts: for host in hosts: _add_player( - hass, async_add_entities, host.get(CONF_HOST), - host.get(CONF_PORT), host.get(CONF_NAME)) + 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.""" @@ -149,25 +177,30 @@ async def async_service_handler(service): if not method: return - params = {key: value for key, value in service.data.items() - if key != ATTR_ENTITY_ID} + 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] + 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) + await getattr(player, method["method"])(**params) for service in SERVICE_TO_METHOD: - schema = SERVICE_TO_METHOD[service]['schema'] + schema = SERVICE_TO_METHOD[service]["schema"] hass.services.async_register( - DOMAIN, service, async_service_handler, schema=schema) + DOMAIN, service, async_service_handler, schema=schema + ) -class BluesoundPlayer(MediaPlayerDevice): +class BluesoundPlayer(MediaPlayerEntity): """Representation of a Bluesound Player.""" def __init__(self, hass, host, port=None, name=None, init_callback=None): @@ -191,6 +224,8 @@ def __init__(self, hass, host, port=None, name=None, init_callback=None): self._master = None self._is_master = False self._group_name = None + self._group_list = [] + self._bluesound_device_name = None self._init_callback = init_callback if self.port is None: @@ -207,28 +242,32 @@ def _try_get_index(string, search_string): except ValueError: return -1 - async def force_update_sync_status( - self, on_updated_cb=None, raise_timeout=False): + 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) + "SyncStatus", raise_timeout, raise_timeout + ) if not resp: return None - self._sync_status = resp['SyncStatus'].copy() + self._sync_status = resp["SyncStatus"].copy() if not self._name: - self._name = self._sync_status.get('@name', self.host) + self._name = self._sync_status.get("@name", self.host) + if not self._bluesound_device_name: + self._bluesound_device_name = self._sync_status.get("@name", self.host) if not self._icon: - self._icon = self._sync_status.get('@icon', self.host) + self._icon = self._sync_status.get("@icon", self.host) - master = self._sync_status.get('master', None) + master = self._sync_status.get("master") 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] + 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] @@ -238,7 +277,7 @@ async def force_update_sync_status( else: if self._master is not None: self._master = None - slaves = self._sync_status.get('slave', None) + slaves = self._sync_status.get("slave") self._is_master = slaves is not None if on_updated_cb: @@ -251,11 +290,9 @@ async def _start_poll_command(self): while True: await self.async_update_status() - except (asyncio.TimeoutError, ClientError, - BluesoundPlayer._TimeoutException): + except (asyncio.TimeoutError, ClientError, BluesoundPlayer._TimeoutException): _LOGGER.info("Node %s is offline, retrying later", self._name) - await asyncio.sleep( - NODE_OFFLINE_CHECK_TIMEOUT, loop=self._hass.loop) + await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT) self.start_polling() except CancelledError: @@ -266,8 +303,7 @@ async def _start_poll_command(self): def start_polling(self): """Start the polling task.""" - self._polling_task = self._hass.async_create_task( - self._start_poll_command()) + self._polling_task = self._hass.async_create_task(self._start_poll_command()) def stop_polling(self): """Stop the polling task.""" @@ -280,15 +316,14 @@ async def async_init(self, triggered=None): self._retry_remove() self._retry_remove = None - await self.force_update_sync_status( - self._init_callback, True) + 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) + self._hass, self.async_init, NODE_RETRY_INITIATION + ) except Exception: - _LOGGER.exception( - "Unexpected when initiating error in %s", self.host) + _LOGGER.exception("Unexpected when initiating error in %s", self.host) raise async def async_update(self): @@ -302,26 +337,25 @@ async def async_update(self): await self.async_update_services() async def send_bluesound_command( - self, method, raise_timeout=False, allow_offline=False): + 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] == '/': + if method[0] == "/": method = method[1:] - url = "http://{}:{}/{}".format(self.host, self.port, method) + url = f"http://{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, loop=self._hass.loop): + with async_timeout.timeout(10): response = await websession.get(url) - if response.status == 200: + if response.status == HTTP_OK: result = await response.text() if result: data = xmltodict.parse(result) @@ -345,66 +379,69 @@ async def send_bluesound_command( 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 = '' + url = "Status" + etag = "" if self._status is not None: - etag = self._status.get('@etag', '') + etag = self._status.get("@etag", "") - if etag != '': - url = 'Status?etag={}&timeout=120.0'.format(etag) - url = "http://{}:{}/{}".format(self.host, self.port, url) + if etag != "": + url = f"Status?etag={etag}&timeout=120.0" + url = f"http://{self.host}:{self.port}/{url}" _LOGGER.debug("Calling URL: %s", url) try: - with async_timeout.timeout(125, loop=self._hass.loop): + with async_timeout.timeout(125): response = await self._polling_session.get( - url, headers={CONNECTION: KEEP_ALIVE}) + url, headers={CONNECTION: KEEP_ALIVE} + ) - if response.status == 200: + if response.status == HTTP_OK: result = await response.text() self._is_online = True self._last_status_update = dt_util.utcnow() - self._status = xmltodict.parse(result)['status'].copy() + self._status = xmltodict.parse(result)["status"].copy() - group_name = self._status.get('groupName', None) + group_name = self._status.get("groupName") if group_name != self._group_name: - _LOGGER.debug( - "Group name change detected on device: %s", self.host) + _LOGGER.debug("Group name change detected on device: %s", self.host) self._group_name = group_name + + # rebuild ordered list of entity_ids that are in the group, master is first + self._group_list = self.rebuild_bluesound_group() + # the sleep is needed to make sure that the # devices is synced - await asyncio.sleep(1, loop=self._hass.loop) + 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 + # the device is playing. This would solve a lot 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() + self.async_write_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) + _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) + self.async_write_ha_state() + _LOGGER.info("Client connection error, marking %s as offline", self._name) raise async def async_trigger_sync_on_all(self): @@ -415,90 +452,93 @@ async def async_trigger_sync_on_all(self): await player.force_update_sync_status() @Throttle(SYNC_STATUS_INTERVAL) - async def async_update_sync_status( - self, on_updated_cb=None, raise_timeout=False): + 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) + 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') + 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']: + 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']) + _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') + 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']: + 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": f"Preset?id={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']) + _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') + 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']: + 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']) + _create_service_item(resp["services"]["service"]) return self._services_items @@ -516,21 +556,20 @@ def state(self): if self.is_grouped and not self.is_master: return STATE_GROUPED - status = self._status.get('state', None) - if status in ('pause', 'stop'): + status = self._status.get("state") + if status in ("pause", "stop"): return STATE_PAUSED - if status in ('stream', 'play'): + 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)): + if self._status is None or (self.is_grouped and not self.is_master): return None - return self._status.get('title1', None) + return self._status.get("title1") @property def media_artist(self): @@ -541,68 +580,63 @@ def media_artist(self): if self.is_grouped and not self.is_master: return self._group_name - artist = self._status.get('artist', None) + artist = self._status.get("artist") if not artist: - artist = self._status.get('title2', None) + artist = self._status.get("title2") 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)): + if self._status is None or (self.is_grouped and not self.is_master): return None - album = self._status.get('album', None) + album = self._status.get("album") if not album: - album = self._status.get('title3', None) + album = self._status.get("title3") 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)): + if self._status is None or (self.is_grouped and not self.is_master): return None - url = self._status.get('image', None) + url = self._status.get("image") if not url: return - if url[0] == '/': - url = "http://{}:{}{}".format(self.host, self.port, url) + if url[0] == "/": + url = f"http://{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)): + 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) + position = self._status.get("secs") if position is None: return None position = float(position) if mediastate == STATE_PLAYING: - position += (dt_util.utcnow() - - self._last_status_update).total_seconds() + 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)): + if self._status is None or (self.is_grouped and not self.is_master): return None - duration = self._status.get('totlen', None) + duration = self._status.get("totlen") if duration is None: return None return float(duration) @@ -615,9 +649,9 @@ def media_position_updated_at(self): @property def volume_level(self): """Volume level of the media player (0..1).""" - volume = self._status.get('volume', None) + volume = self._status.get("volume") if self.is_grouped: - volume = self._sync_status.get('@volume', None) + volume = self._sync_status.get("@volume") if volume is not None: return int(volume) / 100 @@ -636,6 +670,11 @@ def name(self): """Return the name of the device.""" return self._name + @property + def bluesound_device_name(self): + """Return the device name as returned by the device.""" + return self._bluesound_device_name + @property def icon(self): """Return the icon of the device.""" @@ -644,117 +683,131 @@ def icon(self): @property def source_list(self): """List of available input sources.""" - if (self._status is None or - (self.is_grouped and not self.is_master)): + 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']) + 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 [ + 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']) + 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)): + 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', '') + 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 != '': + 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] + 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'] + 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"] + 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'] + return items[0]["title"] - items = [x for x in self._capture_items if x['url'] == stream_url] + items = [x for x in self._capture_items if x["url"] == stream_url] if items: - return items[0]['title'] + return items[0]["title"] - if stream_url[:8] == 'Capture:': + if stream_url[:8] == "Capture:": stream_url = stream_url[8:] - idx = BluesoundPlayer._try_get_index(stream_url, ':') + 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 = parse.unquote(item["url"]) + if url[:8] == "Capture:": url = url[8:] - idx = BluesoundPlayer._try_get_index(url, ':') + idx = BluesoundPlayer._try_get_index(url, ":") if idx > 0: url = url[:idx] if url.lower() == stream_url.lower(): - return item['title'] + return item["title"] - items = [x for x in self._capture_items - if x['name'] == current_service] + items = [x for x in self._capture_items if x["name"] == current_service] if items: - return items[0]['title'] + return items[0]["title"] - items = [x for x in self._services_items - if x['name'] == current_service] + items = [x for x in self._services_items if x["name"] == current_service] if items: - return items[0]['title'] + return items[0]["title"] - if self._status.get('streamUrl', '') != '': - _LOGGER.debug("Couldn't find source of stream URL: %s", - self._status.get('streamUrl', '')) + 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 + return 0 if self.is_grouped and not self.is_master: - return SUPPORT_VOLUME_STEP | SUPPORT_VOLUME_SET | \ - SUPPORT_VOLUME_MUTE + 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 + 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_VOLUME_STEP + | SUPPORT_VOLUME_SET + | SUPPORT_VOLUME_MUTE + ) + + if self._status.get("canSeek", "") == "1": supported = supported | SUPPORT_SEEK return supported @@ -772,21 +825,60 @@ def is_grouped(self): @property def shuffle(self): """Return true if shuffle is active.""" - return self._status.get('shuffle', '0') == '1' + 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] + 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) + _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) + @property + def device_state_attributes(self): + """List members in group.""" + attributes = {} + if self._group_list: + attributes = {ATTR_BLUESOUND_GROUP: self._group_list} + + attributes[ATTR_MASTER] = self._is_master + + return attributes + + def rebuild_bluesound_group(self): + """Rebuild the list of entities in speaker group.""" + if self._group_name is None: + return None + + bluesound_group = [] + + device_group = self._group_name.split("+") + + sorted_entities = sorted( + self._hass.data[DATA_BLUESOUND], + key=lambda entity: entity.is_master, + reverse=True, + ) + bluesound_group = [ + entity.name + for entity in sorted_entities + if entity.bluesound_device_name in device_group + ] + + return bluesound_group + async def async_unjoin(self): """Unjoin the player from a group.""" if self._master is None: @@ -798,24 +890,23 @@ async def async_unjoin(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)) + f"/AddSlave?slave={slave_device.host}&port={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)) + f"/RemoveSlave?slave={slave_device.host}&port={slave_device.port}" + ) async def async_increase_timer(self): """Increase sleep time on player.""" - sleep_time = await self.send_bluesound_command('/Sleep') + 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) + _LOGGER.error("Error while increasing sleep time on player: %s", self.host) return 0 - return int(sleep_time.get('sleep', '0')) + return int(sleep_time.get("sleep", "0")) async def async_clear_timer(self): """Clear sleep timer on player.""" @@ -825,31 +916,29 @@ async def async_clear_timer(self): 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)) + value = "1" if shuffle else "0" + return await self.send_bluesound_command(f"/Shuffle?state={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] + 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] + 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] + 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']) + url = f"Play?url={selected_source['url']}&preset_id&image={selected_source['image']}" - if 'is_raw_url' in selected_source and selected_source['is_raw_url']: - url = selected_source['url'] + if "is_raw_url" in selected_source and selected_source["is_raw_url"]: + url = selected_source["url"] return await self.send_bluesound_command(url) @@ -858,19 +947,18 @@ async def async_clear_playlist(self): if self.is_grouped and not self.is_master: return - return await self.send_bluesound_command('Clear') + 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'] + 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) @@ -879,12 +967,11 @@ async def async_media_previous_track(self): 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'] + 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) @@ -893,29 +980,28 @@ async def async_media_play(self): if self.is_grouped and not self.is_master: return - return await self.send_bluesound_command('Play') + 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') + 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') + 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))) + return await self.send_bluesound_command(f"Play?seek={float(position)}") async def async_play_media(self, media_type, media_id, **kwargs): """ @@ -926,7 +1012,7 @@ async def async_play_media(self, media_type, media_id, **kwargs): if self.is_grouped and not self.is_master: return - url = 'Play?url={}'.format(media_id) + url = f"Play?url={media_id}" if kwargs.get(ATTR_MEDIA_ENQUEUE): return await self.send_bluesound_command(url) @@ -936,16 +1022,16 @@ async def async_play_media(self, media_type, media_id, **kwargs): async def async_volume_up(self): """Volume up the media player.""" current_vol = self.volume_level - if not current_vol or current_vol < 0: + if not current_vol or current_vol >= 1: return - return self.async_set_volume_level(((current_vol*100)+1)/100) + return await self.async_set_volume_level(current_vol + 0.01) async def async_volume_down(self): """Volume down the media player.""" current_vol = self.volume_level - if not current_vol or current_vol < 0: + if not current_vol or current_vol <= 0: return - return self.async_set_volume_level(((current_vol*100)-1)/100) + return await self.async_set_volume_level(current_vol - 0.01) async def async_set_volume_level(self, volume): """Send volume_up command to media player.""" @@ -953,8 +1039,7 @@ async def async_set_volume_level(self, volume): volume = 0 elif volume > 1: volume = 1 - return await self.send_bluesound_command( - 'Volume?level=' + str(float(volume) * 100)) + return await self.send_bluesound_command(f"Volume?level={float(volume) * 100}") async def async_mute_volume(self, mute): """Send mute command to media player.""" @@ -962,6 +1047,7 @@ async def async_mute_volume(self, 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=0") return await self.send_bluesound_command( - 'Volume?level=' + str(float(self._lastvol) * 100)) + f"Volume?level={float(self._lastvol) * 100}" + ) diff --git a/homeassistant/components/bluesound/services.yaml b/homeassistant/components/bluesound/services.yaml index e69de29bb2d1d..0ca12c9e2ae46 100644 --- a/homeassistant/components/bluesound/services.yaml +++ b/homeassistant/components/bluesound/services.yaml @@ -0,0 +1,30 @@ +join: + description: Group player together. + fields: + master: + description: Entity ID of the player that should become the master of the group. + example: "media_player.bluesound_livingroom" + entity_id: + description: Name(s) of entities that will coordinate the grouping. Platform dependent. + example: "media_player.bluesound_livingroom" + +unjoin: + description: Unjoin the player from a group. + fields: + entity_id: + description: Name(s) of entities that will be unjoined from their group. Platform dependent. + example: "media_player.bluesound_livingroom" + +set_sleep_timer: + description: "Set a Bluesound timer. It will increase timer in steps: 15, 30, 45, 60, 90, 0" + fields: + entity_id: + description: Name(s) of entities that will have a timer set. + example: "media_player.bluesound_livingroom" + +clear_sleep_timer: + description: Clear a Bluesound timer. + fields: + entity_id: + description: Name(s) of entities that will have the timer cleared. + example: "media_player.bluesound_livingroom" diff --git a/homeassistant/components/bluetooth_le_tracker/device_tracker.py b/homeassistant/components/bluetooth_le_tracker/device_tracker.py index d256f56e7fe0e..4957356d26a18 100644 --- a/homeassistant/components/bluetooth_le_tracker/device_tracker.py +++ b/homeassistant/components/bluetooth_le_tracker/device_tracker.py @@ -1,26 +1,34 @@ """Tracking for bluetooth low energy devices.""" +import asyncio import logging -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, SOURCE_TYPE_BLUETOOTH_LE +import pygatt # pylint: disable=import-error + +from homeassistant.components.device_tracker.const import ( + CONF_SCAN_INTERVAL, + CONF_TRACK_NEW, + SCAN_INTERVAL, + SOURCE_TYPE_BLUETOOTH_LE, +) +from homeassistant.components.device_tracker.legacy import ( + YAML_DEVICES, + async_load_config, ) from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.helpers.event import track_point_in_utc_time import homeassistant.util.dt as dt_util _LOGGER = logging.getLogger(__name__) -DATA_BLE = 'BLE' -DATA_BLE_ADAPTER = 'ADAPTER' -BLE_PREFIX = 'BLE_' +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}) @@ -36,26 +44,31 @@ def handle_stop(event): def see_device(address, name, new_device=False): """Mark a device as seen.""" + if name is not None: + name = name.strip("\x00") + 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) + new_devices[address]["seen"] += 1 + if name: + new_devices[address]["name"] = name else: + name = new_devices[address]["name"] + _LOGGER.debug("Seen %s %s times", address, new_devices[address]["seen"]) + if new_devices[address]["seen"] < MIN_SEEN_NEW: return + _LOGGER.debug("Adding %s to tracked devices", address) + devs_to_track.append(address) else: _LOGGER.debug("Seen %s for the first time", address) - new_devices[address] = 1 + new_devices[address] = {"seen": 1, "name": name} return - if name is not None: - name = name.strip("\x00") - - see(mac=BLE_PREFIX + address, host_name=name, - source_type=SOURCE_TYPE_BLUETOOTH_LE) + see( + mac=BLE_PREFIX + address, + host_name=name, + source_type=SOURCE_TYPE_BLUETOOTH_LE, + ) def discover_ble_devices(): """Discover Bluetooth LE devices.""" @@ -65,9 +78,9 @@ def discover_ble_devices(): hass.data[DATA_BLE][DATA_BLE_ADAPTER] = adapter devs = adapter.scan() - devices = {x['address']: x['name'] for x in devs} + devices = {x["address"]: x["name"] for x in devs} _LOGGER.debug("Bluetooth LE devices discovered = %s", devices) - except RuntimeError as error: + except (RuntimeError, pygatt.exceptions.BLEError) as error: _LOGGER.error("Error during Bluetooth LE scan: %s", error) return {} return devices @@ -79,7 +92,9 @@ def discover_ble_devices(): # 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): + for device in asyncio.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: @@ -97,7 +112,7 @@ def discover_ble_devices(): _LOGGER.warning("No Bluetooth LE devices to track!") return False - interval = config.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + interval = config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL) def update_ble(now): """Lookup Bluetooth LE devices and update status.""" @@ -112,8 +127,7 @@ def update_ble(now): if track_new: for address in devs: - if address not in devs_to_track and \ - address not in devs_donot_track: + 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) diff --git a/homeassistant/components/bluetooth_le_tracker/manifest.json b/homeassistant/components/bluetooth_le_tracker/manifest.json index d2f8f10290e5e..ca4a44c55c669 100644 --- a/homeassistant/components/bluetooth_le_tracker/manifest.json +++ b/homeassistant/components/bluetooth_le_tracker/manifest.json @@ -1,10 +1,7 @@ { "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": [], + "name": "Bluetooth LE Tracker", + "documentation": "https://www.home-assistant.io/integrations/bluetooth_le_tracker", + "requirements": ["pygatt[GATTTOOL]==4.0.5"], "codeowners": [] } diff --git a/homeassistant/components/bluetooth_tracker/const.py b/homeassistant/components/bluetooth_tracker/const.py new file mode 100644 index 0000000000000..b481efa296f78 --- /dev/null +++ b/homeassistant/components/bluetooth_tracker/const.py @@ -0,0 +1,3 @@ +"""Constants for the Bluetooth Tracker component.""" +DOMAIN = "bluetooth_tracker" +SERVICE_UPDATE = "update" diff --git a/homeassistant/components/bluetooth_tracker/device_tracker.py b/homeassistant/components/bluetooth_tracker/device_tracker.py index d464e87ce640f..af49266bef4b0 100644 --- a/homeassistant/components/bluetooth_tracker/device_tracker.py +++ b/homeassistant/components/bluetooth_tracker/device_tracker.py @@ -1,120 +1,189 @@ """Tracking for bluetooth devices.""" +import asyncio import logging +from typing import List, Optional, Set, Tuple +# pylint: disable=import-error +import bluetooth +from bt_proximity import BluetoothRSSI import voluptuous as vol +from homeassistant.components.device_tracker import PLATFORM_SCHEMA +from homeassistant.components.device_tracker.const import ( + CONF_SCAN_INTERVAL, + CONF_TRACK_NEW, + DEFAULT_TRACK_NEW, + SCAN_INTERVAL, + SOURCE_TYPE_BLUETOOTH, +) +from homeassistant.components.device_tracker.legacy import ( + YAML_DEVICES, + async_load_config, +) 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, SOURCE_TYPE_BLUETOOTH, - DOMAIN) -import homeassistant.util.dt as dt_util +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.typing import HomeAssistantType + +from .const import DOMAIN, SERVICE_UPDATE _LOGGER = logging.getLogger(__name__) -BT_PREFIX = 'BT_' +BT_PREFIX = "BT_" -CONF_REQUEST_RSSI = 'request_rssi' +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): +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 is_bluetooth_device(device) -> bool: + """Check whether a device is a bluetooth device by its mac.""" + return device.mac and device.mac[:3].upper() == BT_PREFIX + + +def discover_devices(device_id: int) -> List[Tuple[str, str]]: + """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 + + +async def see_device( + hass: HomeAssistantType, async_see, mac: str, device_name: str, rssi=None +) -> None: + """Mark a device as seen.""" + attributes = {} + if rssi is not None: + attributes["rssi"] = rssi + + await async_see( + mac=f"{BT_PREFIX}{mac}", + host_name=device_name, + attributes=attributes, + source_type=SOURCE_TYPE_BLUETOOTH, + ) + + +async def get_tracking_devices(hass: HomeAssistantType) -> Tuple[Set[str], Set[str]]: + """ + Load all known devices. + + We just need the devices so set consider_home and home range to 0 + """ + yaml_path: str = hass.config.path(YAML_DEVICES) + + devices = await async_load_config(yaml_path, hass, 0) + bluetooth_devices = [device for device in devices if is_bluetooth_device(device)] + + devices_to_track: Set[str] = { + device.mac[3:] for device in bluetooth_devices if device.track + } + devices_to_not_track: Set[str] = { + device.mac[3:] for device in bluetooth_devices if not device.track + } + + return devices_to_track, devices_to_not_track + + +def lookup_name(mac: str) -> Optional[str]: + """Lookup a Bluetooth device name.""" + _LOGGER.debug("Scanning %s", mac) + return bluetooth.lookup_name(mac, timeout=5) + + +async def async_setup_scanner( + hass: HomeAssistantType, config: dict, async_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 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:]) + device_id: int = config[CONF_DEVICE_ID] + interval = config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL) + request_rssi = config.get(CONF_REQUEST_RSSI, False) + update_bluetooth_lock = asyncio.Lock() # 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]) + track_new: bool = config.get(CONF_TRACK_NEW, DEFAULT_TRACK_NEW) + _LOGGER.debug("Tracking new devices is set to %s", track_new) - interval = config.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + devices_to_track, devices_to_not_track = await get_tracking_devices(hass) - request_rssi = config.get(CONF_REQUEST_RSSI, False) + if not devices_to_track and not track_new: + _LOGGER.debug("No Bluetooth devices to track and not tracking new devices") - 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) + if request_rssi: + _LOGGER.debug("Detecting RSSI for devices") + + async def perform_bluetooth_update(): + """Discover Bluetooth devices and update status.""" + + _LOGGER.debug("Performing Bluetooth devices discovery and update") + tasks = [] - 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: + devices = await hass.async_add_executor_job(discover_devices, device_id) + for mac, device_name in devices: + if mac not in devices_to_track and mac not in devices_to_not_track: + devices_to_track.add(mac) + + for mac in devices_to_track: + device_name = await hass.async_add_executor_job(lookup_name, mac) + if device_name is None: # Could not lookup device name continue - see_device(mac, result, rssi) + + rssi = None + if request_rssi: + client = BluetoothRSSI(mac) + rssi = await hass.async_add_executor_job(client.request_rssi) + client.close() + + tasks.append(see_device(hass, async_see, mac, device_name, rssi)) + + if tasks: + await asyncio.wait(tasks) + except bluetooth.BluetoothError: _LOGGER.exception("Error looking up Bluetooth device") - def handle_update_bluetooth(call): + async def update_bluetooth(now=None): + """Lookup Bluetooth devices and update status.""" + + # If an update is in progress, we don't do anything + if update_bluetooth_lock.locked(): + _LOGGER.debug( + "Previous execution of update_bluetooth is taking longer than the scheduled update of interval %s", + interval, + ) + return + + async with update_bluetooth_lock: + await perform_bluetooth_update() + + async def handle_manual_update_bluetooth(call): """Update bluetooth devices on demand.""" - update_bluetooth_once() - update_bluetooth(dt_util.utcnow()) + await update_bluetooth() + + hass.async_create_task(update_bluetooth()) + async_track_time_interval(hass, update_bluetooth, interval) - hass.services.register( - DOMAIN, "bluetooth_tracker_update", handle_update_bluetooth) + hass.services.async_register(DOMAIN, SERVICE_UPDATE, handle_manual_update_bluetooth) return True diff --git a/homeassistant/components/bluetooth_tracker/manifest.json b/homeassistant/components/bluetooth_tracker/manifest.json index 7eaeb4ef92748..9ef6fddcb0d06 100644 --- a/homeassistant/components/bluetooth_tracker/manifest.json +++ b/homeassistant/components/bluetooth_tracker/manifest.json @@ -1,11 +1,7 @@ { "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": [], + "name": "Bluetooth Tracker", + "documentation": "https://www.home-assistant.io/integrations/bluetooth_tracker", + "requirements": ["bt_proximity==0.2", "pybluez==0.22"], "codeowners": [] } diff --git a/homeassistant/components/bluetooth_tracker/services.yaml b/homeassistant/components/bluetooth_tracker/services.yaml index e69de29bb2d1d..01b31eee63e76 100644 --- a/homeassistant/components/bluetooth_tracker/services.yaml +++ b/homeassistant/components/bluetooth_tracker/services.yaml @@ -0,0 +1,2 @@ +update: + description: Trigger manual tracker update diff --git a/homeassistant/components/bme280/manifest.json b/homeassistant/components/bme280/manifest.json index 2342c8418ebce..2402c41402e3d 100644 --- a/homeassistant/components/bme280/manifest.json +++ b/homeassistant/components/bme280/manifest.json @@ -1,11 +1,7 @@ { "domain": "bme280", - "name": "Bme280", - "documentation": "https://www.home-assistant.io/components/bme280", - "requirements": [ - "i2csense==0.0.4", - "smbus-cffi==0.5.1" - ], - "dependencies": [], + "name": "Bosch BME280 Environmental Sensor", + "documentation": "https://www.home-assistant.io/integrations/bme280", + "requirements": ["i2csense==0.0.4", "smbus-cffi==0.5.1"], "codeowners": [] } diff --git a/homeassistant/components/bme280/sensor.py b/homeassistant/components/bme280/sensor.py index 66b4ba672589a..893ddbf54e981 100644 --- a/homeassistant/components/bme280/sensor.py +++ b/homeassistant/components/bme280/sensor.py @@ -3,30 +3,36 @@ from functools import partial import logging +from i2csense.bme280 import BME280 # pylint: disable=import-error +import smbus # pylint: disable=import-error 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) + CONF_MONITORED_CONDITIONS, + CONF_NAME, + TEMP_FAHRENHEIT, + UNIT_PERCENTAGE, +) +import homeassistant.helpers.config_validation as cv 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' +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 @@ -34,64 +40,69 @@ 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. +DEFAULT_DELTA_TEMP = 0.0 MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=3) -SENSOR_TEMP = 'temperature' -SENSOR_HUMID = 'humidity' -SENSOR_PRESS = 'pressure' +SENSOR_TEMP = "temperature" +SENSOR_HUMID = "humidity" +SENSOR_PRESS = "pressure" SENSOR_TYPES = { - SENSOR_TEMP: ['Temperature', None], - SENSOR_HUMID: ['Humidity', '%'], - SENSOR_PRESS: ['Pressure', 'mb'] + SENSOR_TEMP: ["Temperature", None], + SENSOR_HUMID: ["Humidity", UNIT_PERCENTAGE], + 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): +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) + name = config[CONF_NAME] + i2c_address = config[CONF_I2C_ADDRESS] - bus = smbus.SMBus(config.get(CONF_I2C_BUS)) + bus = smbus.SMBus(config[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) + partial( + BME280, + bus, + i2c_address, + osrs_t=config[CONF_OVERSAMPLING_TEMP], + osrs_p=config[CONF_OVERSAMPLING_PRES], + osrs_h=config[CONF_OVERSAMPLING_HUM], + mode=config[CONF_OPERATION_MODE], + t_sb=config[CONF_T_STANDBY], + filter_mode=config[CONF_FILTER_MODE], + delta_temp=config[CONF_DELTA_TEMP], + logger=_LOGGER, + ) ) if not sensor.sample_ok: _LOGGER.error("BME280 sensor not detected at %s", i2c_address) @@ -102,8 +113,9 @@ async def async_setup_platform(hass, config, async_add_entities, dev = [] try: for variable in config[CONF_MONITORED_CONDITIONS]: - dev.append(BME280Sensor( - sensor_handler, variable, SENSOR_TYPES[variable][1], name)) + dev.append( + BME280Sensor(sensor_handler, variable, SENSOR_TYPES[variable][1], name) + ) except KeyError: pass @@ -140,7 +152,7 @@ def __init__(self, bme280_client, sensor_type, temp_unit, name): @property def name(self): """Return the name of the sensor.""" - return '{} {}'.format(self.client_name, self._name) + return f"{self.client_name} {self._name}" @property def state(self): diff --git a/homeassistant/components/bme680/manifest.json b/homeassistant/components/bme680/manifest.json index 976be85ca9413..be59b2fbaf9f6 100644 --- a/homeassistant/components/bme680/manifest.json +++ b/homeassistant/components/bme680/manifest.json @@ -1,11 +1,7 @@ { "domain": "bme680", - "name": "Bme680", - "documentation": "https://www.home-assistant.io/components/bme680", - "requirements": [ - "bme680==1.0.5", - "smbus-cffi==0.5.1" - ], - "dependencies": [], + "name": "Bosch BME680 Environmental Sensor", + "documentation": "https://www.home-assistant.io/integrations/bme680", + "requirements": ["bme680==1.0.5", "smbus-cffi==0.5.1"], "codeowners": [] } diff --git a/homeassistant/components/bme680/sensor.py b/homeassistant/components/bme680/sensor.py index 73fe827be6ba2..2d274c077a407 100644 --- a/homeassistant/components/bme680/sensor.py +++ b/homeassistant/components/bme680/sensor.py @@ -1,35 +1,40 @@ """Support for BME680 Sensor over SMBus.""" -import importlib import logging +import threading +from time import monotonic, sleep -from time import time, sleep - +import bme680 # pylint: disable=import-error +from smbus import SMBus # pylint: disable=import-error 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) + CONF_MONITORED_CONDITIONS, + CONF_NAME, + TEMP_FAHRENHEIT, + UNIT_PERCENTAGE, +) +import homeassistant.helpers.config_validation as cv 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' +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 @@ -43,58 +48,68 @@ 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_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', '%'] + SENSOR_TEMP: ["Temperature", None], + SENSOR_HUMID: ["Humidity", UNIT_PERCENTAGE], + SENSOR_PRESS: ["Pressure", "mb"], + SENSOR_GAS: ["Gas Resistance", "Ohms"], + SENSOR_AQ: ["Air Quality", UNIT_PERCENTAGE], } 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): +OVERSAMPLING_VALUES = {0, 1, 2, 4, 8, 16} +FILTER_VALUES = {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) + name = config[CONF_NAME] sensor_handler = await hass.async_add_job(_setup_bme680, config) if sensor_handler is None: @@ -102,8 +117,9 @@ async def async_setup_platform(hass, config, async_add_entities, dev = [] for variable in config[CONF_MONITORED_CONDITIONS]: - dev.append(BME680Sensor( - sensor_handler, variable, SENSOR_TYPES[variable][1], name)) + dev.append( + BME680Sensor(sensor_handler, variable, SENSOR_TYPES[variable][1], name) + ) async_add_entities(dev) return @@ -111,15 +127,13 @@ async def async_setup_platform(hass, config, async_add_entities, 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)) + i2c_address = config[CONF_I2C_ADDRESS] + bus = SMBus(config[CONF_I2C_BUS]) sensor = bme680.BME680(i2c_address, bus) # Configure Oversampling @@ -129,20 +143,12 @@ def _setup_bme680(config): 2: bme680.OS_2X, 4: bme680.OS_4X, 8: bme680.OS_8X, - 16: bme680.OS_16X + 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)] - ) + sensor.set_temperature_oversample(os_lookup[config[CONF_OVERSAMPLING_TEMP]]) + sensor.set_temp_offset(config[CONF_TEMP_OFFSET]) + sensor.set_humidity_oversample(os_lookup[config[CONF_OVERSAMPLING_HUM]]) + sensor.set_pressure_oversample(os_lookup[config[CONF_OVERSAMPLING_PRES]]) # Configure IIR Filter filter_lookup = { @@ -153,16 +159,14 @@ def _setup_bme680(config): 15: bme680.FILTER_SIZE_15, 31: bme680.FILTER_SIZE_31, 63: bme680.FILTER_SIZE_63, - 127: bme680.FILTER_SIZE_127 + 127: bme680.FILTER_SIZE_127, } - sensor.set_filter( - filter_lookup[config.get(CONF_FILTER_SIZE)] - ) + sensor.set_filter(filter_lookup[config[CONF_FILTER_SIZE]]) # Configure the Gas Heater if ( - SENSOR_GAS in config[CONF_MONITORED_CONDITIONS] or - SENSOR_AQ in config[CONF_MONITORED_CONDITIONS] + 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]) @@ -170,17 +174,19 @@ def _setup_bme680(config): sensor.select_gas_heater_profile(0) else: sensor.set_gas_status(bme680.DISABLE_GAS_MEAS) - except (RuntimeError, IOError): + except (RuntimeError, OSError): _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]), + ( + 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] + config[CONF_AQ_HUM_WEIGHTING], ) sleep(0.5) # Wait for device to stabilize if not sensor_handler.sensor_data.temperature: @@ -205,8 +211,12 @@ def __init__(self): self.air_quality = None def __init__( - self, sensor, gas_measurement=False, - burn_in_time=300, hum_baseline=40, hum_weighting=25 + self, + sensor, + gas_measurement=False, + burn_in_time=300, + hum_baseline=40, + hum_weighting=25, ): """Initialize the sensor handler.""" self.sensor_data = BME680Handler.SensorData() @@ -217,11 +227,11 @@ def __init__( 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' + kwargs={"burn_in_time": burn_in_time}, + name="BME680Handler_run_gas_sensor", ).start() self.update(first_read=True) @@ -235,38 +245,35 @@ def _run_gas_sensor(self, burn_in_time): # Pause to allow initial data read for device validation. sleep(1) - start_time = time() - curr_time = time() + start_time = monotonic() + curr_time = monotonic() burn_in_data = [] - _LOGGER.info("Beginning %d second gas sensor burn in for Air Quality", - burn_in_time) + _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 - ): + curr_time = monotonic() + 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) + _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) + _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 - ) + 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) @@ -295,16 +302,10 @@ def _calculate_aq_score(self): # 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 + (100 - hum_baseline - hum_offset) / (100 - hum_baseline) * hum_weighting ) else: - hum_score = ( - (hum_baseline + hum_offset) / - hum_baseline * - hum_weighting - ) + hum_score = (hum_baseline + hum_offset) / hum_baseline * hum_weighting # Calculate gas_score as the distance from the gas_baseline. if gas_offset > 0: @@ -332,7 +333,7 @@ def __init__(self, bme680_client, sensor_type, temp_unit, name): @property def name(self): """Return the name of the sensor.""" - return '{} {}'.format(self.client_name, self._name) + return f"{self.client_name} {self._name}" @property def state(self): @@ -357,9 +358,7 @@ async def async_update(self): 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) - ) + 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: diff --git a/homeassistant/components/bmp280/__init__.py b/homeassistant/components/bmp280/__init__.py new file mode 100644 index 0000000000000..0c884eafbf166 --- /dev/null +++ b/homeassistant/components/bmp280/__init__.py @@ -0,0 +1 @@ +"""The Bosch BMP280 sensor integration.""" diff --git a/homeassistant/components/bmp280/manifest.json b/homeassistant/components/bmp280/manifest.json new file mode 100644 index 0000000000000..dbd7989671896 --- /dev/null +++ b/homeassistant/components/bmp280/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "bmp280", + "name": "Bosch BMP280 Environmental Sensor", + "documentation": "https://www.home-assistant.io/integrations/bmp280", + "codeowners": ["@belidzs"], + "requirements": ["adafruit-circuitpython-bmp280==3.1.1", "RPi.GPIO==0.7.0"], + "quality_scale": "silver" +} diff --git a/homeassistant/components/bmp280/sensor.py b/homeassistant/components/bmp280/sensor.py new file mode 100644 index 0000000000000..70efbce7d85d0 --- /dev/null +++ b/homeassistant/components/bmp280/sensor.py @@ -0,0 +1,159 @@ +"""Platform for Bosch BMP280 Environmental Sensor integration.""" +from datetime import timedelta +import logging + +from adafruit_bmp280 import Adafruit_BMP280_I2C +import board +from busio import I2C +import voluptuous as vol + +from homeassistant.components.sensor import ( + DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_TEMPERATURE, + PLATFORM_SCHEMA, +) +from homeassistant.const import CONF_NAME, PRESSURE_HPA, TEMP_CELSIUS +from homeassistant.exceptions import PlatformNotReady +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = "BMP280" +SCAN_INTERVAL = timedelta(seconds=15) + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=3) + +MIN_I2C_ADDRESS = 0x76 +MAX_I2C_ADDRESS = 0x77 + +CONF_I2C_ADDRESS = "i2c_address" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Required(CONF_I2C_ADDRESS): vol.All( + vol.Coerce(int), vol.Range(min=MIN_I2C_ADDRESS, max=MAX_I2C_ADDRESS) + ), + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the sensor platform.""" + try: + # initializing I2C bus using the auto-detected pins + i2c = I2C(board.SCL, board.SDA) + # initializing the sensor + bmp280 = Adafruit_BMP280_I2C(i2c, address=config[CONF_I2C_ADDRESS]) + except ValueError as error: + # this usually happens when the board is I2C capable, but the device can't be found at the configured address + if str(error.args[0]).startswith("No I2C device at address"): + _LOGGER.error( + "%s. Hint: Check wiring and make sure that the SDO pin is tied to either ground (0x76) or VCC (0x77)", + error.args[0], + ) + raise PlatformNotReady() + _LOGGER.error(error) + return + # use custom name if there's any + name = config[CONF_NAME] + # BMP280 has both temperature and pressure sensing capability + add_entities( + [Bmp280TemperatureSensor(bmp280, name), Bmp280PressureSensor(bmp280, name)] + ) + + +class Bmp280Sensor(Entity): + """Base class for BMP280 entities.""" + + def __init__( + self, + bmp280: Adafruit_BMP280_I2C, + name: str, + unit_of_measurement: str, + device_class: str, + ): + """Initialize the sensor.""" + self._bmp280 = bmp280 + self._name = name + self._unit_of_measurement = unit_of_measurement + self._device_class = device_class + self._state = None + self._errored = False + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self._unit_of_measurement + + @property + def device_class(self): + """Return the device class.""" + return self._device_class + + @property + def available(self) -> bool: + """Return if the device is currently available.""" + return not self._errored + + +class Bmp280TemperatureSensor(Bmp280Sensor): + """Representation of a Bosch BMP280 Temperature Sensor.""" + + def __init__(self, bmp280: Adafruit_BMP280_I2C, name: str): + """Initialize the entity.""" + super().__init__( + bmp280, f"{name} Temperature", TEMP_CELSIUS, DEVICE_CLASS_TEMPERATURE + ) + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Fetch new state data for the sensor.""" + try: + self._state = round(self._bmp280.temperature, 1) + if self._errored: + _LOGGER.warning("Communication restored with temperature sensor") + self._errored = False + except OSError: + # this is thrown when a working sensor is unplugged between two updates + _LOGGER.warning( + "Unable to read temperature data due to a communication problem" + ) + self._errored = True + + +class Bmp280PressureSensor(Bmp280Sensor): + """Representation of a Bosch BMP280 Barometric Pressure Sensor.""" + + def __init__(self, bmp280: Adafruit_BMP280_I2C, name: str): + """Initialize the entity.""" + super().__init__( + bmp280, f"{name} Pressure", PRESSURE_HPA, DEVICE_CLASS_PRESSURE + ) + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Fetch new state data for the sensor.""" + try: + self._state = round(self._bmp280.pressure) + if self._errored: + _LOGGER.warning("Communication restored with pressure sensor") + self._errored = False + except OSError: + # this is thrown when a working sensor is unplugged between two updates + _LOGGER.warning( + "Unable to read pressure data due to a communication problem" + ) + self._errored = True diff --git a/homeassistant/components/bmw_connected_drive/__init__.py b/homeassistant/components/bmw_connected_drive/__init__.py index 10c5869674004..b8f60dafdbb88 100644 --- a/homeassistant/components/bmw_connected_drive/__init__.py +++ b/homeassistant/components/bmw_connected_drive/__init__.py @@ -1,49 +1,46 @@ """Reads vehicle status from BMW connected drive portal.""" -import datetime import logging +from bimmer_connected.account import ConnectedDriveAccount +from bimmer_connected.country_selector import get_region_from_name import voluptuous as vol -from homeassistant.const import (CONF_USERNAME, CONF_PASSWORD) +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.helpers import discovery -from homeassistant.helpers.event import track_utc_time_change import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.event import track_utc_time_change +import homeassistant.util.dt as dt_util _LOGGER = logging.getLogger(__name__) -DOMAIN = 'bmw_connected_drive' -CONF_REGION = 'region' -CONF_READ_ONLY = 'read_only' -ATTR_VIN = 'vin' +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, -}) +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) +CONFIG_SCHEMA = vol.Schema({DOMAIN: {cv.string: ACCOUNT_SCHEMA}}, extra=vol.ALLOW_EXTRA) -SERVICE_SCHEMA = vol.Schema({ - vol.Required(ATTR_VIN): cv.string, -}) +SERVICE_SCHEMA = vol.Schema({vol.Required(ATTR_VIN): cv.string}) -BMW_COMPONENTS = ['binary_sensor', 'device_tracker', 'lock', 'sensor'] +BMW_COMPONENTS = ["binary_sensor", "device_tracker", "lock", "notify", "sensor"] UPDATE_INTERVAL = 5 # in minutes -SERVICE_UPDATE_STATE = 'update_state' +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', + "light_flash": "trigger_remote_light_flash", + "sound_horn": "trigger_remote_horn", + "activate_air_conditioning": "trigger_remote_air_conditioning", } @@ -71,17 +68,15 @@ def _update_all(call) -> None: return True -def setup_account(account_config: dict, hass, name: str) \ - -> 'BMWConnectedDriveAccount': +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) + _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. @@ -97,19 +92,23 @@ def execute_service(call): 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) + 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() + now = dt_util.utcnow() track_utc_time_change( - hass, cd_account.update, + hass, + cd_account.update, minute=range(now.minute % UPDATE_INTERVAL, 60, UPDATE_INTERVAL), - second=now.second) + second=now.second, + ) return cd_account @@ -117,12 +116,10 @@ def execute_service(call): 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 - + def __init__( + self, username: str, password: str, region_str: str, name: str, read_only + ) -> None: + """Initialize account.""" region = get_region_from_name(region_str) self.read_only = read_only @@ -137,13 +134,18 @@ def update(self, *_): """ _LOGGER.debug( "Updating vehicle state for account %s, notifying %d listeners", - self.name, len(self._update_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") + except OSError as exception: + _LOGGER.error( + "Could not connect to the BMW Connected Drive portal. " + "The vehicle state could not be updated." + ) _LOGGER.exception(exception) def add_update_listener(self, listener): diff --git a/homeassistant/components/bmw_connected_drive/binary_sensor.py b/homeassistant/components/bmw_connected_drive/binary_sensor.py index 8769fcf7d6205..ee89873e8feff 100644 --- a/homeassistant/components/bmw_connected_drive/binary_sensor.py +++ b/homeassistant/components/bmw_connected_drive/binary_sensor.py @@ -1,25 +1,28 @@ """Reads vehicle status from BMW connected drive portal.""" import logging -from homeassistant.components.binary_sensor import BinarySensorDevice -from homeassistant.const import LENGTH_KILOMETERS +from bimmer_connected.state import ChargingState, LockState + +from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.const import ATTR_ATTRIBUTION, LENGTH_KILOMETERS from . import DOMAIN as BMW_DOMAIN +from .const import ATTRIBUTION _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'] + "lids": ["Doors", "opening", "mdi:car-door-lock"], + "windows": ["Windows", "opening", "mdi:car-door"], + "door_lock_state": ["Door lock state", "lock", "mdi:car-key"], + "lights_parking": ["Parking lights", "light", "mdi:car-parking-lights"], + "condition_based_services": ["Condition based services", "problem", "mdi:wrench"], + "check_control_messages": ["Control messages", "problem", "mdi:car-tire-alert"], } SENSOR_TYPES_ELEC = { - 'charging_status': ['Charging status', 'power'], - 'connection_status': ['Connection status', 'plug'] + "charging_status": ["Charging status", "power", "mdi:ev-station"], + "connection_status": ["Connection status", "plug", "mdi:car-electric"], } SENSOR_TYPES_ELEC.update(SENSOR_TYPES) @@ -28,39 +31,44 @@ 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])) + _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') + _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) + if key in vehicle.available_attributes: + device = BMWConnectedDriveSensor( + account, vehicle, key, value[0], value[1], value[2] + ) + devices.append(device) elif vehicle.has_internal_combustion_engine: - _LOGGER.debug('BMW with an 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) + if key in vehicle.available_attributes: + device = BMWConnectedDriveSensor( + account, vehicle, key, value[0], value[1], value[2] + ) + devices.append(device) add_entities(devices, True) -class BMWConnectedDriveSensor(BinarySensorDevice): +class BMWConnectedDriveSensor(BinarySensorEntity): """Representation of a BMW vehicle binary sensor.""" - def __init__(self, account, vehicle, attribute: str, sensor_name, - device_class): - """Constructor.""" + def __init__( + self, account, vehicle, attribute: str, sensor_name, device_class, icon + ): + """Initialize sensor.""" 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._name = f"{self._vehicle.name} {self._attribute}" + self._unique_id = f"{self._vehicle.vin}-{self._attribute}" self._sensor_name = sensor_name self._device_class = device_class + self._icon = icon self._state = None @property @@ -81,6 +89,11 @@ def name(self): """Return the name of the binary sensor.""" return self._name + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return self._icon + @property def device_class(self): """Return the class of the binary sensor.""" @@ -96,93 +109,88 @@ def device_state_attributes(self): """Return the state attributes of the binary sensor.""" vehicle_state = self._vehicle.state result = { - 'car': self._vehicle.name + "car": self._vehicle.name, + ATTR_ATTRIBUTION: ATTRIBUTION, } - if self._attribute == 'lids': + if self._attribute == "lids": for lid in vehicle_state.lids: result[lid.name] = lid.state.value - elif self._attribute == 'windows': + 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': + 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': + 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: + has_check_control_messages = vehicle_state.has_check_control_messages + if has_check_control_messages: 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'] + cbs_list.append(message["ccmDescriptionShort"]) + result["check_control_messages"] = cbs_list + else: + result["check_control_messages"] = "OK" + elif self._attribute == "charging_status": + result["charging_status"] = vehicle_state.charging_status.value + result["last_charging_end_result"] = vehicle_state.last_charging_end_result + elif self._attribute == "connection_status": + result["connection_status"] = vehicle_state.connection_status 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': + 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': + 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': + # device class lock: On means unlocked, Off means locked + 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] + 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': + 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': + if self._attribute == "condition_based_services": self._state = not vehicle_state.are_all_cbs_ok - if self._attribute == 'check_control_messages': + 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] + 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') + if self._attribute == "connection_status": + self._state = vehicle_state.connection_status == "CONNECTED" def _format_cbs_report(self, report): result = {} - service_type = report.service_type.lower().replace('_', ' ') - result['{} status'.format(service_type)] = report.state.value + service_type = report.service_type.lower().replace("_", " ") + result[f"{service_type} status"] = report.state.value if report.due_date is not None: - result['{} date'.format(service_type)] = \ - report.due_date.strftime('%Y-%m-%d') + result[f"{service_type} date"] = 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) + distance = round( + self.hass.config.units.length(report.due_distance, LENGTH_KILOMETERS) + ) + result[ + f"{service_type} distance" + ] = f"{distance} {self.hass.config.units.length_unit}" return result def update_callback(self): diff --git a/homeassistant/components/bmw_connected_drive/const.py b/homeassistant/components/bmw_connected_drive/const.py new file mode 100644 index 0000000000000..d1a44b5e5c930 --- /dev/null +++ b/homeassistant/components/bmw_connected_drive/const.py @@ -0,0 +1,2 @@ +"""Const file for the BMW Connected Drive integration.""" +ATTRIBUTION = "Data provided by BMW Connected Drive" diff --git a/homeassistant/components/bmw_connected_drive/device_tracker.py b/homeassistant/components/bmw_connected_drive/device_tracker.py index 229488186ae16..fa732b64e7706 100644 --- a/homeassistant/components/bmw_connected_drive/device_tracker.py +++ b/homeassistant/components/bmw_connected_drive/device_tracker.py @@ -11,8 +11,7 @@ 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])) + _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) @@ -32,21 +31,21 @@ def __init__(self, see, vehicle): def update(self) -> None: """Update the device info. - Only update the state in home assistant if tracking in + 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) + _LOGGER.debug("Tracking is disabled for vehicle %s", dev_id) return - _LOGGER.debug('Updating %s', dev_id) - attrs = { - 'vin': self.vehicle.vin, - } + _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' + 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 index 455e1427b05ba..d30f1702ae8fa 100644 --- a/homeassistant/components/bmw_connected_drive/lock.py +++ b/homeassistant/components/bmw_connected_drive/lock.py @@ -1,29 +1,32 @@ """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 bimmer_connected.state import LockState + +from homeassistant.components.lock import LockEntity +from homeassistant.const import ATTR_ATTRIBUTION, STATE_LOCKED, STATE_UNLOCKED from . import DOMAIN as BMW_DOMAIN +from .const import ATTRIBUTION +DOOR_LOCK_STATE = "door_lock_state" _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])) + _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') + device = BMWLock(account, vehicle, "lock", "BMW lock") devices.append(device) add_entities(devices, True) -class BMWLock(LockDevice): +class BMWLock(LockEntity): """Representation of a BMW vehicle lock.""" def __init__(self, account, vehicle, attribute: str, sensor_name): @@ -31,10 +34,13 @@ def __init__(self, account, vehicle, attribute: str, sensor_name): 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._name = f"{self._vehicle.name} {self._attribute}" + self._unique_id = f"{self._vehicle.vin}-{self._attribute}" self._sensor_name = sensor_name self._state = None + self.door_lock_state_available = ( + DOOR_LOCK_STATE in self._vehicle.available_attributes + ) @property def should_poll(self): @@ -58,10 +64,14 @@ def name(self): 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 + result = { + "car": self._vehicle.name, + ATTR_ATTRIBUTION: ATTRIBUTION, } + if self.door_lock_state_available: + result["door_lock_state"] = vehicle_state.door_lock_state.value + result["last_update_reason"] = vehicle_state.last_update_reason + return result @property def is_locked(self): @@ -88,17 +98,15 @@ def unlock(self, **kwargs): 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) + _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] \ + 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.""" diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json index 67bfac9105203..4521af8d36e7f 100644 --- a/homeassistant/components/bmw_connected_drive/manifest.json +++ b/homeassistant/components/bmw_connected_drive/manifest.json @@ -1,12 +1,8 @@ { "domain": "bmw_connected_drive", - "name": "Bmw connected drive", - "documentation": "https://www.home-assistant.io/components/bmw_connected_drive", - "requirements": [ - "bimmer_connected==0.5.3" - ], + "name": "BMW Connected Drive", + "documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive", + "requirements": ["bimmer_connected==0.7.5"], "dependencies": [], - "codeowners": [ - "@ChristianKuehnel" - ] + "codeowners": ["@gerard33"] } diff --git a/homeassistant/components/bmw_connected_drive/notify.py b/homeassistant/components/bmw_connected_drive/notify.py new file mode 100644 index 0000000000000..9cf2bca2df572 --- /dev/null +++ b/homeassistant/components/bmw_connected_drive/notify.py @@ -0,0 +1,74 @@ +"""Support for BMW notifications.""" +import logging + +from homeassistant.components.notify import ( + ATTR_DATA, + ATTR_TARGET, + ATTR_TITLE, + ATTR_TITLE_DEFAULT, + BaseNotificationService, +) +from homeassistant.const import ATTR_LATITUDE, ATTR_LOCATION, ATTR_LONGITUDE, ATTR_NAME + +from . import DOMAIN as BMW_DOMAIN + +ATTR_LAT = "lat" +ATTR_LOCATION_ATTRIBUTES = ["street", "city", "postal_code", "country"] +ATTR_LON = "lon" +ATTR_SUBJECT = "subject" +ATTR_TEXT = "text" + +_LOGGER = logging.getLogger(__name__) + + +def get_service(hass, config, discovery_info=None): + """Get the BMW notification service.""" + accounts = hass.data[BMW_DOMAIN] + _LOGGER.debug("Found BMW accounts: %s", ", ".join([a.name for a in accounts])) + svc = BMWNotificationService() + svc.setup(accounts) + return svc + + +class BMWNotificationService(BaseNotificationService): + """Send Notifications to BMW.""" + + def __init__(self): + """Set up the notification service.""" + self.targets = {} + + def setup(self, accounts): + """Get the BMW vehicle(s) for the account(s).""" + for account in accounts: + self.targets.update({v.name: v for v in account.account.vehicles}) + + def send_message(self, message="", **kwargs): + """Send a message or POI to the car.""" + for _vehicle in kwargs[ATTR_TARGET]: + _LOGGER.debug("Sending message to %s", _vehicle.name) + + # Extract params from data dict + title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) + data = kwargs.get(ATTR_DATA) + + # Check if message is a POI + if data is not None and ATTR_LOCATION in data: + location_dict = { + ATTR_LAT: data[ATTR_LOCATION][ATTR_LATITUDE], + ATTR_LON: data[ATTR_LOCATION][ATTR_LONGITUDE], + ATTR_NAME: message, + } + # Update dictionary with additional attributes if available + location_dict.update( + { + k: v + for k, v in data[ATTR_LOCATION].items() + if k in ATTR_LOCATION_ATTRIBUTES + } + ) + + _vehicle.remote_services.trigger_send_poi(location_dict) + else: + _vehicle.remote_services.trigger_send_message( + {ATTR_TEXT: message, ATTR_SUBJECT: title} + ) diff --git a/homeassistant/components/bmw_connected_drive/sensor.py b/homeassistant/components/bmw_connected_drive/sensor.py index 4d8b7adde1b5b..d7eec8b9479aa 100644 --- a/homeassistant/components/bmw_connected_drive/sensor.py +++ b/homeassistant/components/bmw_connected_drive/sensor.py @@ -1,36 +1,50 @@ """Support for reading vehicle status from BMW connected drive portal.""" import logging +from bimmer_connected.state import ChargingState + from homeassistant.const import ( - CONF_UNIT_SYSTEM_IMPERIAL, LENGTH_KILOMETERS, LENGTH_MILES, VOLUME_GALLONS, - VOLUME_LITERS) + ATTR_ATTRIBUTION, + CONF_UNIT_SYSTEM_IMPERIAL, + LENGTH_KILOMETERS, + LENGTH_MILES, + TIME_HOURS, + UNIT_PERCENTAGE, + 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 +from .const import ATTRIBUTION _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], + "mileage": ["mdi:speedometer", LENGTH_KILOMETERS], + "remaining_range_total": ["mdi:map-marker-distance", LENGTH_KILOMETERS], + "remaining_range_electric": ["mdi:map-marker-distance", LENGTH_KILOMETERS], + "remaining_range_fuel": ["mdi:map-marker-distance", LENGTH_KILOMETERS], + "max_range_electric": ["mdi:map-marker-distance", LENGTH_KILOMETERS], + "remaining_fuel": ["mdi:gas-station", VOLUME_LITERS], + "charging_time_remaining": ["mdi:update", TIME_HOURS], + "charging_status": ["mdi:battery-charging", None], + # No icon as this is dealt with directly as a special case in icon() + "charging_level_hv": [None, UNIT_PERCENTAGE], } 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], + "mileage": ["mdi:speedometer", LENGTH_MILES], + "remaining_range_total": ["mdi:map-marker-distance", LENGTH_MILES], + "remaining_range_electric": ["mdi:map-marker-distance", LENGTH_MILES], + "remaining_range_fuel": ["mdi:map-marker-distance", LENGTH_MILES], + "max_range_electric": ["mdi:map-marker-distance", LENGTH_MILES], + "remaining_fuel": ["mdi:gas-station", VOLUME_GALLONS], + "charging_time_remaining": ["mdi:update", TIME_HOURS], + "charging_status": ["mdi:battery-charging", None], + # No icon as this is dealt with directly as a special case in icon() + "charging_level_hv": [None, UNIT_PERCENTAGE], } @@ -42,18 +56,16 @@ def setup_platform(hass, config, add_entities, discovery_info=None): attribute_info = ATTR_TO_HA_METRIC accounts = hass.data[BMW_DOMAIN] - _LOGGER.debug('Found BMW accounts: %s', - ', '.join([a.name for a in accounts])) + _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) + if attribute_name in vehicle.available_attributes: + device = BMWConnectedDriveSensor( + account, vehicle, attribute_name, attribute_info + ) + devices.append(device) add_entities(devices, True) @@ -61,13 +73,13 @@ class BMWConnectedDriveSensor(Entity): """Representation of a BMW vehicle sensor.""" def __init__(self, account, vehicle, attribute: str, attribute_info): - """Constructor.""" + """Initialize BMW vehicle sensor.""" 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._name = f"{self._vehicle.name} {self._attribute}" + self._unique_id = f"{self._vehicle.vin}-{self._attribute}" self._attribute_info = attribute_info @property @@ -91,15 +103,13 @@ def name(self) -> str: @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] + charging_state = vehicle_state.charging_status in [ChargingState.CHARGING] - if self._attribute == 'charging_level_hv': + if self._attribute == "charging_level_hv": return icon_for_battery_level( - battery_level=vehicle_state.charging_level_hv, - charging=charging_state) + battery_level=vehicle_state.charging_level_hv, charging=charging_state + ) icon, _ = self._attribute_info.get(self._attribute, [None, None]) return icon @@ -122,24 +132,23 @@ def unit_of_measurement(self) -> str: def device_state_attributes(self): """Return the state attributes of the sensor.""" return { - 'car': self._vehicle.name + "car": self._vehicle.name, + ATTR_ATTRIBUTION: ATTRIBUTION, } def update(self) -> None: """Read new state data from the library.""" - _LOGGER.debug('Updating %s', self._vehicle.name) + _LOGGER.debug("Updating %s", self._vehicle.name) vehicle_state = self._vehicle.state - if self._attribute == 'charging_status': + 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) + 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) + value_converted = self.hass.config.units.length(value, LENGTH_KILOMETERS) self._state = round(value_converted) else: self._state = getattr(vehicle_state, self._attribute) diff --git a/homeassistant/components/bom/camera.py b/homeassistant/components/bom/camera.py index 87ffd4ab791b9..3bbd9e391645e 100644 --- a/homeassistant/components/bom/camera.py +++ b/homeassistant/components/bom/camera.py @@ -1,26 +1,73 @@ """Provide animated GIF loops of BOM radar imagery.""" +from bomradarloop import BOMRadarLoop 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' +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', + "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", ] @@ -28,33 +75,39 @@ 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)) + f"Specify '{CONF_ID}', '{CONF_DELTA}' and '{CONF_FRAMES}' when '{CONF_LOCATION}' is unspecified" + ) 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) +LOCATIONS_MSG = f"Set '{CONF_LOCATION}' to one of: {', '.join(sorted(LOCATIONS))}" +XOR_MSG = f"Specify exactly one of '{CONF_ID}' or '{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) + 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)] + location = config.get(CONF_LOCATION) or f"ID {config.get(CONF_ID)}" + name = config.get(CONF_NAME) or f"BOM Radar Loop - {location}" + args = [ + config.get(x) + for x in (CONF_LOCATION, CONF_ID, CONF_DELTA, CONF_FRAMES, CONF_OUTFILE) + ] add_entities([BOMRadarCam(name, *args)]) @@ -63,7 +116,7 @@ class BOMRadarCam(Camera): 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) diff --git a/homeassistant/components/bom/manifest.json b/homeassistant/components/bom/manifest.json index eb1f1d8ca9428..854b42f68d396 100644 --- a/homeassistant/components/bom/manifest.json +++ b/homeassistant/components/bom/manifest.json @@ -1,10 +1,7 @@ { "domain": "bom", - "name": "Bom", - "documentation": "https://www.home-assistant.io/components/bom", - "requirements": [ - "bomradarloop==0.1.3" - ], - "dependencies": [], - "codeowners": [] + "name": "Australian Bureau of Meteorology (BOM)", + "documentation": "https://www.home-assistant.io/integrations/bom", + "requirements": ["bomradarloop==0.1.4"], + "codeowners": ["@maddenp"] } diff --git a/homeassistant/components/bom/sensor.py b/homeassistant/components/bom/sensor.py index 4c96315ec1f79..59ee8027180ce 100644 --- a/homeassistant/components/bom/sensor.py +++ b/homeassistant/components/bom/sensor.py @@ -12,66 +12,75 @@ 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) + ATTR_ATTRIBUTION, + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_MONITORED_CONDITIONS, + CONF_NAME, + LENGTH_KILOMETERS, + LENGTH_METERS, + SPEED_KILOMETERS_PER_HOUR, + TEMP_CELSIUS, + UNIT_PERCENTAGE, +) +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 -_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' +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' +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'] + "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", SPEED_KILOMETERS_PER_HOUR], + "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", UNIT_PERCENTAGE], + "sea_state": ["Sea State", None], + "swell_dir_worded": ["Swell Direction", None], + "swell_height": ["Swell Height", LENGTH_METERS], + "swell_period": ["Swell Period", None], + "vis_km": [f"Visability {LENGTH_KILOMETERS}", LENGTH_KILOMETERS], + "weather": ["Weather", None], + "wind_dir": ["Wind Direction", None], + "wind_spd_kmh": ["Wind Speed kmh", SPEED_KILOMETERS_PER_HOUR], + "wind_spd_kt": ["Wind Speed kt", "kt"], } @@ -79,20 +88,23 @@ 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') + 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)]), -}) +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): @@ -103,14 +115,19 @@ def setup_platform(hass, config, add_entities, discovery_info=None): 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) + "Using configuration %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) + station = f"{zone_id}.{wmo_id}" else: station = closest_station( - config.get(CONF_LATITUDE), config.get(CONF_LONGITUDE), - hass.config.config_dir) + 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 @@ -123,8 +140,12 @@ def setup_platform(hass, config, add_entities, discovery_info=None): _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]]) + add_entities( + [ + BOMCurrentSensor(bom_data, variable, config.get(CONF_NAME)) + for variable in config[CONF_MONITORED_CONDITIONS] + ] + ) class BOMCurrentSensor(Entity): @@ -140,10 +161,9 @@ def __init__(self, bom_data, condition, stationname): def name(self): """Return the name of the sensor.""" if self.stationname is None: - return 'BOM {}'.format(SENSOR_TYPES[self._condition][0]) + return f"BOM {SENSOR_TYPES[self._condition][0]}" - return 'BOM {} {}'.format( - self.stationname, SENSOR_TYPES[self._condition][0]) + return f"BOM {self.stationname} {SENSOR_TYPES[self._condition][0]}" @property def state(self): @@ -157,9 +177,9 @@ def device_state_attributes(self): 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'], + 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 @@ -179,13 +199,16 @@ class BOMCurrentData: def __init__(self, station_id): """Initialize the data object.""" - self._zone_id, self._wmo_id = station_id.split('.') + 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) + url = ( + f"http://www.bom.gov.au/fwo/{self._zone_id}" + f"/{self._zone_id}.{self._wmo_id}.json" + ) _LOGGER.debug("BOM URL: %s", url) return url @@ -208,7 +231,7 @@ def get_reading(self, condition): 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) + return next((x for x in condition_readings if x != "-"), None) def should_update(self): """Determine whether an update should occur. @@ -224,7 +247,7 @@ def should_update(self): # Never updated before, therefore an update should occur. return True - now = datetime.datetime.now() + now = dt_util.utcnow() update_due_at = self.last_updated + datetime.timedelta(minutes=35) return now > update_due_at @@ -235,18 +258,23 @@ def update(self): _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) + (dt_util.utcnow() - self.last_updated), + dt_util.utcnow(), + self.last_updated, + ) return try: result = requests.get(self._build_url(), timeout=10).json() - self._data = result['observations']['data'] + 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') + self.last_updated = dt_util.as_utc( + datetime.datetime.strptime( + str(self._data[0]["local_date_time_full"]), "%Y%m%d%H%M%S" + ) + ) return except ValueError as err: @@ -259,37 +287,38 @@ 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. + caching version to minimize latency and hit-count. """ latlon = {} with io.BytesIO() as file_obj: - with ftplib.FTP('ftp.bom.gov.au') as ftp: + 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) + 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: + 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 != '..': + 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) + pattern = ( + r'' + ) + for state in ("nsw", "vic", "qld", "wa", "tas", "nt"): + url = f"http://www.bom.gov.au/{state}/observations/{state}all.shtml" 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)} + return {f"{zones[k]}.{k}": latlon[k] for k in set(latlon) & set(zones)} def bom_stations(cache_dir): @@ -298,13 +327,13 @@ def bom_stations(cache_dir): 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') + 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: + 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: + with gzip.open(cache_file, "rt") as cache: return {k: tuple(v) for k, v in json.load(cache).items()} diff --git a/homeassistant/components/bom/weather.py b/homeassistant/components/bom/weather.py index 2444192d87d22..94b9960c851bc 100644 --- a/homeassistant/components/bom/weather.py +++ b/homeassistant/components/bom/weather.py @@ -4,28 +4,24 @@ 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.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) +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, -}) +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) + 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 @@ -44,7 +40,7 @@ class BOMWeather(WeatherEntity): 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') + self.stationname = stationname or self.bom_data.latest_data.get("name") def update(self): """Update current conditions.""" @@ -53,19 +49,19 @@ def update(self): @property def name(self): """Return the name of the sensor.""" - return 'BOM {}'.format(self.stationname or '(unknown station)') + return f"BOM {self.stationname or '(unknown station)'}" @property def condition(self): """Return the current condition.""" - return self.bom_data.get_reading('weather') + 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') + return self.bom_data.get_reading("air_temp") @property def temperature_unit(self): @@ -75,27 +71,41 @@ def temperature_unit(self): @property def pressure(self): """Return the mean sea-level pressure.""" - return self.bom_data.get_reading('press_msl') + return self.bom_data.get_reading("press_msl") @property def humidity(self): """Return the relative humidity.""" - return self.bom_data.get_reading('rel_hum') + 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') + 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'] + 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')) + return wind.get(self.bom_data.get_reading("wind_dir")) @property def attribution(self): diff --git a/homeassistant/components/braviatv/__init__.py b/homeassistant/components/braviatv/__init__.py index 47c6f4cf24d0d..46fd8675358df 100644 --- a/homeassistant/components/braviatv/__init__.py +++ b/homeassistant/components/braviatv/__init__.py @@ -1 +1,60 @@ -"""The braviatv component.""" +"""The Bravia TV component.""" +import asyncio + +from bravia_tv import BraviaRC + +from homeassistant.const import CONF_HOST, CONF_MAC + +from .const import BRAVIARC, DOMAIN, UNDO_UPDATE_LISTENER + +PLATFORMS = ["media_player"] + + +async def async_setup(hass, config): + """Set up the Bravia TV component.""" + return True + + +async def async_setup_entry(hass, config_entry): + """Set up a config entry.""" + host = config_entry.data[CONF_HOST] + mac = config_entry.data[CONF_MAC] + + undo_listener = config_entry.add_update_listener(update_listener) + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][config_entry.entry_id] = { + BRAVIARC: BraviaRC(host, mac), + UNDO_UPDATE_LISTENER: undo_listener, + } + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(config_entry, component) + ) + + return True + + +async def async_unload_entry(hass, config_entry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(config_entry, component) + for component in PLATFORMS + ] + ) + ) + + hass.data[DOMAIN][config_entry.entry_id][UNDO_UPDATE_LISTENER]() + + if unload_ok: + hass.data[DOMAIN].pop(config_entry.entry_id) + + return unload_ok + + +async def update_listener(hass, config_entry): + """Handle options update.""" + await hass.config_entries.async_reload(config_entry.entry_id) diff --git a/homeassistant/components/braviatv/config_flow.py b/homeassistant/components/braviatv/config_flow.py new file mode 100644 index 0000000000000..660e2e83ea1f2 --- /dev/null +++ b/homeassistant/components/braviatv/config_flow.py @@ -0,0 +1,190 @@ +"""Adds config flow for Bravia TV integration.""" +import ipaddress +import logging +import re + +from bravia_tv import BraviaRC +import voluptuous as vol + +from homeassistant import config_entries, exceptions +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PIN +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv + +from .const import ( # pylint:disable=unused-import + ATTR_CID, + ATTR_MAC, + ATTR_MODEL, + BRAVIARC, + CLIENTID_PREFIX, + CONF_IGNORED_SOURCES, + DOMAIN, + NICKNAME, +) + +_LOGGER = logging.getLogger(__name__) + + +def host_valid(host): + """Return True if hostname or IP address is valid.""" + try: + if ipaddress.ip_address(host).version == (4 or 6): + return True + except ValueError: + disallowed = re.compile(r"[^a-zA-Z\d\-]") + return all(x and not disallowed.search(x) for x in host.split(".")) + + +class BraviaTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for BraviaTV integration.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + def __init__(self): + """Initialize.""" + self.braviarc = None + self.host = None + self.title = None + self.mac = None + + async def init_device(self, pin): + """Initialize Bravia TV device.""" + await self.hass.async_add_executor_job( + self.braviarc.connect, pin, CLIENTID_PREFIX, NICKNAME, + ) + + if not self.braviarc.is_connected(): + raise CannotConnect() + + system_info = await self.hass.async_add_executor_job( + self.braviarc.get_system_info + ) + if not system_info: + raise ModelNotSupported() + + await self.async_set_unique_id(system_info[ATTR_CID].lower()) + self._abort_if_unique_id_configured() + + self.title = system_info[ATTR_MODEL] + self.mac = system_info[ATTR_MAC] + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Bravia TV options callback.""" + return BraviaTVOptionsFlowHandler(config_entry) + + async def async_step_import(self, user_input=None): + """Handle configuration by yaml file.""" + self.host = user_input[CONF_HOST] + self.braviarc = BraviaRC(self.host) + + try: + await self.init_device(user_input[CONF_PIN]) + except CannotConnect: + _LOGGER.error("Import aborted, cannot connect to %s", self.host) + return self.async_abort(reason="cannot_connect") + except ModelNotSupported: + _LOGGER.error("Import aborted, your TV is not supported") + return self.async_abort(reason="unsupported_model") + + user_input[CONF_MAC] = self.mac + + return self.async_create_entry(title=self.title, data=user_input) + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + + if user_input is not None: + if host_valid(user_input[CONF_HOST]): + self.host = user_input[CONF_HOST] + self.braviarc = BraviaRC(self.host) + + return await self.async_step_authorize() + + errors[CONF_HOST] = "invalid_host" + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({vol.Required(CONF_HOST, default=""): str}), + errors=errors, + ) + + async def async_step_authorize(self, user_input=None): + """Get PIN from the Bravia TV device.""" + errors = {} + + if user_input is not None: + try: + await self.init_device(user_input[CONF_PIN]) + except CannotConnect: + errors["base"] = "cannot_connect" + except ModelNotSupported: + errors["base"] = "unsupported_model" + else: + user_input[CONF_HOST] = self.host + user_input[CONF_MAC] = self.mac + return self.async_create_entry(title=self.title, data=user_input) + + # Connecting with th PIN "0000" to start the pairing process on the TV. + await self.hass.async_add_executor_job( + self.braviarc.connect, "0000", CLIENTID_PREFIX, NICKNAME, + ) + + return self.async_show_form( + step_id="authorize", + data_schema=vol.Schema({vol.Required(CONF_PIN, default=""): str}), + errors=errors, + ) + + +class BraviaTVOptionsFlowHandler(config_entries.OptionsFlow): + """Config flow options for Bravia TV.""" + + def __init__(self, config_entry): + """Initialize Bravia TV options flow.""" + self.braviarc = None + self.config_entry = config_entry + self.pin = config_entry.data[CONF_PIN] + self.ignored_sources = config_entry.options.get(CONF_IGNORED_SOURCES) + self.source_list = [] + + async def async_step_init(self, user_input=None): + """Manage the options.""" + self.braviarc = self.hass.data[DOMAIN][self.config_entry.entry_id][BRAVIARC] + if not self.braviarc.is_connected(): + await self.hass.async_add_executor_job( + self.braviarc.connect, self.pin, CLIENTID_PREFIX, NICKNAME, + ) + + content_mapping = await self.hass.async_add_executor_job( + self.braviarc.load_source_list + ) + self.source_list = [*content_mapping] + return await self.async_step_user() + + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Optional( + CONF_IGNORED_SOURCES, default=self.ignored_sources + ): cv.multi_select(self.source_list) + } + ), + ) + + +class CannotConnect(exceptions.HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class ModelNotSupported(exceptions.HomeAssistantError): + """Error to indicate not supported model.""" diff --git a/homeassistant/components/braviatv/const.py b/homeassistant/components/braviatv/const.py new file mode 100644 index 0000000000000..a5d7a88d4c3e4 --- /dev/null +++ b/homeassistant/components/braviatv/const.py @@ -0,0 +1,15 @@ +"""Constants for Bravia TV integration.""" +ATTR_CID = "cid" +ATTR_MAC = "macAddr" +ATTR_MANUFACTURER = "Sony" +ATTR_MODEL = "model" + +CONF_IGNORED_SOURCES = "ignored_sources" + +BRAVIARC = "braviarc" +BRAVIA_CONFIG_FILE = "bravia.conf" +CLIENTID_PREFIX = "HomeAssistant" +DEFAULT_NAME = f"{ATTR_MANUFACTURER} Bravia TV" +DOMAIN = "braviatv" +NICKNAME = "Home Assistant" +UNDO_UPDATE_LISTENER = "undo_update_listener" diff --git a/homeassistant/components/braviatv/manifest.json b/homeassistant/components/braviatv/manifest.json index d8a835676b807..cde236b4ca4d8 100644 --- a/homeassistant/components/braviatv/manifest.json +++ b/homeassistant/components/braviatv/manifest.json @@ -1,14 +1,8 @@ { "domain": "braviatv", - "name": "Braviatv", - "documentation": "https://www.home-assistant.io/components/braviatv", - "requirements": [ - "braviarc-homeassistant==0.3.7.dev0" - ], - "dependencies": [ - "configurator" - ], - "codeowners": [ - "@robbiet480" - ] + "name": "Sony Bravia TV", + "documentation": "https://www.home-assistant.io/integrations/braviatv", + "requirements": ["bravia-tv==1.0.2"], + "codeowners": ["@robbiet480", "@bieniu"], + "config_flow": true } diff --git a/homeassistant/components/braviatv/media_player.py b/homeassistant/components/braviatv/media_player.py index 6377561009d58..eb75542460f26 100644 --- a/homeassistant/components/braviatv/media_player.py +++ b/homeassistant/components/braviatv/media_player.py @@ -1,151 +1,130 @@ -"""Support for interface with a Sony Bravia TV.""" +"""Support for interface with a Bravia TV.""" +import asyncio import logging -import re import voluptuous as vol from homeassistant.components.media_player import ( - MediaPlayerDevice, PLATFORM_SCHEMA) + DEVICE_CLASS_TV, + PLATFORM_SCHEMA, + MediaPlayerEntity, +) 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 + SUPPORT_NEXT_TRACK, + SUPPORT_PAUSE, + SUPPORT_PLAY, + SUPPORT_PREVIOUS_TRACK, + SUPPORT_SELECT_SOURCE, + SUPPORT_STOP, + SUPPORT_TURN_OFF, + SUPPORT_TURN_ON, + SUPPORT_VOLUME_MUTE, + SUPPORT_VOLUME_SET, + SUPPORT_VOLUME_STEP, +) +from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PIN, 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 = {} +from homeassistant.util.json import load_json + +from .const import ( + ATTR_MANUFACTURER, + BRAVIA_CONFIG_FILE, + BRAVIARC, + CLIENTID_PREFIX, + CONF_IGNORED_SOURCES, + DEFAULT_NAME, + DOMAIN, + NICKNAME, +) _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: +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 + | SUPPORT_STOP +) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_HOST): 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 Bravia TV platform.""" + host = config[CONF_HOST] + + bravia_config_file_path = hass.config.path(BRAVIA_CONFIG_FILE) + bravia_config = await hass.async_add_executor_job( + load_json, bravia_config_file_path + ) + if not bravia_config: + _LOGGER.error( + "Configuration import failed, there is no bravia.conf file in the configuration folder" + ) return - pin = None - bravia_config = load_json(hass.config.path(BRAVIA_CONFIG_FILE)) while bravia_config: - # Set up a configured TV + # Import 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)]) + pin = host_config[CONF_PIN] + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_HOST: host, CONF_PIN: 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': ''}] +async def async_setup_entry(hass, config_entry, async_add_entities): + """Add BraviaTV entities from a config_entry.""" + ignored_sources = [] + pin = config_entry.data[CONF_PIN] + unique_id = config_entry.unique_id + device_info = { + "identifiers": {(DOMAIN, unique_id)}, + "name": DEFAULT_NAME, + "manufacturer": ATTR_MANUFACTURER, + "model": config_entry.title, + } + + braviarc = hass.data[DOMAIN][config_entry.entry_id][BRAVIARC] + + ignored_sources = config_entry.options.get(CONF_IGNORED_SOURCES, []) + + async_add_entities( + [ + BraviaTVDevice( + braviarc, DEFAULT_NAME, pin, unique_id, device_info, ignored_sources + ) + ] ) -class BraviaTVDevice(MediaPlayerDevice): - """Representation of a Sony Bravia TV.""" +class BraviaTVDevice(MediaPlayerEntity): + """Representation of a Bravia TV.""" - def __init__(self, host, mac, name, pin): - """Initialize the Sony Bravia device.""" - from braviarc import braviarc + def __init__(self, client, name, pin, unique_id, device_info, ignored_sources): + """Initialize the Bravia TV device.""" self._pin = pin - self._braviarc = braviarc.BraviaRC(host, mac) + self._braviarc = client self._name = name self._state = STATE_OFF self._muted = False @@ -158,91 +137,118 @@ def __init__(self, host, mac, name, pin): 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._unique_id = unique_id + self._device_info = device_info + self._ignored_sources = ignored_sources + self._state_lock = asyncio.Lock() + self._need_refresh = True - self._braviarc.connect(pin, CLIENTID_PREFIX, NICKNAME) - if self._braviarc.is_connected(): - self.update() - else: - self._state = STATE_OFF - - def update(self): + async def async_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 + if self._state_lock.locked(): + 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') + if self._state == STATE_OFF: + self._need_refresh = True + + power_status = await self.hass.async_add_executor_job( + self._braviarc.get_power_status + ) + if power_status == "active": + if self._need_refresh: + connected = await self.hass.async_add_executor_job( + self._braviarc.connect, self._pin, CLIENTID_PREFIX, NICKNAME + ) + self._need_refresh = False else: - self._state = STATE_OFF + connected = self._braviarc.is_connected() + if not connected: + return - except Exception as exception_instance: # pylint: disable=broad-except - _LOGGER.error(exception_instance) - self._state = STATE_OFF + self._state = STATE_ON + if ( + await self._async_refresh_volume() + and await self._async_refresh_channels() + ): + await self._async_refresh_playing_info() + return + 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 _get_source(self): + """Return the name of the source.""" + for key, value in self._content_mapping.items(): + if value == self._content_uri: + return key - def _refresh_volume(self): + async def _async_refresh_volume(self): """Refresh volume information.""" - volume_info = self._braviarc.get_volume_info() + volume_info = await self.hass.async_add_executor_job( + 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): + 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") + return True + return False + + async def _async_refresh_channels(self): + """Refresh source and channels list.""" if not self._source_list: - self._content_mapping = self._braviarc. \ - load_source_list() + self._content_mapping = await self.hass.async_add_executor_job( + self._braviarc.load_source_list + ) self._source_list = [] + if not self._content_mapping: + return False for key in self._content_mapping: - self._source_list.append(key) + if key not in self._ignored_sources: + self._source_list.append(key) + return True + + async def _async_refresh_playing_info(self): + """Refresh Playing information.""" + playing_info = await self.hass.async_add_executor_job( + self._braviarc.get_playing_info + ) + 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._content_uri = playing_info.get("uri") + self._source = self._get_source() + self._duration = playing_info.get("durationSec") + self._start_date_time = playing_info.get("startDateTime") + if not playing_info: + self._channel_name = "App" @property def name(self): """Return the name of the device.""" return self._name + @property + def device_class(self): + """Set the device class to TV.""" + return DEVICE_CLASS_TV + + @property + def unique_id(self): + """Return a unique_id for this entity.""" + return self._unique_id + + @property + def device_info(self): + """Return the device info.""" + return self._device_info + @property def state(self): """Return the state of the device.""" @@ -282,7 +288,7 @@ def media_title(self): 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_value = f"{return_value}: {self._program_name}" return return_value @property @@ -299,13 +305,15 @@ def set_volume_level(self, volume): """Set volume level, range 0..1.""" self._braviarc.set_volume_level(volume) - def turn_on(self): + async def async_turn_on(self): """Turn the media player on.""" - self._braviarc.turn_on() + async with self._state_lock: + await self.hass.async_add_executor_job(self._braviarc.turn_on) - def turn_off(self): + async def async_turn_off(self): """Turn off media player.""" - self._braviarc.turn_off() + async with self._state_lock: + await self.hass.async_add_executor_job(self._braviarc.turn_off) def volume_up(self): """Volume up the media player.""" @@ -342,6 +350,11 @@ def media_pause(self): self._playing = False self._braviarc.media_pause() + def media_stop(self): + """Send media stop command to media player.""" + self._playing = False + self._braviarc.media_stop() + def media_next_track(self): """Send next track command.""" self._braviarc.media_next_track() diff --git a/homeassistant/components/braviatv/strings.json b/homeassistant/components/braviatv/strings.json new file mode 100644 index 0000000000000..1e434cd118a89 --- /dev/null +++ b/homeassistant/components/braviatv/strings.json @@ -0,0 +1,30 @@ +{ + "config": { + "step": { + "user": { + "title": "Sony Bravia TV", + "description": "Set up Sony Bravia TV integration. If you have problems with configuration go to: https://www.home-assistant.io/integrations/braviatv \n\nEnsure that your TV is turned on.", + "data": { "host": "TV hostname or IP address" } + }, + "authorize": { + "title": "Authorize Sony Bravia TV", + "description": "Enter the PIN code shown on the Sony Bravia TV. \n\nIf the PIN code is not shown, you have to unregister Home Assistant on your TV, go to: Settings -> Network -> Remote device settings -> Unregister remote device.", + "data": { "pin": "PIN code" } + } + }, + "error": { + "invalid_host": "Invalid hostname or IP address.", + "cannot_connect": "Failed to connect, invalid host or PIN code.", + "unsupported_model": "Your TV model is not supported." + }, + "abort": { "already_configured": "This TV is already configured." } + }, + "options": { + "step": { + "user": { + "title": "Options for Sony Bravia TV", + "data": { "ignored_sources": "List of ignored sources" } + } + } + } +} diff --git a/homeassistant/components/braviatv/translations/ca.json b/homeassistant/components/braviatv/translations/ca.json new file mode 100644 index 0000000000000..5a6d50c5c5352 --- /dev/null +++ b/homeassistant/components/braviatv/translations/ca.json @@ -0,0 +1,38 @@ +{ + "config": { + "abort": { + "already_configured": "Aquest televisor ja est\u00e0 configurat." + }, + "error": { + "cannot_connect": "No s'ha pogut connectar, amfitri\u00f3 o codi PIN inv\u00e0lids.", + "invalid_host": "Nom de l'amfitri\u00f3 o adre\u00e7a IP inv\u00e0lids.", + "unsupported_model": "Aquest model de televisor no \u00e9s compatible." + }, + "step": { + "authorize": { + "data": { + "pin": "Codi PIN" + }, + "description": "Introdueix el codi PIN que es mostra a la pantalla del televisor.\n\nSi no es mostra el codi, has d'eliminar Home Assistant del teu televisor. V\u00e9s a Configuraci\u00f3 > Xarxa > Configuraci\u00f3 de dispositiu remot > Elimina dispositiu remot.", + "title": "Autoritzaci\u00f3 del televisor Sony Bravia" + }, + "user": { + "data": { + "host": "Nom d'amfitri\u00f3 o adre\u00e7a IP del televisor" + }, + "description": "Configura la integraci\u00f3 de televisor Sony Bravia. Si tens problemes durant la configuraci\u00f3, v\u00e9s a: https://www.home-assistant.io/integrations/braviatv\n\nAssegura't que el televisor estigui engegat.", + "title": "Televisor Sony Bravia" + } + } + }, + "options": { + "step": { + "user": { + "data": { + "ignored_sources": "Llista de fonts ignorades" + }, + "title": "Opcions del televisor Sony Bravia" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/braviatv/translations/de.json b/homeassistant/components/braviatv/translations/de.json new file mode 100644 index 0000000000000..f46ff584d6cf9 --- /dev/null +++ b/homeassistant/components/braviatv/translations/de.json @@ -0,0 +1,38 @@ +{ + "config": { + "abort": { + "already_configured": "Dieser Fernseher ist bereits konfiguriert." + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen, ung\u00fcltiger Host- oder PIN-Code.", + "invalid_host": "Ung\u00fcltiger Hostname oder IP-Adresse", + "unsupported_model": "Ihr TV-Modell wird nicht unterst\u00fctzt." + }, + "step": { + "authorize": { + "data": { + "pin": "PIN Code" + }, + "description": "Geben Sie den auf dem Sony Bravia-Fernseher angezeigten PIN-Code ein. \n\nWenn der PIN-Code nicht angezeigt wird, m\u00fcssen Sie die Registrierung von Home Assistant auf Ihrem Fernseher aufheben, gehen Sie daf\u00fcr zu: Einstellungen -> Netzwerk -> Remote - Ger\u00e4teeinstellungen -> Registrierung des entfernten Ger\u00e4ts aufheben.", + "title": "Autorisieren Sie Sony Bravia TV" + }, + "user": { + "data": { + "host": "TV-Hostname oder IP-Adresse" + }, + "description": "Richten Sie die Sony Bravia TV-Integration ein. Wenn Sie Probleme mit der Konfiguration haben, gehen Sie zu: https://www.home-assistant.io/integrations/braviatv \n\n Stellen Sie sicher, dass Ihr Fernseher eingeschaltet ist.", + "title": "Sony Bravia TV" + } + } + }, + "options": { + "step": { + "user": { + "data": { + "ignored_sources": "Liste der ignorierten Quellen" + }, + "title": "Optionen f\u00fcr Sony Bravia TV" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/braviatv/translations/en.json b/homeassistant/components/braviatv/translations/en.json new file mode 100644 index 0000000000000..04ab58307398e --- /dev/null +++ b/homeassistant/components/braviatv/translations/en.json @@ -0,0 +1,38 @@ +{ + "config": { + "abort": { + "already_configured": "This TV is already configured." + }, + "error": { + "cannot_connect": "Failed to connect, invalid host or PIN code.", + "invalid_host": "Invalid hostname or IP address.", + "unsupported_model": "Your TV model is not supported." + }, + "step": { + "authorize": { + "data": { + "pin": "PIN code" + }, + "description": "Enter the PIN code shown on the Sony Bravia TV. \n\nIf the PIN code is not shown, you have to unregister Home Assistant on your TV, go to: Settings -> Network -> Remote device settings -> Unregister remote device.", + "title": "Authorize Sony Bravia TV" + }, + "user": { + "data": { + "host": "TV hostname or IP address" + }, + "description": "Set up Sony Bravia TV integration. If you have problems with configuration go to: https://www.home-assistant.io/integrations/braviatv \n\nEnsure that your TV is turned on.", + "title": "Sony Bravia TV" + } + } + }, + "options": { + "step": { + "user": { + "data": { + "ignored_sources": "List of ignored sources" + }, + "title": "Options for Sony Bravia TV" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/braviatv/translations/es-419.json b/homeassistant/components/braviatv/translations/es-419.json new file mode 100644 index 0000000000000..48457826a5250 --- /dev/null +++ b/homeassistant/components/braviatv/translations/es-419.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "Esta televisi\u00f3n ya est\u00e1 configurada." + }, + "error": { + "unsupported_model": "Su modelo de televisi\u00f3n no es compatible." + }, + "step": { + "authorize": { + "data": { + "pin": "C\u00f3digo PIN" + }, + "title": "Autorizar Sony Bravia TV" + }, + "user": { + "title": "Sony Bravia TV" + } + } + }, + "options": { + "step": { + "user": { + "data": { + "ignored_sources": "Lista de fuentes ignoradas" + }, + "title": "Opciones para Sony Bravia TV" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/braviatv/translations/es.json b/homeassistant/components/braviatv/translations/es.json new file mode 100644 index 0000000000000..fa858a7307916 --- /dev/null +++ b/homeassistant/components/braviatv/translations/es.json @@ -0,0 +1,38 @@ +{ + "config": { + "abort": { + "already_configured": "Este televisor ya est\u00e1 configurado." + }, + "error": { + "cannot_connect": "No se pudo conectar, host o c\u00f3digo PIN no v\u00e1lido.", + "invalid_host": "Nombre del host o direcci\u00f3n IP no v\u00e1lidos.", + "unsupported_model": "Tu modelo de televisor no es compatible." + }, + "step": { + "authorize": { + "data": { + "pin": "C\u00f3digo PIN" + }, + "description": "Introduce el c\u00f3digo PIN que se muestra en el televisor Sony Bravia.\n\nSi no se muestra ning\u00fan c\u00f3digo PIN, necesitas eliminar el registro de Home Assistant de tu televisor, ve a: Configuraci\u00f3n -> Red -> Configuraci\u00f3n del dispositivo remoto -> Eliminar el dispositivo remoto.", + "title": "Autorizar televisor Sony Bravia" + }, + "user": { + "data": { + "host": "Nombre host del televisor o direcci\u00f3n IP" + }, + "description": "Configura la integraci\u00f3n del televisor Sony Bravia. Si tienes problemas con la configuraci\u00f3n, ve a: https://www.home-assistant.io/integrations/braviatv\n\nAseg\u00farate de que tu televisor est\u00e1 encendido.", + "title": "Televisor Sony Bravia" + } + } + }, + "options": { + "step": { + "user": { + "data": { + "ignored_sources": "Lista de fuentes ignoradas" + }, + "title": "Opciones para el televisor Sony Bravia" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/braviatv/translations/fr.json b/homeassistant/components/braviatv/translations/fr.json new file mode 100644 index 0000000000000..787d53b90a34e --- /dev/null +++ b/homeassistant/components/braviatv/translations/fr.json @@ -0,0 +1,38 @@ +{ + "config": { + "abort": { + "already_configured": "Ce t\u00e9l\u00e9viseur est d\u00e9j\u00e0 configur\u00e9." + }, + "error": { + "cannot_connect": "\u00c9chec de connexion, h\u00f4te ou code PIN non valide.", + "invalid_host": "Nom d'h\u00f4te ou adresse IP invalide.", + "unsupported_model": "Votre mod\u00e8le de t\u00e9l\u00e9viseur n'est pas pris en charge." + }, + "step": { + "authorize": { + "data": { + "pin": "Code PIN" + }, + "description": "Saisissez le code PIN affich\u00e9 sur le t\u00e9l\u00e9viseur Sony Bravia. \n\nSi le code PIN n'est pas affich\u00e9, vous devez d\u00e9senregistrer Home Assistant de votre t\u00e9l\u00e9viseur, allez dans: Param\u00e8tres - > R\u00e9seau - > Param\u00e8tres de l'appareil distant - > Annuler l'enregistrement de l'appareil distant.", + "title": "Autoriser Sony Bravia TV" + }, + "user": { + "data": { + "host": "Nom d'h\u00f4te ou adresse IP du t\u00e9l\u00e9viseur" + }, + "description": "Configurez l'int\u00e9gration du t\u00e9l\u00e9viseur Sony Bravia. Si vous rencontrez des probl\u00e8mes de configuration, rendez-vous sur: https://www.home-assistant.io/integrations/braviatv \n\n Assurez-vous que votre t\u00e9l\u00e9viseur est allum\u00e9.", + "title": "Sony Bravia TV" + } + } + }, + "options": { + "step": { + "user": { + "data": { + "ignored_sources": "Liste des sources ignor\u00e9es" + }, + "title": "Options pour Sony Bravia TV" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/braviatv/translations/hi.json b/homeassistant/components/braviatv/translations/hi.json new file mode 100644 index 0000000000000..a7a8a15c20475 --- /dev/null +++ b/homeassistant/components/braviatv/translations/hi.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "authorize": { + "description": "\u0938\u094b\u0928\u0940 \u092c\u094d\u0930\u093e\u0935\u093f\u092f\u093e \u091f\u0940\u0935\u0940 \u092a\u0930 \u0926\u093f\u0916\u093e\u092f\u093e \u0917\u092f\u093e \u092a\u093f\u0928 \u0915\u094b\u0921 \u0921\u093e\u0932\u0947\u0902\u0964 \n\n \u092f\u0926\u093f \u092a\u093f\u0928 \u0915\u094b\u0921 \u0928\u0939\u0940\u0902 \u0926\u093f\u0916\u093e\u092f\u093e \u0917\u092f\u093e \u0939\u0948, \u0924\u094b \u0906\u092a\u0915\u094b \u0905\u092a\u0928\u0947 \u091f\u0940\u0935\u0940 \u092a\u0930 \u0939\u094b\u092e \u0905\u0938\u093f\u0938\u094d\u091f\u0947\u0902\u091f \u0915\u094b \u0905\u092a\u0902\u091c\u0940\u0915\u0943\u0924 \u0915\u0930\u0928\u093e \u0939\u094b\u0917\u093e, \u0907\u0938\u0915\u0947 \u0932\u093f\u090f \u091c\u093e\u090f\u0902: \u0938\u0947\u091f\u093f\u0902\u0917\u094d\u0938 - > \u0928\u0947\u091f\u0935\u0930\u094d\u0915 - > \u0926\u0942\u0930\u0938\u094d\u0925 \u0921\u093f\u0935\u093e\u0907\u0938 \u0938\u0947\u091f\u093f\u0902\u0917\u094d\u0938 - > \u0905\u092a\u0902\u091c\u0940\u0915\u0943\u0924 \u0930\u093f\u092e\u094b\u091f \u0921\u093f\u0935\u093e\u0907\u0938\u0964" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/braviatv/translations/it.json b/homeassistant/components/braviatv/translations/it.json new file mode 100644 index 0000000000000..c6fe7db443901 --- /dev/null +++ b/homeassistant/components/braviatv/translations/it.json @@ -0,0 +1,38 @@ +{ + "config": { + "abort": { + "already_configured": "Questo televisore \u00e8 gi\u00e0 configurato." + }, + "error": { + "cannot_connect": "Connessione non riuscita, host o codice PIN non valido.", + "invalid_host": "Nome host o indirizzo IP non valido.", + "unsupported_model": "Il tuo modello TV non \u00e8 supportato." + }, + "step": { + "authorize": { + "data": { + "pin": "Codice PIN" + }, + "description": "Immettere il codice PIN visualizzato sul Sony Bravia TV. \n\nSe il codice PIN non viene visualizzato, \u00e8 necessario annullare la registrazione di Home Assistant sul televisore, andare su: Impostazioni - > Rete - > Impostazioni dispositivo remoto - > Annulla registrazione dispositivo remoto.", + "title": "Autorizzare Sony Bravia TV" + }, + "user": { + "data": { + "host": "Nome host TV o indirizzo IP" + }, + "description": "Configurare l'integrazione TV di Sony Bravia. In caso di problemi con la configurazione visitare: https://www.home-assistant.io/integrations/braviatv\n\nAssicurarsi che il televisore sia acceso.", + "title": "Sony Bravia TV" + } + } + }, + "options": { + "step": { + "user": { + "data": { + "ignored_sources": "Elenco delle fonti ignorate" + }, + "title": "Opzioni per Sony Bravia TV" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/braviatv/translations/ko.json b/homeassistant/components/braviatv/translations/ko.json new file mode 100644 index 0000000000000..3652210f7b7c0 --- /dev/null +++ b/homeassistant/components/braviatv/translations/ko.json @@ -0,0 +1,38 @@ +{ + "config": { + "abort": { + "already_configured": "\uc774 TV \ub294 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + }, + "error": { + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. \ud638\uc2a4\ud2b8 \ub610\ub294 PIN \ucf54\ub4dc\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", + "invalid_host": "\ud638\uc2a4\ud2b8 \uc774\ub984 \ub610\ub294 IP \uc8fc\uc18c\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", + "unsupported_model": "\uc774 TV \ubaa8\ub378\uc740 \uc9c0\uc6d0\ub418\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4." + }, + "step": { + "authorize": { + "data": { + "pin": "PIN \ucf54\ub4dc" + }, + "description": "Sony Bravia TV \uc5d0 \ud45c\uc2dc\ub41c PIN \ucf54\ub4dc\ub97c \uc785\ub825\ud574\uc8fc\uc138\uc694.\n\nPIN \ucf54\ub4dc\uac00 \ud45c\uc2dc\ub418\uc9c0 \uc54a\uc73c\uba74 TV \uc5d0\uc11c Home Assistant \ub97c \ub4f1\ub85d \ud574\uc81c\ud558\uc5ec\uc57c \ud569\ub2c8\ub2e4. Settings -> Network -> Remote device settings -> Unregister remote device \ub85c \uc774\ub3d9\ud558\uc5ec \ub4f1\ub85d\uc744 \ud574\uc81c\ud574\uc8fc\uc138\uc694.", + "title": "Sony Bravia TV \uc2b9\uc778\ud558\uae30" + }, + "user": { + "data": { + "host": "TV \ud638\uc2a4\ud2b8 \uc774\ub984 \ub610\ub294 IP \uc8fc\uc18c" + }, + "description": "Sony Bravia TV \ud1b5\ud569 \uad6c\uc131\uc694\uc18c\ub97c \uc124\uc815\ud574\uc8fc\uc138\uc694. \uad6c\uc131\uc5d0 \ubb38\uc81c\uac00 \uc788\ub294 \uacbd\uc6b0 https://www.home-assistant.io/integrations/braviatv \ub97c \ucc38\uace0\ud574\uc8fc\uc138\uc694.\n\nTV \uac00 \ucf1c\uc838 \uc788\ub294\uc9c0 \ud655\uc778\ud574\uc8fc\uc138\uc694.", + "title": "Sony Bravia TV" + } + } + }, + "options": { + "step": { + "user": { + "data": { + "ignored_sources": "\ubb34\uc2dc\ub41c \uc785\ub825 \uc18c\uc2a4 \ubaa9\ub85d" + }, + "title": "Sony Bravia TV \uc635\uc158" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/braviatv/translations/lb.json b/homeassistant/components/braviatv/translations/lb.json new file mode 100644 index 0000000000000..37366eee6ddf4 --- /dev/null +++ b/homeassistant/components/braviatv/translations/lb.json @@ -0,0 +1,38 @@ +{ + "config": { + "abort": { + "already_configured": "D\u00ebse Fernseh ass scho konfigur\u00e9iert." + }, + "error": { + "cannot_connect": "Feeler beim verbannen, ong\u00ebltege Numm oder PIN code.", + "invalid_host": "Ong\u00ebltege Numm oder IP Adresse.", + "unsupported_model": "D\u00e4in TV Modell g\u00ebtt net \u00ebnnerst\u00ebtzt." + }, + "step": { + "authorize": { + "data": { + "pin": "PIN Code" + }, + "description": "G\u00ebff de PIN code an deen op der Sony Bravia TV ugewise g\u00ebtt.\n\nFalls kee PIN code ugewise g\u00ebtt muss den Home Assistant um Fernseh ofgemellt ginn, um TV: Settings -> Network -> Remote device settings -> Unregister remote device.", + "title": "Sony Bravia TV erlaaben" + }, + "user": { + "data": { + "host": "TV Host Numm oder IP Adresse" + }, + "description": "Sony Bravia TV Integratioun ariichten. Falls et Problemer mat der Konfiguratioun g\u00ebtt g\u00e9i op:\nhttps://www.home-assistant.io/integrations/braviatv\nStell s\u00e9cher dass d\u00e4in Fernseh un ass.", + "title": "Sony Bravia TV" + } + } + }, + "options": { + "step": { + "user": { + "data": { + "ignored_sources": "L\u00ebscht vun ignor\u00e9ierte Quellen" + }, + "title": "Optioune fir Sony Bravia TV" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/braviatv/translations/nl.json b/homeassistant/components/braviatv/translations/nl.json new file mode 100644 index 0000000000000..ba09fbca3a3da --- /dev/null +++ b/homeassistant/components/braviatv/translations/nl.json @@ -0,0 +1,38 @@ +{ + "config": { + "abort": { + "already_configured": "Deze tv is al geconfigureerd." + }, + "error": { + "cannot_connect": "Geen verbinding, ongeldige host of PIN-code.", + "invalid_host": "Ongeldige hostnaam of IP-adres.", + "unsupported_model": "Uw tv-model wordt niet ondersteund." + }, + "step": { + "authorize": { + "data": { + "pin": "PIN-code" + }, + "description": "Voer de pincode in die wordt weergegeven op de Sony Bravia tv. \n\nAls de pincode niet wordt weergegeven, moet u de Home Assistant op uw tv afmelden, ga naar: Instellingen -> Netwerk -> Instellingen extern apparaat -> Afmelden extern apparaat.", + "title": "Autoriseer Sony Bravia tv" + }, + "user": { + "data": { + "host": "Hostnaam of IP-adres van tv" + }, + "description": "Stel Sony Bravia TV-integratie in. Als je problemen hebt met de configuratie ga dan naar: https://www.home-assistant.io/integrations/braviatv \n\nZorg ervoor dat uw tv is ingeschakeld.", + "title": "Sony Bravia TV" + } + } + }, + "options": { + "step": { + "user": { + "data": { + "ignored_sources": "Lijst met genegeerde bronnen" + }, + "title": "Opties voor Sony Bravia TV" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/braviatv/translations/no.json b/homeassistant/components/braviatv/translations/no.json new file mode 100644 index 0000000000000..45644446a3bba --- /dev/null +++ b/homeassistant/components/braviatv/translations/no.json @@ -0,0 +1,38 @@ +{ + "config": { + "abort": { + "already_configured": "Denne TV-en er allerede konfigurert." + }, + "error": { + "cannot_connect": "Kunne ikke koble til, ugyldig vert eller PIN-kode.", + "invalid_host": "Ugyldig vertsnavn eller IP-adresse.", + "unsupported_model": "TV-modellen din st\u00f8ttes ikke." + }, + "step": { + "authorize": { + "data": { + "pin": "PIN-kode" + }, + "description": "Tast inn PIN-koden som vises p\u00e5 Sony Bravia TV. \n\n Hvis PIN-koden ikke vises, m\u00e5 du avregistrere Home Assistant p\u00e5 TV-en, g\u00e5 til: Innstillinger - > Nettverk - > Innstillinger for ekstern enhet - > Avregistrere ekstern enhet.", + "title": "Autoriser Sony Bravia TV" + }, + "user": { + "data": { + "host": "TV-vertsnavn eller IP-adresse" + }, + "description": "Konfigurer Sony Bravia TV-integrasjon. Hvis du har problemer med konfigurasjonen, g\u00e5 til: https://www.home-assistant.io/integrations/braviatv \n\n Forsikre deg om at TV-en er sl\u00e5tt p\u00e5.", + "title": "" + } + } + }, + "options": { + "step": { + "user": { + "data": { + "ignored_sources": "Liste over ignorerte kilder" + }, + "title": "Alternativer for Sony Bravia TV" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/braviatv/translations/pl.json b/homeassistant/components/braviatv/translations/pl.json new file mode 100644 index 0000000000000..cbafa3c4b4456 --- /dev/null +++ b/homeassistant/components/braviatv/translations/pl.json @@ -0,0 +1,38 @@ +{ + "config": { + "abort": { + "already_configured": "Ten telewizor jest ju\u017c skonfigurowany." + }, + "error": { + "cannot_connect": "Po\u0142\u0105czenie nieudane, nieprawid\u0142owy host lub kod PIN.", + "invalid_host": "Nieprawid\u0142owa nazwa hosta lub adres IP.", + "unsupported_model": "Ten model telewizora nie jest obs\u0142ugiwany." + }, + "step": { + "authorize": { + "data": { + "pin": "Kod PIN" + }, + "description": "Wprowad\u017a kod PIN wy\u015bwietlany na telewizorze Sony Bravia. \n\nJe\u015bli kod PIN nie jest wy\u015bwietlany, musisz wyrejestrowa\u0107 Home Assistant'a na swoim telewizorze, przejd\u017a do Ustawienia -> Sie\u0107 -> Ustawienia urz\u0105dzenia zdalnego -> Wyrejestruj urz\u0105dzenie zdalne.", + "title": "Autoryzacja Sony Bravia TV" + }, + "user": { + "data": { + "host": "Nazwa hosta lub adres IP telewizora" + }, + "description": "Konfiguracja integracji telewizora Sony Bravia. Je\u015bli masz problemy z konfiguracj\u0105, przejd\u017a do strony: https://www.home-assistant.io/integrations/braviatv\n\nUpewnij si\u0119, \u017ce telewizor jest w\u0142\u0105czony.", + "title": "Sony Bravia TV" + } + } + }, + "options": { + "step": { + "user": { + "data": { + "ignored_sources": "Lista ignorowanych \u017ar\u00f3de\u0142" + }, + "title": "Opcje dla Sony Bravia TV" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/braviatv/translations/pt.json b/homeassistant/components/braviatv/translations/pt.json new file mode 100644 index 0000000000000..d818bda11ffaf --- /dev/null +++ b/homeassistant/components/braviatv/translations/pt.json @@ -0,0 +1,37 @@ +{ + "config": { + "abort": { + "already_configured": "Esta TV j\u00e1 est\u00e1 configurada." + }, + "error": { + "cannot_connect": "Falha na conex\u00e3o, nome de servidor inv\u00e1lido ou c\u00f3digo PIN.", + "invalid_host": "Nome de servidor ou endere\u00e7o IP inv\u00e1lido.", + "unsupported_model": "O seu modelo de TV n\u00e3o \u00e9 suportado." + }, + "step": { + "authorize": { + "data": { + "pin": "C\u00f3digo PIN" + }, + "description": "Digite o c\u00f3digo PIN mostrado na TV Sony Bravia. \n\nSe o c\u00f3digo PIN n\u00e3o for exibido, \u00e9 necess\u00e1rio cancelar o registro do Home Assistant na TV, v\u00e1 para: Configura\u00e7\u00f5es -> Rede -> Configura\u00e7\u00f5es do dispositivo remoto -> Cancelar registro do dispositivo remoto.", + "title": "Autorizar TV Sony Bravia" + }, + "user": { + "data": { + "host": "Nome do host da TV ou endere\u00e7o IP" + }, + "title": "TV Sony Bravia" + } + } + }, + "options": { + "step": { + "user": { + "data": { + "ignored_sources": "Lista de fontes ignoradas" + }, + "title": "Op\u00e7\u00f5es para a TV Sony Bravia" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/braviatv/translations/ru.json b/homeassistant/components/braviatv/translations/ru.json new file mode 100644 index 0000000000000..aa07f4d8fbceb --- /dev/null +++ b/homeassistant/components/braviatv/translations/ru.json @@ -0,0 +1,38 @@ +{ + "config": { + "abort": { + "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u044d\u0442\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430." + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f, \u043d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u0445\u043e\u0441\u0442 \u0438\u043b\u0438 PIN-\u043a\u043e\u0434.", + "invalid_host": "\u041d\u0435\u0432\u0435\u0440\u043d\u043e\u0435 \u0434\u043e\u043c\u0435\u043d\u043d\u043e\u0435 \u0438\u043c\u044f \u0438\u043b\u0438 IP-\u0430\u0434\u0440\u0435\u0441.", + "unsupported_model": "\u042d\u0442\u0430 \u043c\u043e\u0434\u0435\u043b\u044c \u0442\u0435\u043b\u0435\u0432\u0438\u0437\u043e\u0440\u0430 \u043d\u0435 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442\u0441\u044f." + }, + "step": { + "authorize": { + "data": { + "pin": "PIN-\u043a\u043e\u0434" + }, + "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 PIN-\u043a\u043e\u0434, \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0430\u044e\u0449\u0438\u0439\u0441\u044f \u043d\u0430 \u044d\u043a\u0440\u0430\u043d\u0435 \u0442\u0435\u043b\u0435\u0432\u0438\u0437\u043e\u0440\u0430 Sony Bravia. \n\n\u0415\u0441\u043b\u0438 \u0412\u044b \u043d\u0435 \u0432\u0438\u0434\u0438\u0442\u0435 PIN-\u043a\u043e\u0434, \u043d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e \u043e\u0442\u043c\u0435\u043d\u0438\u0442\u044c \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u044e Home Assistant \u043d\u0430 \u0442\u0435\u043b\u0435\u0432\u0438\u0437\u043e\u0440\u0435. \u041f\u0435\u0440\u0435\u0439\u0434\u0438\u0442\u0435 \u0432 \u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 -> \u0421\u0435\u0442\u044c -> \u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0443\u0434\u0430\u043b\u0435\u043d\u043d\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 -> \u041e\u0442\u043c\u0435\u043d\u0438\u0442\u044c \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u044e \u0443\u0434\u0430\u043b\u0435\u043d\u043d\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430.", + "title": "\u0410\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u044f \u0442\u0435\u043b\u0435\u0432\u0438\u0437\u043e\u0440\u0430 Sony Bravia" + }, + "user": { + "data": { + "host": "\u0414\u043e\u043c\u0435\u043d\u043d\u043e\u0435 \u0438\u043c\u044f \u0438\u043b\u0438 IP-\u0430\u0434\u0440\u0435\u0441" + }, + "description": "\u041e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 \u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u0435\u0439 \u043f\u043e \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438:\nhttps://www.home-assistant.io/integrations/braviatv", + "title": "\u0422\u0435\u043b\u0435\u0432\u0438\u0437\u043e\u0440 Sony Bravia" + } + } + }, + "options": { + "step": { + "user": { + "data": { + "ignored_sources": "\u0421\u043f\u0438\u0441\u043e\u043a \u0438\u0433\u043d\u043e\u0440\u0438\u0440\u0443\u0435\u043c\u044b\u0445 \u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u043e\u0432" + }, + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0442\u0435\u043b\u0435\u0432\u0438\u0437\u043e\u0440\u0430 Sony Bravia" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/braviatv/translations/sl.json b/homeassistant/components/braviatv/translations/sl.json new file mode 100644 index 0000000000000..99ab83a39c6ce --- /dev/null +++ b/homeassistant/components/braviatv/translations/sl.json @@ -0,0 +1,38 @@ +{ + "config": { + "abort": { + "already_configured": "Ta televizor je \u017ee konfiguriran." + }, + "error": { + "cannot_connect": "Povezava ni bila mogo\u010de, neveljaven gostitelj ali PIN koda.", + "invalid_host": "Neveljavno ime gostitelja ali IP naslov.", + "unsupported_model": "Va\u0161 model televizorja ni podprt." + }, + "step": { + "authorize": { + "data": { + "pin": "PIN koda" + }, + "description": "Vnesite kodo PIN, ki je prikazana na Sony Bravia TVju. \n\n \u010ce koda PIN ni prikazana, morate na televizorju odjaviti Home Assistant, pojdite na: Nastavitve - > Omre\u017eje - > Nastavitve oddaljenih naprav - > Odjavite oddaljeno napravo.", + "title": "Pooblastite Sony Bravia TV" + }, + "user": { + "data": { + "host": "TV ime gostitelja ali IP naslov" + }, + "description": "Nastavite integracijo Sony Bravia TV. \u010ce imate te\u017eave s konfiguracijo, pojdite na: https://www.home-assistant.io/integrations/braviatv \n\n Prepri\u010dajte se, da je va\u0161 televizor vklopljen.", + "title": "Sony Bravia TV" + } + } + }, + "options": { + "step": { + "user": { + "data": { + "ignored_sources": "Seznam prezrtih virov" + }, + "title": "Mo\u017enosti za Sony Bravia TV" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/braviatv/translations/sv.json b/homeassistant/components/braviatv/translations/sv.json new file mode 100644 index 0000000000000..6ec160e799a08 --- /dev/null +++ b/homeassistant/components/braviatv/translations/sv.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "Den h\u00e4r TV:n \u00e4r redan konfigurerad" + }, + "error": { + "invalid_host": "Ogiltigt v\u00e4rdnamn eller IP-adress.", + "unsupported_model": "Den h\u00e4r tv modellen st\u00f6ds inte." + }, + "step": { + "authorize": { + "data": { + "pin": "Pin-kod" + }, + "title": "Auktorisera Sony Bravia TV" + }, + "user": { + "data": { + "host": "V\u00e4rdnamn eller IP-adress f\u00f6r TV" + }, + "title": "Sony Bravia TV" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/braviatv/translations/zh-Hant.json b/homeassistant/components/braviatv/translations/zh-Hant.json new file mode 100644 index 0000000000000..cf2c87f4c93af --- /dev/null +++ b/homeassistant/components/braviatv/translations/zh-Hant.json @@ -0,0 +1,38 @@ +{ + "config": { + "abort": { + "already_configured": "\u6b64\u96fb\u8996\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210\u3002" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557\uff0c\u7121\u6548\u7684\u4e3b\u6a5f\u540d\u7a31\u6216 PIN \u78bc\u3002", + "invalid_host": "\u7121\u6548\u4e3b\u6a5f\u540d\u6216 IP \u4f4d\u5740", + "unsupported_model": "\u4e0d\u652f\u63f4\u6b64\u6b3e\u96fb\u8996\u578b\u865f\u3002" + }, + "step": { + "authorize": { + "data": { + "pin": "PIN \u78bc" + }, + "description": "\u8f38\u5165 Sony Bravia \u96fb\u8996\u6240\u986f\u793a\u4e4b PIN \u78bc\u3002\n\n\u5047\u5982 PIN \u78bc\u672a\u986f\u793a\uff0c\u5fc5\u9808\u5148\u65bc\u96fb\u8996\u89e3\u9664 Home Assistant \u8a3b\u518a\uff0c\u6b65\u9a5f\u70ba\uff1a\u8a2d\u5b9a -> \u7db2\u8def -> \u9060\u7aef\u88dd\u7f6e\u8a2d\u5b9a -> \u89e3\u9664\u9060\u7aef\u88dd\u7f6e\u8a3b\u518a\u3002", + "title": "\u8a8d\u8b49 Sony Bravia \u96fb\u8996" + }, + "user": { + "data": { + "host": "\u96fb\u8996\u4e3b\u6a5f\u540d\u6216 IP \u4f4d\u5740" + }, + "description": "\u8a2d\u5b9a Sony Bravia \u96fb\u8996\u6574\u5408\u3002\u5047\u5982\u65bc\u8a2d\u5b9a\u904e\u7a0b\u4e2d\u906d\u9047\u56f0\u7136\uff0c\u8acb\u53c3\u95b1\uff1ahttps://www.home-assistant.io/integrations/braviatv \n\n\u78ba\u5b9a\u96fb\u8996\u5df2\u7d93\u958b\u555f\u3002", + "title": "Sony Bravia \u96fb\u8996" + } + } + }, + "options": { + "step": { + "user": { + "data": { + "ignored_sources": "\u5ffd\u7565\u7684\u4f86\u6e90\u5217\u8868" + }, + "title": "Sony Bravia \u96fb\u8996\u9078\u9805" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/broadlink/__init__.py b/homeassistant/components/broadlink/__init__.py index a1cc0a0caa3ce..be6aa2664912d 100644 --- a/homeassistant/components/broadlink/__init__.py +++ b/homeassistant/components/broadlink/__init__.py @@ -1,13 +1,16 @@ """The broadlink component.""" import asyncio from base64 import b64decode, b64encode +from binascii import unhexlify +from datetime import timedelta import logging +import re import socket -from datetime import timedelta import voluptuous as vol from homeassistant.const import CONF_HOST +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.util.dt import utcnow @@ -23,84 +26,108 @@ def data_packet(value): value = cv.string(value) extra = len(value) % 4 if extra > 0: - value = value + ('=' * (4 - extra)) + 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]) -}) +def hostname(value): + """Validate a hostname.""" + host = str(value).lower() + if len(host) > 253: + raise ValueError + if host[-1] == ".": + host = host[:-1] + allowed = re.compile(r"(?!-)[a-z\d-]{1,63}(? 0 and self._auth(): - self._update(retry-1) + self._update(retry - 1) def _auth(self, retry=3): try: - auth = self._device.auth() - except socket.timeout: + auth = self.api.auth() + except OSError: auth = False if not auth and retry > 0: - self._connect() - return self._auth(retry-1) + return self._auth(retry - 1) return auth diff --git a/homeassistant/components/broadlink/services.yaml b/homeassistant/components/broadlink/services.yaml index 2281cb1cc4d05..f1b39976afc18 100644 --- a/homeassistant/components/broadlink/services.yaml +++ b/homeassistant/components/broadlink/services.yaml @@ -1,9 +1,14 @@ 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.} + 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"} + 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 index d1b769e3d834a..4173fa4adc669 100644 --- a/homeassistant/components/broadlink/switch.py +++ b/homeassistant/components/broadlink/switch.py @@ -1,131 +1,174 @@ """Support for Broadlink RM devices.""" -import binascii from datetime import timedelta +from ipaddress import ip_address import logging import socket +import broadlink as blk import voluptuous as vol -from homeassistant.components.switch import ( - ENTITY_ID_FORMAT, PLATFORM_SCHEMA, SwitchDevice) +from homeassistant.components.switch import DOMAIN, PLATFORM_SCHEMA, SwitchEntity from homeassistant.const import ( - CONF_COMMAND_OFF, CONF_COMMAND_ON, CONF_FRIENDLY_NAME, CONF_HOST, CONF_MAC, - CONF_SWITCHES, CONF_TIMEOUT, CONF_TYPE) + 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.helpers.restore_state import RestoreEntity from homeassistant.util import Throttle, slugify -from . import async_setup_service, data_packet +from . import async_setup_service, data_packet, hostname, mac_address +from .const import ( + DEFAULT_NAME, + DEFAULT_PORT, + DEFAULT_RETRY, + DEFAULT_TIMEOUT, + MP1_TYPES, + RM4_TYPES, + RM_TYPES, + SP1_TYPES, + SP2_TYPES, +) _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 -}) +CONF_SLOTS = "slots" +CONF_RETRY = "retry" + +DEVICE_TYPES = RM_TYPES + RM4_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): vol.All(vol.Any(hostname, ip_address), cv.string), + vol.Required(CONF_MAC): mac_address, + vol.Optional(CONF_FRIENDLY_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_TYPE, default=DEVICE_TYPES[0]): vol.In(DEVICE_TYPES), + vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, + vol.Optional(CONF_RETRY, default=DEFAULT_RETRY): 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) + slots = config.get("slots", {}) + host = config.get(CONF_HOST) + mac_addr = config.get(CONF_MAC) 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) + model = config[CONF_TYPE] + retry_times = config.get(CONF_RETRY) + + def generate_rm_switches(switches, broadlink_device): + """Generate RM switches.""" + return [ + BroadlinkRMSwitch( + object_id, + config.get(CONF_FRIENDLY_NAME, object_id), + broadlink_device, + config.get(CONF_COMMAND_ON), + config.get(CONF_COMMAND_OFF), + retry_times, + ) + for object_id, config in switches.items() + ] - def _get_mp1_slot_name(switch_friendly_name, slot): + 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: + if not slots[f"slot_{slot}"]: + return f"{switch_friendly_name} slot {slot}" + return slots[f"slot_{slot}"] + + if model in RM_TYPES: + broadlink_device = blk.rm((host, DEFAULT_PORT), mac_addr, None) + hass.add_job(async_setup_service, hass, host, broadlink_device) + switches = generate_rm_switches(devices, broadlink_device) + elif model in RM4_TYPES: + broadlink_device = blk.rm4((host, DEFAULT_PORT), mac_addr, None) + hass.add_job(async_setup_service, hass, host, broadlink_device) + switches = generate_rm_switches(devices, broadlink_device) + elif model in SP1_TYPES: + broadlink_device = blk.sp1((host, DEFAULT_PORT), mac_addr, None) + switches = [BroadlinkSP1Switch(friendly_name, broadlink_device, retry_times)] + elif model in SP2_TYPES: + broadlink_device = blk.sp2((host, DEFAULT_PORT), mac_addr, None) + switches = [BroadlinkSP2Switch(friendly_name, broadlink_device, retry_times)] + elif model in MP1_TYPES: switches = [] - broadlink_device = broadlink.mp1((ip_addr, 80), mac_addr, None) - parent_device = BroadlinkMP1Switch(broadlink_device) + broadlink_device = blk.mp1((host, DEFAULT_PORT), mac_addr, None) + parent_device = BroadlinkMP1Switch(broadlink_device, retry_times) for i in range(1, 5): slot = BroadlinkMP1Slot( - _get_mp1_slot_name(friendly_name, i), - broadlink_device, i, parent_device) + get_mp1_slot_name(friendly_name, i), + broadlink_device, + i, + parent_device, + retry_times, + ) switches.append(slot) broadlink_device.timeout = config.get(CONF_TIMEOUT) try: broadlink_device.auth() - except socket.timeout: + except OSError: _LOGGER.error("Failed to connect to device") add_entities(switches) -class BroadlinkRMSwitch(SwitchDevice): +class BroadlinkRMSwitch(SwitchEntity, RestoreEntity): """Representation of an Broadlink switch.""" - def __init__(self, name, friendly_name, device, command_on, command_off): + def __init__( + self, name, friendly_name, device, command_on, command_off, retry_times + ): """Initialize the switch.""" - self.entity_id = ENTITY_ID_FORMAT.format(slugify(name)) + self.entity_id = f"{DOMAIN}.{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 + self._retry_times = retry_times + _LOGGER.debug("_retry_times : %s", self._retry_times) + + 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): @@ -137,6 +180,11 @@ 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.""" @@ -149,55 +197,56 @@ def is_on(self): def turn_on(self, **kwargs): """Turn the device on.""" - if self._sendpacket(self._command_on): + if self._sendpacket(self._command_on, self._retry_times): self._state = True self.schedule_update_ha_state() def turn_off(self, **kwargs): """Turn the device off.""" - if self._sendpacket(self._command_off): + if self._sendpacket(self._command_off, self._retry_times): self._state = False self.schedule_update_ha_state() - def _sendpacket(self, packet, retry=2): + def _sendpacket(self, packet, retry): """Send packet to device.""" if packet is None: _LOGGER.debug("Empty packet") return True try: self._device.send_data(packet) - except (socket.timeout, ValueError) as error: + except (ValueError, OSError) as error: if retry < 1: _LOGGER.error("Error during sending a packet: %s", error) return False - if not self._auth(): + if not self._auth(self._retry_times): return False - return self._sendpacket(packet, retry-1) + return self._sendpacket(packet, retry - 1) return True - def _auth(self, retry=2): + def _auth(self, retry): + _LOGGER.debug("_auth : retry=%s", retry) try: auth = self._device.auth() - except socket.timeout: + except OSError: auth = False if retry < 1: _LOGGER.error("Timeout during authorization") if not auth and retry > 0: - return self._auth(retry-1) + return self._auth(retry - 1) return auth class BroadlinkSP1Switch(BroadlinkRMSwitch): """Representation of an Broadlink switch.""" - def __init__(self, friendly_name, device): + def __init__(self, friendly_name, device, retry_times): """Initialize the switch.""" - super().__init__(friendly_name, friendly_name, device, None, None) + super().__init__(friendly_name, friendly_name, device, None, None, retry_times) self._command_on = 1 self._command_off = 0 self._load_power = None - def _sendpacket(self, packet, retry=2): + def _sendpacket(self, packet, retry): """Send packet to device.""" try: self._device.set_power(packet) @@ -205,9 +254,9 @@ def _sendpacket(self, packet, retry=2): if retry < 1: _LOGGER.error("Error during sending a packet: %s", error) return False - if not self._auth(): + if not self._auth(self._retry_times): return False - return self._sendpacket(packet, retry-1) + return self._sendpacket(packet, retry - 1) return True @@ -234,32 +283,35 @@ def current_power_w(self): def update(self): """Synchronize state with switch.""" - self._update() + self._update(self._retry_times) - def _update(self, retry=2): + def _update(self, retry): """Update the state of the device.""" + _LOGGER.debug("_update : retry=%s", retry) 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(): + if not self._auth(self._retry_times): return - return self._update(retry-1) + return self._update(retry - 1) if state is None and retry > 0: - return self._update(retry-1) + 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): + def __init__(self, friendly_name, device, slot, parent_device, retry_times): """Initialize the slot of switch.""" - super().__init__(friendly_name, friendly_name, device, None, None) + super().__init__(friendly_name, friendly_name, device, None, None, retry_times) self._command_on = 1 self._command_off = 0 self._slot = slot @@ -270,17 +322,19 @@ def assumed_state(self): """Return true if unable to access real state of entity.""" return False - def _sendpacket(self, packet, retry=2): + def _sendpacket(self, packet, retry): """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(): + if not self._auth(self._retry_times): return False - return self._sendpacket(packet, max(0, retry-1)) + return self._sendpacket(packet, max(0, retry - 1)) + self._is_available = True return True @property @@ -292,26 +346,33 @@ 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) + if self._state is None: + self._is_available = False + else: + self._is_available = True class BroadlinkMP1Switch: """Representation of a Broadlink switch - To fetch states of all slots.""" - def __init__(self, device): + def __init__(self, device, retry_times): """Initialize the switch.""" self._device = device self._states = None + self._retry_times = retry_times def get_outlet_status(self, slot): """Get status of outlet from cached status list.""" - return self._states['s{}'.format(slot)] + if self._states is None: + return None + return self._states[f"s{slot}"] @Throttle(TIME_BETWEEN_UPDATES) def update(self): """Fetch new state data for this device.""" - self._update() + self._update(self._retry_times) - def _update(self, retry=2): + def _update(self, retry): """Update the state of the device.""" try: states = self._device.check_power() @@ -319,19 +380,19 @@ def _update(self, retry=2): if retry < 1: _LOGGER.error("Error during updating the state: %s", error) return - if not self._auth(): + if not self._auth(self._retry_times): return - return self._update(max(0, retry-1)) + return self._update(max(0, retry - 1)) if states is None and retry > 0: - return self._update(max(0, retry-1)) + return self._update(max(0, retry - 1)) self._states = states - def _auth(self, retry=2): + def _auth(self, retry): """Authenticate the device.""" try: auth = self._device.auth() - except socket.timeout: + except OSError: auth = False if not auth and retry > 0: - return self._auth(retry-1) + return self._auth(retry - 1) return auth diff --git a/homeassistant/components/brother/__init__.py b/homeassistant/components/brother/__init__.py new file mode 100644 index 0000000000000..5daf54a568c16 --- /dev/null +++ b/homeassistant/components/brother/__init__.py @@ -0,0 +1,83 @@ +"""The Brother component.""" +import asyncio +from datetime import timedelta +import logging + +from brother import Brother, SnmpError, UnsupportedModel + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_TYPE +from homeassistant.core import Config, HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +PLATFORMS = ["sensor"] + +SCAN_INTERVAL = timedelta(seconds=30) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass: HomeAssistant, config: Config): + """Set up the Brother component.""" + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up Brother from a config entry.""" + host = entry.data[CONF_HOST] + kind = entry.data[CONF_TYPE] + + coordinator = BrotherDataUpdateCoordinator(hass, host=host, kind=kind) + await coordinator.async_refresh() + + if not coordinator.last_update_success: + raise ConfigEntryNotReady + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = coordinator + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ] + ) + ) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +class BrotherDataUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching Brother data from the printer.""" + + def __init__(self, hass, host, kind): + """Initialize.""" + self.brother = Brother(host, kind=kind) + + super().__init__( + hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL, + ) + + async def _async_update_data(self): + """Update data via library.""" + try: + await self.brother.async_update() + except (ConnectionError, SnmpError, UnsupportedModel) as error: + raise UpdateFailed(error) + return self.brother.data diff --git a/homeassistant/components/brother/config_flow.py b/homeassistant/components/brother/config_flow.py new file mode 100644 index 0000000000000..e50105e0b27ef --- /dev/null +++ b/homeassistant/components/brother/config_flow.py @@ -0,0 +1,126 @@ +"""Adds config flow for Brother Printer.""" +import ipaddress +import re + +from brother import Brother, SnmpError, UnsupportedModel +import voluptuous as vol + +from homeassistant import config_entries, exceptions +from homeassistant.const import CONF_HOST, CONF_TYPE + +from .const import DOMAIN, PRINTER_TYPES # pylint:disable=unused-import + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST, default=""): str, + vol.Optional(CONF_TYPE, default="laser"): vol.In(PRINTER_TYPES), + } +) + + +def host_valid(host): + """Return True if hostname or IP address is valid.""" + try: + if ipaddress.ip_address(host).version == (4 or 6): + return True + except ValueError: + disallowed = re.compile(r"[^a-zA-Z\d\-]") + return all(x and not disallowed.search(x) for x in host.split(".")) + + +class BrotherConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Brother Printer.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + def __init__(self): + """Initialize.""" + self.brother = None + self.host = None + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + + if user_input is not None: + try: + if not host_valid(user_input[CONF_HOST]): + raise InvalidHost() + + brother = Brother(user_input[CONF_HOST]) + await brother.async_update() + + await self.async_set_unique_id(brother.serial.lower()) + self._abort_if_unique_id_configured() + + title = f"{brother.model} {brother.serial}" + return self.async_create_entry(title=title, data=user_input) + except InvalidHost: + errors[CONF_HOST] = "wrong_host" + except ConnectionError: + errors["base"] = "connection_error" + except SnmpError: + errors["base"] = "snmp_error" + except UnsupportedModel: + return self.async_abort(reason="unsupported_model") + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + async def async_step_zeroconf(self, user_input=None): + """Handle zeroconf discovery.""" + if user_input is None: + return self.async_abort(reason="connection_error") + + if not user_input.get("name") or not user_input["name"].startswith("Brother"): + return self.async_abort(reason="not_brother_printer") + + # Hostname is format: brother.local. + self.host = user_input["hostname"].rstrip(".") + + self.brother = Brother(self.host) + try: + await self.brother.async_update() + except (ConnectionError, SnmpError, UnsupportedModel): + return self.async_abort(reason="connection_error") + + # Check if already configured + await self.async_set_unique_id(self.brother.serial.lower()) + self._abort_if_unique_id_configured() + + # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 + self.context.update( + { + "title_placeholders": { + "serial_number": self.brother.serial, + "model": self.brother.model, + } + } + ) + return await self.async_step_zeroconf_confirm() + + async def async_step_zeroconf_confirm(self, user_input=None): + """Handle a flow initiated by zeroconf.""" + if user_input is not None: + title = f"{self.brother.model} {self.brother.serial}" + # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 + return self.async_create_entry( + title=title, + data={CONF_HOST: self.host, CONF_TYPE: user_input[CONF_TYPE]}, + ) + return self.async_show_form( + step_id="zeroconf_confirm", + data_schema=vol.Schema( + {vol.Optional(CONF_TYPE, default="laser"): vol.In(PRINTER_TYPES)} + ), + description_placeholders={ + "serial_number": self.brother.serial, + "model": self.brother.model, + }, + ) + + +class InvalidHost(exceptions.HomeAssistantError): + """Error to indicate that hostname/IP address is invalid.""" diff --git a/homeassistant/components/brother/const.py b/homeassistant/components/brother/const.py new file mode 100644 index 0000000000000..d5bceaa2653d4 --- /dev/null +++ b/homeassistant/components/brother/const.py @@ -0,0 +1,170 @@ +"""Constants for Brother integration.""" +from homeassistant.const import TIME_DAYS, UNIT_PERCENTAGE + +ATTR_BELT_UNIT_REMAINING_LIFE = "belt_unit_remaining_life" +ATTR_BLACK_DRUM_COUNTER = "black_drum_counter" +ATTR_BLACK_DRUM_REMAINING_LIFE = "black_drum_remaining_life" +ATTR_BLACK_DRUM_REMAINING_PAGES = "black_drum_remaining_pages" +ATTR_BLACK_INK_REMAINING = "black_ink_remaining" +ATTR_BLACK_TONER_REMAINING = "black_toner_remaining" +ATTR_BW_COUNTER = "b/w_counter" +ATTR_COLOR_COUNTER = "color_counter" +ATTR_CYAN_DRUM_COUNTER = "cyan_drum_counter" +ATTR_CYAN_DRUM_REMAINING_LIFE = "cyan_drum_remaining_life" +ATTR_CYAN_DRUM_REMAINING_PAGES = "cyan_drum_remaining_pages" +ATTR_CYAN_INK_REMAINING = "cyan_ink_remaining" +ATTR_CYAN_TONER_REMAINING = "cyan_toner_remaining" +ATTR_DRUM_COUNTER = "drum_counter" +ATTR_DRUM_REMAINING_LIFE = "drum_remaining_life" +ATTR_DRUM_REMAINING_PAGES = "drum_remaining_pages" +ATTR_DUPLEX_COUNTER = "duplex_unit_pages_counter" +ATTR_FUSER_REMAINING_LIFE = "fuser_remaining_life" +ATTR_ICON = "icon" +ATTR_LABEL = "label" +ATTR_LASER_REMAINING_LIFE = "laser_remaining_life" +ATTR_MAGENTA_DRUM_COUNTER = "magenta_drum_counter" +ATTR_MAGENTA_DRUM_REMAINING_LIFE = "magenta_drum_remaining_life" +ATTR_MAGENTA_DRUM_REMAINING_PAGES = "magenta_drum_remaining_pages" +ATTR_MAGENTA_INK_REMAINING = "magenta_ink_remaining" +ATTR_MAGENTA_TONER_REMAINING = "magenta_toner_remaining" +ATTR_MANUFACTURER = "Brother" +ATTR_PAGE_COUNTER = "page_counter" +ATTR_PF_KIT_1_REMAINING_LIFE = "pf_kit_1_remaining_life" +ATTR_PF_KIT_MP_REMAINING_LIFE = "pf_kit_mp_remaining_life" +ATTR_STATUS = "status" +ATTR_UNIT = "unit" +ATTR_UPTIME = "uptime" +ATTR_YELLOW_DRUM_COUNTER = "yellow_drum_counter" +ATTR_YELLOW_DRUM_REMAINING_LIFE = "yellow_drum_remaining_life" +ATTR_YELLOW_DRUM_REMAINING_PAGES = "yellow_drum_remaining_pages" +ATTR_YELLOW_INK_REMAINING = "yellow_ink_remaining" +ATTR_YELLOW_TONER_REMAINING = "yellow_toner_remaining" + +DOMAIN = "brother" + +UNIT_PAGES = "p" + +PRINTER_TYPES = ["laser", "ink"] + +SENSOR_TYPES = { + ATTR_STATUS: { + ATTR_ICON: "mdi:printer", + ATTR_LABEL: ATTR_STATUS.title(), + ATTR_UNIT: None, + }, + ATTR_PAGE_COUNTER: { + ATTR_ICON: "mdi:file-document-outline", + ATTR_LABEL: ATTR_PAGE_COUNTER.replace("_", " ").title(), + ATTR_UNIT: UNIT_PAGES, + }, + ATTR_BW_COUNTER: { + ATTR_ICON: "mdi:file-document-outline", + ATTR_LABEL: ATTR_BW_COUNTER.replace("_", " ").title(), + ATTR_UNIT: UNIT_PAGES, + }, + ATTR_COLOR_COUNTER: { + ATTR_ICON: "mdi:file-document-outline", + ATTR_LABEL: ATTR_COLOR_COUNTER.replace("_", " ").title(), + ATTR_UNIT: UNIT_PAGES, + }, + ATTR_DUPLEX_COUNTER: { + ATTR_ICON: "mdi:file-document-outline", + ATTR_LABEL: ATTR_DUPLEX_COUNTER.replace("_", " ").title(), + ATTR_UNIT: UNIT_PAGES, + }, + ATTR_DRUM_REMAINING_LIFE: { + ATTR_ICON: "mdi:chart-donut", + ATTR_LABEL: ATTR_DRUM_REMAINING_LIFE.replace("_", " ").title(), + ATTR_UNIT: UNIT_PERCENTAGE, + }, + ATTR_BLACK_DRUM_REMAINING_LIFE: { + ATTR_ICON: "mdi:chart-donut", + ATTR_LABEL: ATTR_BLACK_DRUM_REMAINING_LIFE.replace("_", " ").title(), + ATTR_UNIT: UNIT_PERCENTAGE, + }, + ATTR_CYAN_DRUM_REMAINING_LIFE: { + ATTR_ICON: "mdi:chart-donut", + ATTR_LABEL: ATTR_CYAN_DRUM_REMAINING_LIFE.replace("_", " ").title(), + ATTR_UNIT: UNIT_PERCENTAGE, + }, + ATTR_MAGENTA_DRUM_REMAINING_LIFE: { + ATTR_ICON: "mdi:chart-donut", + ATTR_LABEL: ATTR_MAGENTA_DRUM_REMAINING_LIFE.replace("_", " ").title(), + ATTR_UNIT: UNIT_PERCENTAGE, + }, + ATTR_YELLOW_DRUM_REMAINING_LIFE: { + ATTR_ICON: "mdi:chart-donut", + ATTR_LABEL: ATTR_YELLOW_DRUM_REMAINING_LIFE.replace("_", " ").title(), + ATTR_UNIT: UNIT_PERCENTAGE, + }, + ATTR_BELT_UNIT_REMAINING_LIFE: { + ATTR_ICON: "mdi:current-ac", + ATTR_LABEL: ATTR_BELT_UNIT_REMAINING_LIFE.replace("_", " ").title(), + ATTR_UNIT: UNIT_PERCENTAGE, + }, + ATTR_FUSER_REMAINING_LIFE: { + ATTR_ICON: "mdi:water-outline", + ATTR_LABEL: ATTR_FUSER_REMAINING_LIFE.replace("_", " ").title(), + ATTR_UNIT: UNIT_PERCENTAGE, + }, + ATTR_LASER_REMAINING_LIFE: { + ATTR_ICON: "mdi:spotlight-beam", + ATTR_LABEL: ATTR_LASER_REMAINING_LIFE.replace("_", " ").title(), + ATTR_UNIT: UNIT_PERCENTAGE, + }, + ATTR_PF_KIT_1_REMAINING_LIFE: { + ATTR_ICON: "mdi:printer-3d", + ATTR_LABEL: ATTR_PF_KIT_1_REMAINING_LIFE.replace("_", " ").title(), + ATTR_UNIT: UNIT_PERCENTAGE, + }, + ATTR_PF_KIT_MP_REMAINING_LIFE: { + ATTR_ICON: "mdi:printer-3d", + ATTR_LABEL: ATTR_PF_KIT_MP_REMAINING_LIFE.replace("_", " ").title(), + ATTR_UNIT: UNIT_PERCENTAGE, + }, + ATTR_BLACK_TONER_REMAINING: { + ATTR_ICON: "mdi:printer-3d-nozzle", + ATTR_LABEL: ATTR_BLACK_TONER_REMAINING.replace("_", " ").title(), + ATTR_UNIT: UNIT_PERCENTAGE, + }, + ATTR_CYAN_TONER_REMAINING: { + ATTR_ICON: "mdi:printer-3d-nozzle", + ATTR_LABEL: ATTR_CYAN_TONER_REMAINING.replace("_", " ").title(), + ATTR_UNIT: UNIT_PERCENTAGE, + }, + ATTR_MAGENTA_TONER_REMAINING: { + ATTR_ICON: "mdi:printer-3d-nozzle", + ATTR_LABEL: ATTR_MAGENTA_TONER_REMAINING.replace("_", " ").title(), + ATTR_UNIT: UNIT_PERCENTAGE, + }, + ATTR_YELLOW_TONER_REMAINING: { + ATTR_ICON: "mdi:printer-3d-nozzle", + ATTR_LABEL: ATTR_YELLOW_TONER_REMAINING.replace("_", " ").title(), + ATTR_UNIT: UNIT_PERCENTAGE, + }, + ATTR_BLACK_INK_REMAINING: { + ATTR_ICON: "mdi:printer-3d-nozzle", + ATTR_LABEL: ATTR_BLACK_INK_REMAINING.replace("_", " ").title(), + ATTR_UNIT: UNIT_PERCENTAGE, + }, + ATTR_CYAN_INK_REMAINING: { + ATTR_ICON: "mdi:printer-3d-nozzle", + ATTR_LABEL: ATTR_CYAN_INK_REMAINING.replace("_", " ").title(), + ATTR_UNIT: UNIT_PERCENTAGE, + }, + ATTR_MAGENTA_INK_REMAINING: { + ATTR_ICON: "mdi:printer-3d-nozzle", + ATTR_LABEL: ATTR_MAGENTA_INK_REMAINING.replace("_", " ").title(), + ATTR_UNIT: UNIT_PERCENTAGE, + }, + ATTR_YELLOW_INK_REMAINING: { + ATTR_ICON: "mdi:printer-3d-nozzle", + ATTR_LABEL: ATTR_YELLOW_INK_REMAINING.replace("_", " ").title(), + ATTR_UNIT: UNIT_PERCENTAGE, + }, + ATTR_UPTIME: { + ATTR_ICON: "mdi:timer", + ATTR_LABEL: ATTR_UPTIME.title(), + ATTR_UNIT: TIME_DAYS, + }, +} diff --git a/homeassistant/components/brother/manifest.json b/homeassistant/components/brother/manifest.json new file mode 100644 index 0000000000000..7f59aaa9c2cc3 --- /dev/null +++ b/homeassistant/components/brother/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "brother", + "name": "Brother Printer", + "documentation": "https://www.home-assistant.io/integrations/brother", + "codeowners": ["@bieniu"], + "requirements": ["brother==0.1.14"], + "zeroconf": ["_printer._tcp.local."], + "config_flow": true, + "quality_scale": "platinum" +} diff --git a/homeassistant/components/brother/sensor.py b/homeassistant/components/brother/sensor.py new file mode 100644 index 0000000000000..d4f389908b1dc --- /dev/null +++ b/homeassistant/components/brother/sensor.py @@ -0,0 +1,151 @@ +"""Support for the Brother service.""" +import logging + +from homeassistant.helpers.entity import Entity + +from .const import ( + ATTR_BLACK_DRUM_COUNTER, + ATTR_BLACK_DRUM_REMAINING_LIFE, + ATTR_BLACK_DRUM_REMAINING_PAGES, + ATTR_CYAN_DRUM_COUNTER, + ATTR_CYAN_DRUM_REMAINING_LIFE, + ATTR_CYAN_DRUM_REMAINING_PAGES, + ATTR_DRUM_COUNTER, + ATTR_DRUM_REMAINING_LIFE, + ATTR_DRUM_REMAINING_PAGES, + ATTR_ICON, + ATTR_LABEL, + ATTR_MAGENTA_DRUM_COUNTER, + ATTR_MAGENTA_DRUM_REMAINING_LIFE, + ATTR_MAGENTA_DRUM_REMAINING_PAGES, + ATTR_MANUFACTURER, + ATTR_UNIT, + ATTR_YELLOW_DRUM_COUNTER, + ATTR_YELLOW_DRUM_REMAINING_LIFE, + ATTR_YELLOW_DRUM_REMAINING_PAGES, + DOMAIN, + SENSOR_TYPES, +) + +ATTR_COUNTER = "counter" +ATTR_FIRMWARE = "firmware" +ATTR_MODEL = "model" +ATTR_REMAINING_PAGES = "remaining_pages" +ATTR_SERIAL = "serial" + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Add Brother entities from a config_entry.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id] + + sensors = [] + + device_info = { + "identifiers": {(DOMAIN, coordinator.data[ATTR_SERIAL])}, + "name": coordinator.data[ATTR_MODEL], + "manufacturer": ATTR_MANUFACTURER, + "model": coordinator.data[ATTR_MODEL], + "sw_version": coordinator.data.get(ATTR_FIRMWARE), + } + + for sensor in SENSOR_TYPES: + if sensor in coordinator.data: + sensors.append(BrotherPrinterSensor(coordinator, sensor, device_info)) + async_add_entities(sensors, False) + + +class BrotherPrinterSensor(Entity): + """Define an Brother Printer sensor.""" + + def __init__(self, coordinator, kind, device_info): + """Initialize.""" + self._name = f"{coordinator.data[ATTR_MODEL]} {SENSOR_TYPES[kind][ATTR_LABEL]}" + self._unique_id = f"{coordinator.data[ATTR_SERIAL].lower()}_{kind}" + self._device_info = device_info + self.coordinator = coordinator + self.kind = kind + self._attrs = {} + + @property + def name(self): + """Return the name.""" + return self._name + + @property + def state(self): + """Return the state.""" + return self.coordinator.data.get(self.kind) + + @property + def device_state_attributes(self): + """Return the state attributes.""" + remaining_pages = None + drum_counter = None + if self.kind == ATTR_DRUM_REMAINING_LIFE: + remaining_pages = ATTR_DRUM_REMAINING_PAGES + drum_counter = ATTR_DRUM_COUNTER + elif self.kind == ATTR_BLACK_DRUM_REMAINING_LIFE: + remaining_pages = ATTR_BLACK_DRUM_REMAINING_PAGES + drum_counter = ATTR_BLACK_DRUM_COUNTER + elif self.kind == ATTR_CYAN_DRUM_REMAINING_LIFE: + remaining_pages = ATTR_CYAN_DRUM_REMAINING_PAGES + drum_counter = ATTR_CYAN_DRUM_COUNTER + elif self.kind == ATTR_MAGENTA_DRUM_REMAINING_LIFE: + remaining_pages = ATTR_MAGENTA_DRUM_REMAINING_PAGES + drum_counter = ATTR_MAGENTA_DRUM_COUNTER + elif self.kind == ATTR_YELLOW_DRUM_REMAINING_LIFE: + remaining_pages = ATTR_YELLOW_DRUM_REMAINING_PAGES + drum_counter = ATTR_YELLOW_DRUM_COUNTER + if remaining_pages and drum_counter: + self._attrs[ATTR_REMAINING_PAGES] = self.coordinator.data.get( + remaining_pages + ) + self._attrs[ATTR_COUNTER] = self.coordinator.data.get(drum_counter) + return self._attrs + + @property + def icon(self): + """Return the icon.""" + return SENSOR_TYPES[self.kind][ATTR_ICON] + + @property + def unique_id(self): + """Return a unique_id for this entity.""" + return self._unique_id + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return SENSOR_TYPES[self.kind][ATTR_UNIT] + + @property + def available(self): + """Return True if entity is available.""" + return self.coordinator.last_update_success + + @property + def should_poll(self): + """Return the polling requirement of the entity.""" + return False + + @property + def device_info(self): + """Return the device info.""" + return self._device_info + + @property + def entity_registry_enabled_default(self): + """Return if the entity should be enabled when first added to the entity registry.""" + return True + + async def async_added_to_hass(self): + """Connect to dispatcher listening for entity data notifications.""" + self.async_on_remove( + self.coordinator.async_add_listener(self.async_write_ha_state) + ) + + async def async_update(self): + """Update Brother entity.""" + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/brother/strings.json b/homeassistant/components/brother/strings.json new file mode 100644 index 0000000000000..76e62731e53fd --- /dev/null +++ b/homeassistant/components/brother/strings.json @@ -0,0 +1,28 @@ +{ + "config": { + "flow_title": "Brother Printer: {model} {serial_number}", + "step": { + "user": { + "description": "Set up Brother printer integration. If you have problems with configuration go to: https://www.home-assistant.io/integrations/brother", + "data": { + "host": "Printer hostname or IP address", + "type": "Type of the printer" + } + }, + "zeroconf_confirm": { + "description": "Do you want to add the Brother Printer {model} with serial number `{serial_number}` to Home Assistant?", + "title": "Discovered Brother Printer", + "data": { "type": "Type of the printer" } + } + }, + "error": { + "wrong_host": "Invalid hostname or IP address.", + "connection_error": "Connection error.", + "snmp_error": "SNMP server turned off or printer not supported." + }, + "abort": { + "unsupported_model": "This printer model is not supported.", + "already_configured": "This printer is already configured." + } + } +} diff --git a/homeassistant/components/brother/translations/ca.json b/homeassistant/components/brother/translations/ca.json new file mode 100644 index 0000000000000..bf96f4c3d58e4 --- /dev/null +++ b/homeassistant/components/brother/translations/ca.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "Aquesta impressora ja est\u00e0 configurada.", + "unsupported_model": "Aquest model d'impressora no \u00e9s compatible." + }, + "error": { + "connection_error": "Error de connexi\u00f3.", + "snmp_error": "El servidor SNMP s'ha tancat o la impressora no \u00e9s compatible.", + "wrong_host": "Nom de l'amfitri\u00f3 o adre\u00e7a IP inv\u00e0lids." + }, + "flow_title": "Impressora Brother: {model} {serial_number}", + "step": { + "user": { + "data": { + "host": "Nom de l'amfitri\u00f3 o adre\u00e7a IP de la impressora", + "type": "Tipus d'impressora" + }, + "description": "Configura la integraci\u00f3 d'impressora Brother. Si tens problemes amb la configuraci\u00f3, visita: https://www.home-assistant.io/integrations/brother", + "title": "Impressora Brother" + }, + "zeroconf_confirm": { + "data": { + "type": "Tipus d'impressora" + }, + "description": "Vols afegir la impressora Brother {model} amb n\u00famero de s\u00e8rie `{serial_number}` a Home Assistant?", + "title": "Impressora Brother descoberta" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/brother/translations/cs.json b/homeassistant/components/brother/translations/cs.json new file mode 100644 index 0000000000000..716b62c6c705a --- /dev/null +++ b/homeassistant/components/brother/translations/cs.json @@ -0,0 +1,14 @@ +{ + "config": { + "flow_title": "Tisk\u00e1rna Brother: {model} {serial_number}", + "step": { + "zeroconf_confirm": { + "data": { + "type": "Typ tisk\u00e1rny" + }, + "description": "Chcete p\u0159idat tisk\u00e1rnu Brother {model} se s\u00e9riov\u00fdm \u010d\u00edslem \"{serial_number}\" do Home Assistant?", + "title": "Objeven\u00e1 tisk\u00e1rna Brother" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/brother/translations/da.json b/homeassistant/components/brother/translations/da.json new file mode 100644 index 0000000000000..6d9edc1fcad98 --- /dev/null +++ b/homeassistant/components/brother/translations/da.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "Denne printer er allerede konfigureret.", + "unsupported_model": "Denne printermodel underst\u00f8ttes ikke." + }, + "error": { + "connection_error": "Forbindelsesfejl.", + "snmp_error": "SNMP-server er sl\u00e5et fra, eller printeren underst\u00f8ttes ikke.", + "wrong_host": "Ugyldigt v\u00e6rtsnavn eller IP-adresse." + }, + "flow_title": "Brother-printer: {model} {serial_number}", + "step": { + "user": { + "data": { + "host": "Printerens v\u00e6rtsnavn eller IP-adresse", + "type": "Type af printer" + }, + "description": "Konfigurer Brother-printerintegration. Hvis du har problemer med konfiguration, kan du g\u00e5 til: https://www.home-assistant.io/integrations/brother", + "title": "Brother-printer" + }, + "zeroconf_confirm": { + "data": { + "type": "Type af printer" + }, + "description": "Vil du tilf\u00f8je Brother-printeren {model} med serienummeret `{serial_number}` til Home Assistant?", + "title": "Fandt Brother-printer" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/brother/translations/de.json b/homeassistant/components/brother/translations/de.json new file mode 100644 index 0000000000000..fb1a142039711 --- /dev/null +++ b/homeassistant/components/brother/translations/de.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "Dieser Drucker ist bereits konfiguriert", + "unsupported_model": "Dieses Druckermodell wird nicht unterst\u00fctzt." + }, + "error": { + "connection_error": "Verbindungsfehler", + "snmp_error": "SNMP-Server deaktiviert oder Drucker nicht unterst\u00fctzt.", + "wrong_host": " Ung\u00fcltiger Hostname oder IP-Adresse" + }, + "flow_title": "Brother-Drucker: {model} {serial_number}", + "step": { + "user": { + "data": { + "host": "Drucker Hostname oder IP-Adresse", + "type": "Typ des Druckers" + }, + "description": "Einrichten der Brother-Drucker-Integration. Wenn Du Probleme mit der Konfiguration hast, gehe zu: https://www.home-assistant.io/integrations/brother", + "title": "Brother Drucker" + }, + "zeroconf_confirm": { + "data": { + "type": "Typ des Druckers" + }, + "description": "M\u00f6chten Sie den Brother Drucker {model} mit der Seriennummer `{serial_number}` zum Home Assistant hinzuf\u00fcgen?", + "title": "Brother-Drucker entdeckt" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/brother/translations/en.json b/homeassistant/components/brother/translations/en.json new file mode 100644 index 0000000000000..6d45347808378 --- /dev/null +++ b/homeassistant/components/brother/translations/en.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "This printer is already configured.", + "unsupported_model": "This printer model is not supported." + }, + "error": { + "connection_error": "Connection error.", + "snmp_error": "SNMP server turned off or printer not supported.", + "wrong_host": "Invalid hostname or IP address." + }, + "flow_title": "Brother Printer: {model} {serial_number}", + "step": { + "user": { + "data": { + "host": "Printer hostname or IP address", + "type": "Type of the printer" + }, + "description": "Set up Brother printer integration. If you have problems with configuration go to: https://www.home-assistant.io/integrations/brother", + "title": "Brother Printer" + }, + "zeroconf_confirm": { + "data": { + "type": "Type of the printer" + }, + "description": "Do you want to add the Brother Printer {model} with serial number `{serial_number}` to Home Assistant?", + "title": "Discovered Brother Printer" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/brother/translations/es-419.json b/homeassistant/components/brother/translations/es-419.json new file mode 100644 index 0000000000000..0cb35449bc517 --- /dev/null +++ b/homeassistant/components/brother/translations/es-419.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "Esta impresora ya est\u00e1 configurada.", + "unsupported_model": "Este modelo de impresora no es compatible." + }, + "error": { + "connection_error": "Error de conexi\u00f3n.", + "snmp_error": "El servidor SNMP est\u00e1 apagado o la impresora no es compatible." + }, + "flow_title": "Impresora Brother: {model} {serial_number}", + "step": { + "user": { + "data": { + "type": "Tipo de impresora" + }, + "description": "Configure la integraci\u00f3n de la impresora Brother. Si tiene problemas con la configuraci\u00f3n, vaya a: https://www.home-assistant.io/integrations/brother", + "title": "Impresora Brother" + }, + "zeroconf_confirm": { + "data": { + "type": "Tipo de impresora" + }, + "description": "\u00bfDesea agregar la Impresora Brother {model} con el n\u00famero de serie `{serial_number}` a Home Assistant?", + "title": "Impresora Brother descubierta" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/brother/translations/es.json b/homeassistant/components/brother/translations/es.json new file mode 100644 index 0000000000000..ffd0bd9c7a0d2 --- /dev/null +++ b/homeassistant/components/brother/translations/es.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "Esta impresora ya est\u00e1 configurada.", + "unsupported_model": "Este modelo de impresora no es compatible." + }, + "error": { + "connection_error": "Error de conexi\u00f3n.", + "snmp_error": "El servidor SNMP est\u00e1 apagado o la impresora no es compatible.", + "wrong_host": "Nombre del host o direcci\u00f3n IP no v\u00e1lidos." + }, + "flow_title": "Impresora Brother: {model} {serial_number}", + "step": { + "user": { + "data": { + "host": "Nombre del host o direcci\u00f3n IP de la impresora", + "type": "Tipo de impresora" + }, + "description": "Configura la integraci\u00f3n de impresoras Brother. Si tienes problemas con la configuraci\u00f3n, ve a: https://www.home-assistant.io/integrations/brother", + "title": "Impresora Brother" + }, + "zeroconf_confirm": { + "data": { + "type": "Tipo de impresora" + }, + "description": "\u00bfQuieres a\u00f1adir la Impresora Brother {model} con el n\u00famero de serie `{serial_number}` a Home Assistant?", + "title": "Impresora Brother encontrada" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/brother/translations/fr.json b/homeassistant/components/brother/translations/fr.json new file mode 100644 index 0000000000000..9dba52055acaa --- /dev/null +++ b/homeassistant/components/brother/translations/fr.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "Cette imprimante est d\u00e9j\u00e0 configur\u00e9e.", + "unsupported_model": "Ce mod\u00e8le d'imprimante n'est pas pris en charge." + }, + "error": { + "connection_error": "Erreur de connexion.", + "snmp_error": "Serveur SNMP d\u00e9sactiv\u00e9 ou imprimante non prise en charge.", + "wrong_host": "Nom d'h\u00f4te ou adresse IP invalide." + }, + "flow_title": "Imprimante Brother: {model} {serial_number}", + "step": { + "user": { + "data": { + "host": "Nom d'h\u00f4te ou adresse IP de l'imprimante", + "type": "Type d'imprimante" + }, + "description": "Configurez l'int\u00e9gration de l'imprimante Brother. Si vous avez des probl\u00e8mes avec la configuration, allez \u00e0 : https://www.home-assistant.io/integrations/brother", + "title": "Imprimante Brother" + }, + "zeroconf_confirm": { + "data": { + "type": "Type d'imprimante" + }, + "description": "Voulez-vous ajouter l'imprimante Brother {model} avec le num\u00e9ro de s\u00e9rie `{serial_number}` \u00e0 Home Assistant ?", + "title": "Imprimante Brother d\u00e9couverte" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/brother/translations/hu.json b/homeassistant/components/brother/translations/hu.json new file mode 100644 index 0000000000000..77bdb4b6bf10a --- /dev/null +++ b/homeassistant/components/brother/translations/hu.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "Ez a nyomtat\u00f3 m\u00e1r konfigur\u00e1lva van.", + "unsupported_model": "Ez a nyomtat\u00f3modell nem t\u00e1mogatott." + }, + "error": { + "connection_error": "Csatlakoz\u00e1si hiba.", + "snmp_error": "Az SNMP szerver ki van kapcsolva, vagy a nyomtat\u00f3 nem t\u00e1mogatott.", + "wrong_host": "\u00c9rv\u00e9nytelen \u00e1llom\u00e1sn\u00e9v vagy IP-c\u00edm." + }, + "flow_title": "Brother nyomtat\u00f3: {model} {serial_number}", + "step": { + "user": { + "data": { + "host": "Nyomtat\u00f3 \u00e1llom\u00e1sneve vagy IP-c\u00edme", + "type": "A nyomtat\u00f3 t\u00edpusa" + }, + "description": "A Brother nyomtat\u00f3 integr\u00e1ci\u00f3j\u00e1nak be\u00e1ll\u00edt\u00e1sa. Ha probl\u00e9m\u00e1id vannak a konfigur\u00e1ci\u00f3val, l\u00e1togass el a k\u00f6vetkez\u0151 oldalra: https://www.home-assistant.io/integrations/brother", + "title": "Brother nyomtat\u00f3" + }, + "zeroconf_confirm": { + "data": { + "type": "A nyomtat\u00f3 t\u00edpusa" + }, + "description": "Hozz\u00e1 akarja adni a {model} Brother nyomtat\u00f3t, amelynek sorsz\u00e1ma: {serial_number} `, a Home Assistant-hoz?", + "title": "Felfedezett Brother nyomtat\u00f3" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/brother/translations/it.json b/homeassistant/components/brother/translations/it.json new file mode 100644 index 0000000000000..7631709d0b06d --- /dev/null +++ b/homeassistant/components/brother/translations/it.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "Questa stampante \u00e8 gi\u00e0 configurata.", + "unsupported_model": "Questo modello di stampante non \u00e8 supportato." + }, + "error": { + "connection_error": "Errore di connessione.", + "snmp_error": "Server SNMP spento o stampante non supportata.", + "wrong_host": "Nome host o indirizzo IP non valido." + }, + "flow_title": "Stampante Brother: {model} {serial_number}", + "step": { + "user": { + "data": { + "host": "Nome host o indirizzo IP della stampante", + "type": "Tipo di stampante" + }, + "description": "Configurare l'integrazione della stampante Brother. In caso di problemi con la configurazione, visitare: https://www.home-assistant.io/integrations/brother", + "title": "Stampante Brother" + }, + "zeroconf_confirm": { + "data": { + "type": "Tipo di stampante" + }, + "description": "Vuoi aggiungere la stampante Brother {model} con il numero seriale `{serial_number}` a Home Assistant?", + "title": "Trovata stampante Brother" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/brother/translations/ko.json b/homeassistant/components/brother/translations/ko.json new file mode 100644 index 0000000000000..5b79c87c175e8 --- /dev/null +++ b/homeassistant/components/brother/translations/ko.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "\uc774 \ud504\ub9b0\ud130\ub294 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", + "unsupported_model": "\uc774 \ud504\ub9b0\ud130 \ubaa8\ub378\uc740 \uc9c0\uc6d0\ub418\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4." + }, + "error": { + "connection_error": "\uc5f0\uacb0 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4.", + "snmp_error": "SNMP \uc11c\ubc84\uac00 \uaebc\uc838 \uc788\uac70\ub098 \uc9c0\uc6d0\ub418\uc9c0 \uc54a\ub294 \ud504\ub9b0\ud130\uc785\ub2c8\ub2e4.", + "wrong_host": "\ud638\uc2a4\ud2b8 \uc774\ub984 \ub610\ub294 IP \uc8fc\uc18c\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + }, + "flow_title": "\ube0c\ub77c\ub354 \ud504\ub9b0\ud130: {model} {serial_number}", + "step": { + "user": { + "data": { + "host": "\ud504\ub9b0\ud130 \ud638\uc2a4\ud2b8 \uc774\ub984 \ub610\ub294 IP \uc8fc\uc18c", + "type": "\ud504\ub9b0\ud130\uc758 \uc885\ub958" + }, + "description": "\ube0c\ub77c\ub354 \ud504\ub9b0\ud130 \ud1b5\ud569 \uad6c\uc131\uc694\uc18c\ub97c \uc124\uc815\ud569\ub2c8\ub2e4. \uad6c\uc131\uc5d0 \ubb38\uc81c\uac00\uc788\ub294 \uacbd\uc6b0 https://www.home-assistant.io/integrations/brother \ub97c \ucc38\uc870\ud574\uc8fc\uc138\uc694", + "title": "\ube0c\ub77c\ub354 \ud504\ub9b0\ud130" + }, + "zeroconf_confirm": { + "data": { + "type": "\ud504\ub9b0\ud130\uc758 \uc885\ub958" + }, + "description": "\uc2dc\ub9ac\uc5bc \ubc88\ud638 `{serial_number}` \ub85c \ube0c\ub77c\ub354 \ud504\ub9b0\ud130 {model} \uc744(\ub97c) Home Assistant \uc5d0 \ucd94\uac00\ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?", + "title": "\ubc1c\uacac\ub41c \ube0c\ub77c\ub354 \ud504\ub9b0\ud130" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/brother/translations/lb.json b/homeassistant/components/brother/translations/lb.json new file mode 100644 index 0000000000000..d8d49b0b3ac89 --- /dev/null +++ b/homeassistant/components/brother/translations/lb.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "D\u00ebse Printer ass scho konfigur\u00e9iert.", + "unsupported_model": "D\u00ebse Printer Modell g\u00ebtt net \u00ebnnerst\u00ebtzt." + }, + "error": { + "connection_error": "Feeler bei der Verbindung.", + "snmp_error": "SNMP Server ausgeschalt oder Printer net \u00ebnnerst\u00ebtzt.", + "wrong_host": "Ong\u00ebltege Numm oder IP Adresse" + }, + "flow_title": "Brother Printer: {model {serial_number}", + "step": { + "user": { + "data": { + "host": "Printer Numm oder IP Adresse", + "type": "Typ vum Printer" + }, + "description": "Brother Printer Integratioun ariichten. Am Fall vun Problemer kuckt op: https://www.home-assistant.io/integrations/brother", + "title": "Brother Printer" + }, + "zeroconf_confirm": { + "data": { + "type": "Typ vum Printer" + }, + "description": "W\u00ebllt dir den Brother Printer {model} mat der Seriennummer `{serial_number}` am Home Assistant dob\u00e4isetzen?", + "title": "Entdeckten Brother Printer" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/brother/translations/nl.json b/homeassistant/components/brother/translations/nl.json new file mode 100644 index 0000000000000..2dfef67fb18b8 --- /dev/null +++ b/homeassistant/components/brother/translations/nl.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "Deze printer is al geconfigureerd.", + "unsupported_model": "Dit printermodel wordt niet ondersteund." + }, + "error": { + "connection_error": "Verbindingsfout.", + "snmp_error": "SNMP-server uitgeschakeld of printer wordt niet ondersteund.", + "wrong_host": "Ongeldige hostnaam of IP-adres." + }, + "flow_title": "Brother Printer: {model} {serial_number}", + "step": { + "user": { + "data": { + "host": "Printerhostnaam of IP-adres", + "type": "Type printer" + }, + "description": "Zet Brother printerintegratie op. Als u problemen heeft met de configuratie ga dan naar: https://www.home-assistant.io/integrations/brother", + "title": "Brother Printer" + }, + "zeroconf_confirm": { + "data": { + "type": "Type printer" + }, + "description": "Wilt u het Brother Printer {model} met serienummer {serial_number}' toevoegen aan Home Assistant?", + "title": "Ontdekte Brother Printer" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/brother/translations/no.json b/homeassistant/components/brother/translations/no.json new file mode 100644 index 0000000000000..0235c4d1693fb --- /dev/null +++ b/homeassistant/components/brother/translations/no.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "Denne skriveren er allerede konfigurert.", + "unsupported_model": "Denne skrivermodellen er ikke st\u00f8ttet." + }, + "error": { + "connection_error": "Tilkoblingen mislyktes.", + "snmp_error": "SNMP verten er skrudd av eller printeren er ikke st\u00f8ttet.", + "wrong_host": "Ugyldig vertsnavn eller IP-adresse." + }, + "flow_title": "Brother Printer: {model} {serial_number}", + "step": { + "user": { + "data": { + "host": "Vertsnavn eller IP-adresse til skriveren", + "type": "Skriver type" + }, + "description": "Konfigurer Brother skriver integrasjonen. Hvis du har problemer med konfigurasjonen, bes\u00f8k dokumentasjonen her: https://www.home-assistant.io/integrations/brother", + "title": "Brother skriver" + }, + "zeroconf_confirm": { + "data": { + "type": "Type skriver" + }, + "description": "Vil du legge til Brother-skriveren {model} med serienummeret `{serial_number}` til Home Assistant?", + "title": "Oppdaget Brother Skriver" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/brother/translations/pl.json b/homeassistant/components/brother/translations/pl.json new file mode 100644 index 0000000000000..94f23b8b5d28c --- /dev/null +++ b/homeassistant/components/brother/translations/pl.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "Ta drukarka jest ju\u017c skonfigurowana.", + "unsupported_model": "Ten model drukarki nie jest obs\u0142ugiwany." + }, + "error": { + "connection_error": "B\u0142\u0105d po\u0142\u0105czenia.", + "snmp_error": "Serwer SNMP wy\u0142\u0105czony lub drukarka nie jest obs\u0142ugiwana.", + "wrong_host": "Niepoprawna nazwa hosta lub adres IP drukarki." + }, + "flow_title": "Drukarka Brother: {model} {serial_number}", + "step": { + "user": { + "data": { + "host": "Nazwa hosta lub adres IP drukarki", + "type": "Typ drukarki" + }, + "description": "Konfiguracja integracji drukarek Brother. Je\u015bli masz problemy z konfiguracj\u0105, przejd\u017a na stron\u0119: https://www.home-assistant.io/integrations/brother", + "title": "Drukarka Brother" + }, + "zeroconf_confirm": { + "data": { + "type": "Typ drukarki" + }, + "description": "Czy chcesz doda\u0107 drukark\u0119 Brother {model} o numerze seryjnym `{serial_number}` do Home Assistant'a?", + "title": "Wykryto drukark\u0119 Brother" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/brother/translations/pt-BR.json b/homeassistant/components/brother/translations/pt-BR.json new file mode 100644 index 0000000000000..b59501e8cfbe6 --- /dev/null +++ b/homeassistant/components/brother/translations/pt-BR.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "unsupported_model": "Este modelo de impressora n\u00e3o \u00e9 suportado." + }, + "error": { + "connection_error": "Erro de conex\u00e3o.", + "snmp_error": "Servidor SNMP desligado ou impressora n\u00e3o suportada.", + "wrong_host": "Nome de host ou endere\u00e7o IP inv\u00e1lido." + }, + "step": { + "user": { + "data": { + "host": "Nome do host ou endere\u00e7o IP da impressora", + "type": "Tipo de impressora" + }, + "description": "Configure a integra\u00e7\u00e3o da impressora Brother. Se voc\u00ea tiver problemas com a configura\u00e7\u00e3o, acesse: https://www.home-assistant.io/integrations/brother", + "title": "Impressora Brother" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/brother/translations/pt.json b/homeassistant/components/brother/translations/pt.json new file mode 100644 index 0000000000000..4f76c66c4f673 --- /dev/null +++ b/homeassistant/components/brother/translations/pt.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "type": "Tipo de impressora" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/brother/translations/ru.json b/homeassistant/components/brother/translations/ru.json new file mode 100644 index 0000000000000..66c4df1ac6a95 --- /dev/null +++ b/homeassistant/components/brother/translations/ru.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u044d\u0442\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430.", + "unsupported_model": "\u042d\u0442\u0430 \u043c\u043e\u0434\u0435\u043b\u044c \u043f\u0440\u0438\u043d\u0442\u0435\u0440\u0430 \u043d\u0435 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442\u0441\u044f." + }, + "error": { + "connection_error": "\u041e\u0448\u0438\u0431\u043a\u0430 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f.", + "snmp_error": "\u0421\u0435\u0440\u0432\u0435\u0440 SNMP \u0432\u044b\u043a\u043b\u044e\u0447\u0435\u043d \u0438\u043b\u0438 \u043f\u0440\u0438\u043d\u0442\u0435\u0440 \u043d\u0435 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442\u0441\u044f.", + "wrong_host": "\u041d\u0435\u0432\u0435\u0440\u043d\u043e\u0435 \u0434\u043e\u043c\u0435\u043d\u043d\u043e\u0435 \u0438\u043c\u044f \u0438\u043b\u0438 IP-\u0430\u0434\u0440\u0435\u0441." + }, + "flow_title": "\u041f\u0440\u0438\u043d\u0442\u0435\u0440 Brother: {model} {serial_number}", + "step": { + "user": { + "data": { + "host": "\u0414\u043e\u043c\u0435\u043d\u043d\u043e\u0435 \u0438\u043c\u044f \u0438\u043b\u0438 IP-\u0430\u0434\u0440\u0435\u0441", + "type": "\u0422\u0438\u043f \u043f\u0440\u0438\u043d\u0442\u0435\u0440\u0430" + }, + "description": "\u041e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 \u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u0435\u0439 \u043f\u043e \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0435 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438: https://www.home-assistant.io/integrations/brother.", + "title": "\u041f\u0440\u0438\u043d\u0442\u0435\u0440 Brother" + }, + "zeroconf_confirm": { + "data": { + "type": "\u0422\u0438\u043f \u043f\u0440\u0438\u043d\u0442\u0435\u0440\u0430" + }, + "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 \u043f\u0440\u0438\u043d\u0442\u0435\u0440 Brother {model} \u0441 \u0441\u0435\u0440\u0438\u0439\u043d\u044b\u043c \u043d\u043e\u043c\u0435\u0440\u043e\u043c `{serial_number}`?", + "title": "\u041e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d \u043f\u0440\u0438\u043d\u0442\u0435\u0440 Brother" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/brother/translations/sl.json b/homeassistant/components/brother/translations/sl.json new file mode 100644 index 0000000000000..91085e32ba171 --- /dev/null +++ b/homeassistant/components/brother/translations/sl.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "Ta tiskalnik je \u017ee konfiguriran.", + "unsupported_model": "Ta model tiskalnika ni podprt." + }, + "error": { + "connection_error": "Napaka v povezavi.", + "snmp_error": "Stre\u017enik SNMP je izklopljen ali tiskalnik ni podprt.", + "wrong_host": "Neveljavno ime gostitelja ali IP naslov." + }, + "flow_title": "Tiskalnik Brother: {model} {serial_number}", + "step": { + "user": { + "data": { + "host": "Gostiteljsko ime tiskalnika ali naslov IP", + "type": "Vrsta tiskalnika" + }, + "description": "Nastavite integracijo tiskalnika Brother. \u010ce imate te\u017eave s konfiguracijo, pojdite na: https://www.home-assistant.io/integrations/brother", + "title": "Brother Tiskalnik" + }, + "zeroconf_confirm": { + "data": { + "type": "Vrsta tiskalnika" + }, + "description": "Ali \u017eelite dodati Brother tiskalnik {model} s serijsko \u0161tevilko ' {serial_number} ' v Home Assistant?", + "title": "Odkriti Brother tiskalniki" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/brother/translations/sv.json b/homeassistant/components/brother/translations/sv.json new file mode 100644 index 0000000000000..ad6372c423ee0 --- /dev/null +++ b/homeassistant/components/brother/translations/sv.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "Den h\u00e4r skrivaren \u00e4r redan konfigurerad.", + "unsupported_model": "Den h\u00e4r skrivarmodellen st\u00f6ds inte." + }, + "error": { + "connection_error": "Anslutningsfel.", + "snmp_error": "SNMP-servern har st\u00e4ngts av eller s\u00e5 st\u00f6ds inte skrivaren.", + "wrong_host": "Ogiltigt v\u00e4rdnamn eller IP-adress." + }, + "flow_title": "Brother-skrivare: {model} {serial_number}", + "step": { + "user": { + "data": { + "host": "Skrivarens v\u00e4rdnamn eller IP-adress", + "type": "Typ av skrivare" + }, + "description": "St\u00e4ll in Brother-skrivarintegration. Om du har problem med konfigurationen g\u00e5r du till: https://www.home-assistant.io/integrations/brother", + "title": "Brother-skrivare" + }, + "zeroconf_confirm": { + "data": { + "type": "Typ av skrivare" + }, + "description": "Vill du l\u00e4gga till Brother-skrivaren {model} med serienumret {serial_number} i Home Assistant?", + "title": "Uppt\u00e4ckte Brother-skrivare" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/brother/translations/zh-Hant.json b/homeassistant/components/brother/translations/zh-Hant.json new file mode 100644 index 0000000000000..cf268cb4563da --- /dev/null +++ b/homeassistant/components/brother/translations/zh-Hant.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "\u6b64\u5370\u8868\u6a5f\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "unsupported_model": "\u4e0d\u652f\u63f4\u6b64\u6b3e\u5370\u8868\u6a5f\u3002" + }, + "error": { + "connection_error": "\u9023\u7dda\u932f\u8aa4\u3002", + "snmp_error": "SNMP \u4f3a\u670d\u5668\u70ba\u95dc\u9589\u72c0\u614b\u6216\u5370\u8868\u6a5f\u4e0d\u652f\u63f4\u3002", + "wrong_host": "\u7121\u6548\u4e3b\u6a5f\u540d\u6216 IP \u4f4d\u5740" + }, + "flow_title": "Brother \u5370\u8868\u6a5f\uff1a{model} {serial_number}", + "step": { + "user": { + "data": { + "host": "\u5370\u8868\u6a5f\u4e3b\u6a5f\u540d\u6216 IP \u4f4d\u5740", + "type": "\u5370\u8868\u6a5f\u985e\u578b" + }, + "description": "\u8a2d\u5b9a Brother \u5370\u8868\u6a5f\u6574\u5408\u3002\u5047\u5982\u9700\u8981\u5354\u52a9\uff0c\u8acb\u53c3\u8003\uff1ahttps://www.home-assistant.io/integrations/brother", + "title": "Brother \u5370\u8868\u6a5f" + }, + "zeroconf_confirm": { + "data": { + "type": "\u5370\u8868\u6a5f\u985e\u578b" + }, + "description": "\u662f\u5426\u8981\u5c07\u5e8f\u865f {serial_number} \u4e4bBrother \u5370\u8868\u6a5f {model} \u65b0\u589e\u81f3 Home Assistant\uff1f", + "title": "\u81ea\u52d5\u63a2\u7d22\u5230 Brother \u5370\u8868\u6a5f" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/brottsplatskartan/manifest.json b/homeassistant/components/brottsplatskartan/manifest.json index d3b0657fed82e..0737e506785c2 100644 --- a/homeassistant/components/brottsplatskartan/manifest.json +++ b/homeassistant/components/brottsplatskartan/manifest.json @@ -1,10 +1,7 @@ { "domain": "brottsplatskartan", "name": "Brottsplatskartan", - "documentation": "https://www.home-assistant.io/components/brottsplatskartan", - "requirements": [ - "brottsplatskartan==0.0.1" - ], - "dependencies": [], + "documentation": "https://www.home-assistant.io/integrations/brottsplatskartan", + "requirements": ["brottsplatskartan==0.0.1"], "codeowners": [] } diff --git a/homeassistant/components/brottsplatskartan/sensor.py b/homeassistant/components/brottsplatskartan/sensor.py index c36c5c0ad1c4a..7b2c3e585e309 100644 --- a/homeassistant/components/brottsplatskartan/sensor.py +++ b/homeassistant/components/brottsplatskartan/sensor.py @@ -4,55 +4,76 @@ import logging import uuid +import brottsplatskartan import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - ATTR_ATTRIBUTION, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME) + 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' +CONF_AREA = "area" -DEFAULT_NAME = 'Brottsplatskartan' +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" + "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)]), -}) +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) + name = config[CONF_NAME] # Every Home Assistant instance should have their own unique # app parameter: https://brottsplatskartan.se/sida/api - app = 'ha-{}'.format(uuid.getnode()) + app = f"ha-{uuid.getnode()}" bpk = brottsplatskartan.BrottsplatsKartan( - app=app, area=area, latitude=latitude, longitude=longitude) + app=app, area=area, latitude=latitude, longitude=longitude + ) add_entities([BrottsplatskartanSensor(bpk, name)], True) @@ -84,7 +105,7 @@ def device_state_attributes(self): def update(self): """Update device state.""" - import brottsplatskartan + incident_counts = defaultdict(int) incidents = self._brottsplatskartan.get_incidents() @@ -93,7 +114,7 @@ def update(self): return for incident in incidents: - incident_type = incident.get('title_type') + incident_type = incident.get("title_type") incident_counts[incident_type] += 1 self._attributes = {ATTR_ATTRIBUTION: brottsplatskartan.ATTRIBUTION} diff --git a/homeassistant/components/browser/__init__.py b/homeassistant/components/browser/__init__.py index 1c002f21f5fb4..fc0e9eccb3a09 100644 --- a/homeassistant/components/browser/__init__.py +++ b/homeassistant/components/browser/__init__.py @@ -1,26 +1,31 @@ """Support for launching a web browser on the host machine.""" +import webbrowser + import voluptuous as vol -ATTR_URL = 'url' -ATTR_URL_DEFAULT = 'https://www.google.com' +ATTR_URL = "url" +ATTR_URL_DEFAULT = "https://www.google.com" -DOMAIN = 'browser' +DOMAIN = "browser" -SERVICE_BROWSE_URL = 'browse_url' +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(), -}) +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) + 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 index 61823564fe918..448e3af1d24e2 100644 --- a/homeassistant/components/browser/manifest.json +++ b/homeassistant/components/browser/manifest.json @@ -1,8 +1,7 @@ { "domain": "browser", "name": "Browser", - "documentation": "https://www.home-assistant.io/components/browser", - "requirements": [], - "dependencies": [], - "codeowners": [] + "documentation": "https://www.home-assistant.io/integrations/browser", + "codeowners": [], + "quality_scale": "internal" } diff --git a/homeassistant/components/browser/services.yaml b/homeassistant/components/browser/services.yaml index e69de29bb2d1d..460def22dc144 100644 --- a/homeassistant/components/browser/services.yaml +++ b/homeassistant/components/browser/services.yaml @@ -0,0 +1,6 @@ +browse_url: + description: Open a URL in the default browser on the host machine of Home Assistant. + fields: + url: + description: The URL to open. + example: "https://www.home-assistant.io" diff --git a/homeassistant/components/brunt/cover.py b/homeassistant/components/brunt/cover.py index f9455ae091093..83c20ea108889 100644 --- a/homeassistant/components/brunt/cover.py +++ b/homeassistant/components/brunt/cover.py @@ -2,63 +2,67 @@ import logging +from brunt import BruntAPI 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 + ATTR_POSITION, + PLATFORM_SCHEMA, + SUPPORT_CLOSE, + SUPPORT_OPEN, + SUPPORT_SET_POSITION, + CoverEntity, ) +from homeassistant.const import ATTR_ATTRIBUTION, CONF_PASSWORD, CONF_USERNAME import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) COVER_FEATURES = SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_SET_POSITION -DEVICE_CLASS = 'window' +DEVICE_CLASS = "window" -ATTR_REQUEST_POSITION = 'request_position' -NOTIFICATION_ID = 'brunt_notification' -NOTIFICATION_TITLE = 'Brunt Cover Setup' -ATTRIBUTION = 'Based on an unofficial Brunt SDK.' +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 -}) +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'] + 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) + 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), + "Error: {ex}
You will need to restart hass after fixing.", title=NOTIFICATION_TITLE, - notification_id=NOTIFICATION_ID) + notification_id=NOTIFICATION_ID, + ) -class BruntDevice(CoverDevice): +class BruntDevice(CoverEntity): """ Representation of a Brunt cover device. @@ -91,7 +95,7 @@ def current_cover_position(self): None is unknown, 0 is closed, 100 is fully open. """ - pos = self._state.get('currentPosition') + pos = self._state.get("currentPosition") return int(pos) if pos else None @property @@ -103,7 +107,7 @@ def request_cover_position(self): 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') + pos = self._state.get("requestPosition") return int(pos) if pos else None @property @@ -113,7 +117,7 @@ def move_state(self): None is unknown, 0 when stopped, 1 when opening, 2 when closing """ - mov = self._state.get('moveState') + mov = self._state.get("moveState") return int(mov) if mov else None @property @@ -131,7 +135,7 @@ def device_state_attributes(self): """Return the detailed device state attributes.""" return { ATTR_ATTRIBUTION: ATTRIBUTION, - ATTR_REQUEST_POSITION: self.request_cover_position + ATTR_REQUEST_POSITION: self.request_cover_position, } @property @@ -152,8 +156,7 @@ def is_closed(self): def update(self): """Poll the current state of the device.""" try: - self._state = self._bapi.getState( - thingUri=self._thing_uri).get('thing') + 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) @@ -161,15 +164,14 @@ def update(self): def open_cover(self, **kwargs): """Set the cover to the open position.""" - self._bapi.changeRequestPosition( - OPEN_POSITION, thingUri=self._thing_uri) + 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) + 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) + kwargs[ATTR_POSITION], thingUri=self._thing_uri + ) diff --git a/homeassistant/components/brunt/manifest.json b/homeassistant/components/brunt/manifest.json index a47e3f69d5cf0..68f0cf9e461d1 100644 --- a/homeassistant/components/brunt/manifest.json +++ b/homeassistant/components/brunt/manifest.json @@ -1,12 +1,7 @@ { "domain": "brunt", - "name": "Brunt", - "documentation": "https://www.home-assistant.io/components/brunt", - "requirements": [ - "brunt==0.1.3" - ], - "dependencies": [], - "codeowners": [ - "@eavanvalkenburg" - ] + "name": "Brunt Blind Engine", + "documentation": "https://www.home-assistant.io/integrations/brunt", + "requirements": ["brunt==0.1.3"], + "codeowners": ["@eavanvalkenburg"] } diff --git a/homeassistant/components/bt_home_hub_5/device_tracker.py b/homeassistant/components/bt_home_hub_5/device_tracker.py index 65f88e05d1cc1..32b8e2aa0507c 100644 --- a/homeassistant/components/bt_home_hub_5/device_tracker.py +++ b/homeassistant/components/bt_home_hub_5/device_tracker.py @@ -1,20 +1,24 @@ """Support for BT Home Hub 5.""" import logging +import bthomehub5_devicelist import voluptuous as vol -import homeassistant.helpers.config_validation as cv -from homeassistant.components.device_tracker import (DOMAIN, PLATFORM_SCHEMA, - DeviceScanner) +from homeassistant.components.device_tracker import ( + DOMAIN, + PLATFORM_SCHEMA, + DeviceScanner, +) from homeassistant.const import CONF_HOST +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -CONF_DEFAULT_IP = '192.168.1.254' +CONF_DEFAULT_IP = "192.168.1.254" -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_HOST, default=CONF_DEFAULT_IP): cv.string, -}) +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + {vol.Optional(CONF_HOST, default=CONF_DEFAULT_IP): cv.string} +) def get_scanner(hass, config): @@ -29,7 +33,6 @@ class BTHomeHub5DeviceScanner(DeviceScanner): def __init__(self, config): """Initialise the scanner.""" - import bthomehub5_devicelist _LOGGER.info("Initialising BT Home Hub 5") self.host = config[CONF_HOST] @@ -58,7 +61,6 @@ def get_device_name(self, device): def update_info(self): """Ensure the information from the BT Home Hub 5 is up to date.""" - import bthomehub5_devicelist _LOGGER.info("Scanning") diff --git a/homeassistant/components/bt_home_hub_5/manifest.json b/homeassistant/components/bt_home_hub_5/manifest.json index 927d9ea941230..adf3e74c7a60c 100644 --- a/homeassistant/components/bt_home_hub_5/manifest.json +++ b/homeassistant/components/bt_home_hub_5/manifest.json @@ -1,10 +1,7 @@ { "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": [], + "name": "BT Home Hub 5", + "documentation": "https://www.home-assistant.io/integrations/bt_home_hub_5", + "requirements": ["bthomehub5-devicelist==0.1.1"], "codeowners": [] } diff --git a/homeassistant/components/bt_smarthub/device_tracker.py b/homeassistant/components/bt_smarthub/device_tracker.py index adc873f56b396..383f724decd63 100644 --- a/homeassistant/components/bt_smarthub/device_tracker.py +++ b/homeassistant/components/bt_smarthub/device_tracker.py @@ -1,25 +1,38 @@ """Support for BT Smart Hub (Sometimes referred to as BT Home Hub 6).""" import logging +from btsmarthub_devicelist import BTSmartHub import voluptuous as vol -import homeassistant.helpers.config_validation as cv from homeassistant.components.device_tracker import ( - DOMAIN, PLATFORM_SCHEMA, DeviceScanner) + DOMAIN, + PLATFORM_SCHEMA, + DeviceScanner, +) from homeassistant.const import CONF_HOST +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -CONF_DEFAULT_IP = '192.168.1.254' +CONF_DEFAULT_IP = "192.168.1.254" +CONF_SMARTHUB_MODEL = "smarthub_model" -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_HOST, default=CONF_DEFAULT_IP): cv.string, -}) +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_HOST, default=CONF_DEFAULT_IP): cv.string, + vol.Optional(CONF_SMARTHUB_MODEL): vol.In([1, 2]), + } +) def get_scanner(hass, config): """Return a BT Smart Hub scanner if successful.""" - scanner = BTSmartHubScanner(config[DOMAIN]) + info = config[DOMAIN] + smarthub_client = BTSmartHub( + router_ip=info[CONF_HOST], smarthub_model=info.get(CONF_SMARTHUB_MODEL) + ) + + scanner = BTSmartHubScanner(smarthub_client) return scanner if scanner.success_init else None @@ -27,10 +40,9 @@ def get_scanner(hass, config): class BTSmartHubScanner(DeviceScanner): """This class queries a BT Smart Hub.""" - def __init__(self, config): + def __init__(self, smarthub_client): """Initialise the scanner.""" - _LOGGER.debug("Initialising BT Smart Hub") - self.host = config[CONF_HOST] + self.smarthub = smarthub_client self.last_results = {} self.success_init = False @@ -39,20 +51,20 @@ def __init__(self, config): if data: self.success_init = True else: - _LOGGER.info("Failed to connect to %s", self.host) + _LOGGER.info("Failed to connect to %s", self.smarthub.router_ip) 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] + 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'] + if client["mac"] == device: + return client["host"] return None def _update_info(self): @@ -66,24 +78,24 @@ def _update_info(self): _LOGGER.warning("Error scanning devices") return - clients = [client for client in data.values()] + clients = list(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) + data = self.smarthub.get_devicelist(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'] + devices[device["UserHostName"]] = { + "ip": device["IPAddress"], + "mac": device["PhysAddress"], + "host": device["UserHostName"], + "status": device["Active"], } except KeyError: pass diff --git a/homeassistant/components/bt_smarthub/manifest.json b/homeassistant/components/bt_smarthub/manifest.json index 725541082e701..81f7098e65348 100644 --- a/homeassistant/components/bt_smarthub/manifest.json +++ b/homeassistant/components/bt_smarthub/manifest.json @@ -1,12 +1,7 @@ { "domain": "bt_smarthub", - "name": "Bt smarthub", - "documentation": "https://www.home-assistant.io/components/bt_smarthub", - "requirements": [ - "btsmarthub_devicelist==0.1.3" - ], - "dependencies": [], - "codeowners": [ - "@jxwolstenholme" - ] + "name": "BT Smart Hub", + "documentation": "https://www.home-assistant.io/integrations/bt_smarthub", + "requirements": ["btsmarthub_devicelist==0.2.0"], + "codeowners": ["@jxwolstenholme"] } diff --git a/homeassistant/components/buienradar/camera.py b/homeassistant/components/buienradar/camera.py new file mode 100644 index 0000000000000..78c8f82d1ffd3 --- /dev/null +++ b/homeassistant/components/buienradar/camera.py @@ -0,0 +1,188 @@ +"""Provide animated GIF loops of Buienradar imagery.""" +import asyncio +from datetime import datetime, timedelta +import logging +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" +CONF_COUNTRY = "country_code" + +_LOG = logging.getLogger(__name__) + +# Maximum range according to docs +DIM_RANGE = vol.All(vol.Coerce(int), vol.Range(min=120, max=700)) + +# Multiple choice for available Radar Map URL +SUPPORTED_COUNTRY_CODES = ["NL", "BE"] + +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, + vol.Optional(CONF_COUNTRY, default="NL"): vol.All( + vol.Coerce(str), vol.In(SUPPORTED_COUNTRY_CODES) + ), + } + ) +) + + +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] + country = config[CONF_COUNTRY] + + async_add_entities([BuienradarCam(name, dimension, delta, country)]) + + +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, country: str): + """ + 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 + + # country location + self._country = country + + # 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: Optional[bytes] = None + # value of the last seen last modified header + self._last_modified: Optional[str] = None + # loading status + self._loading = False + # deadline for image refresh - self.delta after last successful load + self._deadline: Optional[datetime] = None + + @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 = ( + f"https://api.buienradar.nl/image/1.0/RadarMap{self._country}" + f"?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") + 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/const.py b/homeassistant/components/buienradar/const.py new file mode 100644 index 0000000000000..b91d2497d77e3 --- /dev/null +++ b/homeassistant/components/buienradar/const.py @@ -0,0 +1,7 @@ +"""Constants for buienradar component.""" +DEFAULT_TIMEFRAME = 60 + +"""Schedule next call after (minutes).""" +SCHEDULE_OK = 10 +"""When an error occurred, new call after (minutes).""" +SCHEDULE_NOK = 2 diff --git a/homeassistant/components/buienradar/manifest.json b/homeassistant/components/buienradar/manifest.json index 98fc5fbdeac45..359cb471adad0 100644 --- a/homeassistant/components/buienradar/manifest.json +++ b/homeassistant/components/buienradar/manifest.json @@ -1,10 +1,7 @@ { "domain": "buienradar", "name": "Buienradar", - "documentation": "https://www.home-assistant.io/components/buienradar", - "requirements": [ - "buienradar==0.91" - ], - "dependencies": [], - "codeowners": [] + "documentation": "https://www.home-assistant.io/integrations/buienradar", + "requirements": ["buienradar==1.0.4"], + "codeowners": ["@mjj4791", "@ties"] } diff --git a/homeassistant/components/buienradar/sensor.py b/homeassistant/components/buienradar/sensor.py index f3aaa9b75378d..92811b98a8060 100644 --- a/homeassistant/components/buienradar/sensor.py +++ b/homeassistant/components/buienradar/sensor.py @@ -1,27 +1,53 @@ """Support for Buienradar.nl weather service.""" -import asyncio -from datetime import datetime, timedelta import logging -import aiohttp -import async_timeout +from buienradar.constants import ( + ATTRIBUTION, + CONDCODE, + CONDITION, + DETAILED, + EXACT, + EXACTNL, + FORECAST, + IMAGE, + MEASURED, + PRECIPITATION_FORECAST, + STATIONNAME, + TIMEFRAME, + VISIBILITY, + WINDGUST, + WINDSPEED, +) 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 + ATTR_ATTRIBUTION, + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_MONITORED_CONDITIONS, + CONF_NAME, + DEGREE, + IRRADIATION_WATTS_PER_SQUARE_METER, + LENGTH_KILOMETERS, + SPEED_KILOMETERS_PER_HOUR, + TEMP_CELSIUS, + TIME_HOURS, + UNIT_PERCENTAGE, +) +from homeassistant.core import callback 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 +from .const import DEFAULT_TIMEFRAME +from .util import BrData + _LOGGER = logging.getLogger(__name__) -MEASURED_LABEL = 'Measured' -TIMEFRAME_LABEL = 'Timeframe' -SYMBOL = 'symbol' +MEASURED_LABEL = "Measured" +TIMEFRAME_LABEL = "Timeframe" +SYMBOL = "symbol" # Schedule next call after (minutes): SCHEDULE_OK = 10 @@ -31,131 +57,173 @@ # 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], + "stationname": ["Stationname", None, None], + # new in json api (>1.0.0): + "barometerfc": ["Barometer value", None, "mdi:gauge"], + # new in json api (>1.0.0): + "barometerfcname": ["Barometer", None, "mdi:gauge"], + # new in json api (>1.0.0): + "barometerfcnamenl": ["Barometer", None, "mdi:gauge"], + "condition": ["Condition", None, None], + "conditioncode": ["Condition code", None, None], + "conditiondetailed": ["Detailed condition", None, None], + "conditionexact": ["Full condition", None, None], + "symbol": ["Symbol", None, None], + # new in json api (>1.0.0): + "feeltemperature": ["Feel temperature", TEMP_CELSIUS, "mdi:thermometer"], + "humidity": ["Humidity", UNIT_PERCENTAGE, "mdi:water-percent"], + "temperature": ["Temperature", TEMP_CELSIUS, "mdi:thermometer"], + "groundtemperature": ["Ground temperature", TEMP_CELSIUS, "mdi:thermometer"], + "windspeed": ["Wind speed", SPEED_KILOMETERS_PER_HOUR, "mdi:weather-windy"], + "windforce": ["Wind force", "Bft", "mdi:weather-windy"], + "winddirection": ["Wind direction", None, "mdi:compass-outline"], + "windazimuth": ["Wind direction azimuth", DEGREE, "mdi:compass-outline"], + "pressure": ["Pressure", "hPa", "mdi:gauge"], + "visibility": ["Visibility", LENGTH_KILOMETERS, None], + "windgust": ["Wind gust", SPEED_KILOMETERS_PER_HOUR, "mdi:weather-windy"], + "precipitation": ["Precipitation", f"mm/{TIME_HOURS}", "mdi:weather-pouring"], + "irradiance": ["Irradiance", IRRADIATION_WATTS_PER_SQUARE_METER, "mdi:sunglasses"], + "precipitation_forecast_average": [ + "Precipitation forecast average", + f"mm/{TIME_HOURS}", + "mdi:weather-pouring", + ], + "precipitation_forecast_total": [ + "Precipitation forecast total", + "mm", + "mdi:weather-pouring", + ], + # new in json api (>1.0.0): + "rainlast24hour": ["Rain last 24h", "mm", "mdi:weather-pouring"], + # new in json api (>1.0.0): + "rainlasthour": ["Rain last hour", "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"], + # new in json api (>1.0.0): + "minrain_1d": ["Minimum rain 1d", "mm", "mdi:weather-pouring"], + "minrain_2d": ["Minimum rain 2d", "mm", "mdi:weather-pouring"], + "minrain_3d": ["Minimum rain 3d", "mm", "mdi:weather-pouring"], + "minrain_4d": ["Minimum rain 4d", "mm", "mdi:weather-pouring"], + "minrain_5d": ["Minimum rain 5d", "mm", "mdi:weather-pouring"], + # new in json api (>1.0.0): + "maxrain_1d": ["Maximum rain 1d", "mm", "mdi:weather-pouring"], + "maxrain_2d": ["Maximum rain 2d", "mm", "mdi:weather-pouring"], + "maxrain_3d": ["Maximum rain 3d", "mm", "mdi:weather-pouring"], + "maxrain_4d": ["Maximum rain 4d", "mm", "mdi:weather-pouring"], + "maxrain_5d": ["Maximum rain 5d", "mm", "mdi:weather-pouring"], + "rainchance_1d": ["Rainchance 1d", UNIT_PERCENTAGE, "mdi:weather-pouring"], + "rainchance_2d": ["Rainchance 2d", UNIT_PERCENTAGE, "mdi:weather-pouring"], + "rainchance_3d": ["Rainchance 3d", UNIT_PERCENTAGE, "mdi:weather-pouring"], + "rainchance_4d": ["Rainchance 4d", UNIT_PERCENTAGE, "mdi:weather-pouring"], + "rainchance_5d": ["Rainchance 5d", UNIT_PERCENTAGE, "mdi:weather-pouring"], + "sunchance_1d": ["Sunchance 1d", UNIT_PERCENTAGE, "mdi:weather-partly-cloudy"], + "sunchance_2d": ["Sunchance 2d", UNIT_PERCENTAGE, "mdi:weather-partly-cloudy"], + "sunchance_3d": ["Sunchance 3d", UNIT_PERCENTAGE, "mdi:weather-partly-cloudy"], + "sunchance_4d": ["Sunchance 4d", UNIT_PERCENTAGE, "mdi:weather-partly-cloudy"], + "sunchance_5d": ["Sunchance 5d", UNIT_PERCENTAGE, "mdi:weather-partly-cloudy"], + "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"], + "windspeed_1d": ["Wind speed 1d", SPEED_KILOMETERS_PER_HOUR, "mdi:weather-windy"], + "windspeed_2d": ["Wind speed 2d", SPEED_KILOMETERS_PER_HOUR, "mdi:weather-windy"], + "windspeed_3d": ["Wind speed 3d", SPEED_KILOMETERS_PER_HOUR, "mdi:weather-windy"], + "windspeed_4d": ["Wind speed 4d", SPEED_KILOMETERS_PER_HOUR, "mdi:weather-windy"], + "windspeed_5d": ["Wind speed 5d", SPEED_KILOMETERS_PER_HOUR, "mdi:weather-windy"], + "winddirection_1d": ["Wind direction 1d", None, "mdi:compass-outline"], + "winddirection_2d": ["Wind direction 2d", None, "mdi:compass-outline"], + "winddirection_3d": ["Wind direction 3d", None, "mdi:compass-outline"], + "winddirection_4d": ["Wind direction 4d", None, "mdi:compass-outline"], + "winddirection_5d": ["Wind direction 5d", None, "mdi:compass-outline"], + "windazimuth_1d": ["Wind direction azimuth 1d", DEGREE, "mdi:compass-outline"], + "windazimuth_2d": ["Wind direction azimuth 2d", DEGREE, "mdi:compass-outline"], + "windazimuth_3d": ["Wind direction azimuth 3d", DEGREE, "mdi:compass-outline"], + "windazimuth_4d": ["Wind direction azimuth 4d", DEGREE, "mdi:compass-outline"], + "windazimuth_5d": ["Wind direction azimuth 5d", DEGREE, "mdi:compass-outline"], + "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): +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=DEFAULT_TIMEFRAME): 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) + timeframe = config[CONF_TIMEFRAME] if None in (latitude, longitude): - _LOGGER.error("Latitude or longitude not set in HomeAssistant config") + _LOGGER.error("Latitude or longitude not set in Home Assistant config") return False - coordinates = {CONF_LATITUDE: float(latitude), - CONF_LONGITUDE: float(longitude)} + coordinates = {CONF_LATITUDE: float(latitude), CONF_LONGITUDE: float(longitude)} - _LOGGER.debug("Initializing buienradar sensor coordinate %s, timeframe %s", - coordinates, timeframe) + _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)) + dev.append(BrSensor(sensor_type, config.get(CONF_NAME), coordinates)) async_add_entities(dev) data = BrData(hass, coordinates, timeframe, dev) @@ -168,7 +236,6 @@ class BrSensor(Entity): 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] @@ -182,8 +249,7 @@ def __init__(self, sensor_type, client_name, coordinates): 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) + self._force_update = self.type != SYMBOL and not self.type.startswith(CONDITION) if self.type.startswith(PRECIPITATION_FORECAST): self._timeframe = None @@ -191,18 +257,20 @@ def __init__(self, sensor_type, client_name, coordinates): 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): + return "{:2.6f}{:2.6f}{}".format( + coordinates[CONF_LATITUDE], coordinates[CONF_LONGITUDE], self.type + ) + + @callback + def data_updated(self, data): + """Update data.""" + if self._load_data(data) and self.hass: + self.async_write_ha_state() + + @callback + 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 @@ -213,23 +281,26 @@ def load_data(self, data): 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'): + 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") + ): + # update forcasting sensors: fcday = 0 - if self.type.endswith('_2d'): + if self.type.endswith("_2d"): fcday = 1 - if self.type.endswith('_3d'): + if self.type.endswith("_3d"): fcday = 2 - if self.type.endswith('_4d'): + if self.type.endswith("_4d"): fcday = 3 - if self.type.endswith('_5d'): + if self.type.endswith("_5d"): fcday = 4 - # update all other sensors + # update weather symbol & status text if self.type.startswith(SYMBOL) or self.type.startswith(CONDITION): try: condition = data.get(FORECAST)[fcday].get(CONDITION) @@ -238,17 +309,17 @@ def load_data(self, data): return False if condition: - new_state = condition.get(CONDITION, None) + new_state = condition.get(CONDITION) 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) + new_state = condition.get(EXACTNL) + if self.type.startswith("conditioncode"): + new_state = condition.get(CONDCODE) + if self.type.startswith("conditiondetailed"): + new_state = condition.get(DETAILED) + if self.type.startswith("conditionexact"): + new_state = condition.get(EXACT) - img = condition.get(IMAGE, None) + img = condition.get(IMAGE) if new_state != self._state or img != self._entity_picture: self._state = new_state @@ -256,6 +327,18 @@ def load_data(self, data): return True return False + if self.type.startswith(WINDSPEED): + # hass wants windspeeds in km/h not m/s, so convert: + try: + self._state = data.get(FORECAST)[fcday].get(self.type[:-3]) + if self._state is not None: + self._state = round(self._state * 3.6, 1) + return True + except IndexError: + _LOGGER.warning("No forecast for fcday=%s...", fcday) + return False + + # update all other sensors try: self._state = data.get(FORECAST)[fcday].get(self.type[:-3]) return True @@ -265,20 +348,20 @@ def load_data(self, data): if self.type == SYMBOL or self.type.startswith(CONDITION): # update weather symbol & status text - condition = data.get(CONDITION, None) + condition = data.get(CONDITION) if condition: if self.type == SYMBOL: - new_state = condition.get(EXACTNL, None) + new_state = condition.get(EXACTNL) 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) + new_state = condition.get(CONDITION) + if self.type == "conditioncode": + new_state = condition.get(CONDCODE) + if self.type == "conditiondetailed": + new_state = condition.get(DETAILED) + if self.type == "conditionexact": + new_state = condition.get(EXACT) - img = condition.get(IMAGE, None) + img = condition.get(IMAGE) if new_state != self._state or img != self._entity_picture: self._state = new_state @@ -291,7 +374,21 @@ def load_data(self, data): # 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:]) + self._state = nested.get(self.type[len(PRECIPITATION_FORECAST) + 1 :]) + return True + + if self.type == WINDSPEED or self.type == WINDGUST: + # hass wants windspeeds in km/h not m/s, so convert: + self._state = data.get(self.type) + if self._state is not None: + self._state = round(data.get(self.type) * 3.6, 1) + return True + + if self.type == VISIBILITY: + # hass wants visibility in km (not m), so convert: + self._state = data.get(self.type) + if self._state is not None: + self._state = round(self._state / 1000, 1) return True # update all other sensors @@ -311,7 +408,7 @@ def unique_id(self): @property def name(self): """Return the name of the sensor.""" - return '{} {}'.format(self.client_name, self._name) + return f"{self.client_name} {self._name}" @property def state(self): @@ -331,7 +428,6 @@ def entity_picture(self): @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} @@ -342,7 +438,7 @@ def device_state_attributes(self): result = { ATTR_ATTRIBUTION: self._attribution, - SENSOR_TYPES['stationname'][0]: self._stationname, + SENSOR_TYPES["stationname"][0]: self._stationname, } if self._measured is not None: # convert datetime (Europe/Amsterdam) into local datetime @@ -365,196 +461,3 @@ def icon(self): 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, loop=self.hass.loop) - - 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, loop=self.hass.loop): - 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/util.py b/homeassistant/components/buienradar/util.py new file mode 100644 index 0000000000000..4c69678d215d9 --- /dev/null +++ b/homeassistant/components/buienradar/util.py @@ -0,0 +1,247 @@ +"""Shared utilities for different supported platforms.""" +import asyncio +from datetime import datetime, timedelta +import logging + +import aiohttp +import async_timeout +from buienradar.buienradar import parse_data +from buienradar.constants import ( + ATTRIBUTION, + CONDITION, + CONTENT, + DATA, + FORECAST, + HUMIDITY, + MESSAGE, + PRESSURE, + STATIONNAME, + STATUS_CODE, + SUCCESS, + TEMPERATURE, + VISIBILITY, + WINDAZIMUTH, + WINDSPEED, +) +from buienradar.urls import JSON_FEED_URL, json_precipitation_forecast_url + +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, HTTP_OK +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.event import async_track_point_in_utc_time +from homeassistant.util import dt as dt_util + +from .const import SCHEDULE_NOK, SCHEDULE_OK + +__all__ = ["BrData"] +_LOGGER = logging.getLogger(__name__) + +""" +Log at WARN level after WARN_THRESHOLD failures, otherwise log at +DEBUG level. +""" +WARN_THRESHOLD = 4 + + +def threshold_log(count: int, *args, **kwargs) -> None: + """Log at warn level after WARN_THRESHOLD failures, debug otherwise.""" + if count >= WARN_THRESHOLD: + _LOGGER.warning(*args, **kwargs) + else: + _LOGGER.debug(*args, **kwargs) + + +class BrData: + """Get the latest data and updates the states.""" + + # Initialize to warn immediately if the first call fails. + load_error_count: int = WARN_THRESHOLD + rain_error_count: int = WARN_THRESHOLD + + 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 not self.devices: + return + + # Update all devices + for dev in self.devices: + dev.data_updated(self.data) + + 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.""" + _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 == HTTP_OK: + 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.""" + + content = await self.get_data(JSON_FEED_URL) + + if content.get(SUCCESS) is not True: + # unable to get the data + self.load_error_count += 1 + threshold_log( + self.load_error_count, + "Unable to retrieve json data from Buienradar." + "(Msg: %s, status: %s,)", + content.get(MESSAGE), + content.get(STATUS_CODE), + ) + # schedule new call + await self.schedule_update(SCHEDULE_NOK) + return + self.load_error_count = 0 + + # rounding coordinates prevents unnecessary redirects/calls + lat = self.coordinates[CONF_LATITUDE] + lon = self.coordinates[CONF_LONGITUDE] + rainurl = json_precipitation_forecast_url(lat, lon) + raincontent = await self.get_data(rainurl) + + if raincontent.get(SUCCESS) is not True: + self.rain_error_count += 1 + # unable to get the data + threshold_log( + self.rain_error_count, + "Unable to retrieve rain data from Buienradar." "(Msg: %s, status: %s)", + raincontent.get(MESSAGE), + raincontent.get(STATUS_CODE), + ) + # schedule new call + await self.schedule_update(SCHEDULE_NOK) + return + self.rain_error_count = 0 + + result = parse_data( + content.get(CONTENT), + raincontent.get(CONTENT), + self.coordinates[CONF_LATITUDE], + self.coordinates[CONF_LONGITUDE], + self.timeframe, + False, + ) + + _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.""" + + return self.data.get(ATTRIBUTION) + + @property + def stationname(self): + """Return the name of the selected weatherstation.""" + + return self.data.get(STATIONNAME) + + @property + def condition(self): + """Return the condition.""" + + return self.data.get(CONDITION) + + @property + def temperature(self): + """Return the temperature, or None.""" + + try: + return float(self.data.get(TEMPERATURE)) + except (ValueError, TypeError): + return None + + @property + def pressure(self): + """Return the pressure, or None.""" + + try: + return float(self.data.get(PRESSURE)) + except (ValueError, TypeError): + return None + + @property + def humidity(self): + """Return the humidity, or None.""" + + try: + return int(self.data.get(HUMIDITY)) + except (ValueError, TypeError): + return None + + @property + def visibility(self): + """Return the visibility, or None.""" + + try: + return int(self.data.get(VISIBILITY)) + except (ValueError, TypeError): + return None + + @property + def wind_speed(self): + """Return the windspeed, or None.""" + + try: + return float(self.data.get(WINDSPEED)) + except (ValueError, TypeError): + return None + + @property + def wind_bearing(self): + """Return the wind bearing, or None.""" + + try: + return int(self.data.get(WINDAZIMUTH)) + except (ValueError, TypeError): + return None + + @property + def forecast(self): + """Return the forecast data.""" + + return self.data.get(FORECAST) diff --git a/homeassistant/components/buienradar/weather.py b/homeassistant/components/buienradar/weather.py index 7d77bec7cca05..37dee08313e34 100644 --- a/homeassistant/components/buienradar/weather.py +++ b/homeassistant/components/buienradar/weather.py @@ -1,54 +1,72 @@ """Support for Buienradar.nl weather service.""" import logging +from buienradar.constants import ( + CONDCODE, + CONDITION, + DATETIME, + MAX_TEMP, + MIN_TEMP, + RAIN, + WINDAZIMUTH, + WINDSPEED, +) 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) + 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_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 +from .const import DEFAULT_TIMEFRAME +from .util import BrData _LOGGER = logging.getLogger(__name__) -DATA_CONDITION = 'buienradar_condition' +DATA_CONDITION = "buienradar_condition" -DEFAULT_TIMEFRAME = 60 -CONF_FORECAST = 'forecast' +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': [], + "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, -}) +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): +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) @@ -57,14 +75,12 @@ async def async_setup_platform(hass, config, async_add_entities, _LOGGER.error("Latitude or longitude not set in Home Assistant config") return False - coordinates = {CONF_LATITUDE: float(latitude), - CONF_LONGITUDE: float(longitude)} + 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) + _LOGGER.debug("Initializing buienradar weather: coordinates %s", coordinates) # create condition helper if DATA_CONDITION not in hass.data: @@ -85,8 +101,8 @@ class BrWeather(WeatherEntity): 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._stationname = config.get(CONF_NAME) + self._forecast = config[CONF_FORECAST] self._data = data @property @@ -97,13 +113,14 @@ def attribution(self): @property def name(self): """Return the name of the sensor.""" - return self._stationname or 'BR {}'.format(self._data.stationname - or '(unknown station)') + return ( + self._stationname or f"BR {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: @@ -128,13 +145,17 @@ def humidity(self): @property def visibility(self): - """Return the current visibility.""" - return self._data.visibility + """Return the current visibility in km.""" + if self._data.visibility is None: + return None + return round(self._data.visibility / 1000, 1) @property def wind_speed(self): - """Return the current windspeed.""" - return self._data.wind_speed + """Return the current windspeed in km/h.""" + if self._data.wind_speed is None: + return None + return round(self._data.wind_speed * 3.6, 1) @property def wind_bearing(self): @@ -149,24 +170,30 @@ def temperature_unit(self): @property def forecast(self): """Return the forecast array.""" - from buienradar.buienradar import (CONDITION, CONDCODE, 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) - - fcdata_out.append(data_out) - - return fcdata_out + + if not self._forecast: + return None + + fcdata_out = [] + cond = self.hass.data[DATA_CONDITION] + + if not self._data.forecast: + return None + + for data_in in self._data.forecast: + # remap keys from external library to + # keys understood by the weather component: + condcode = data_in.get(CONDITION, []).get(CONDCODE) + data_out = { + ATTR_FORECAST_TIME: data_in.get(DATETIME), + ATTR_FORECAST_CONDITION: cond[condcode], + ATTR_FORECAST_TEMP_LOW: data_in.get(MIN_TEMP), + ATTR_FORECAST_TEMP: data_in.get(MAX_TEMP), + ATTR_FORECAST_PRECIPITATION: data_in.get(RAIN), + ATTR_FORECAST_WIND_BEARING: data_in.get(WINDAZIMUTH), + ATTR_FORECAST_WIND_SPEED: round(data_in.get(WINDSPEED) * 3.6, 1), + } + + fcdata_out.append(data_out) + + return fcdata_out diff --git a/homeassistant/components/caldav/calendar.py b/homeassistant/components/caldav/calendar.py index 594a6473877bb..579755709d1dd 100644 --- a/homeassistant/components/caldav/calendar.py +++ b/homeassistant/components/caldav/calendar.py @@ -1,58 +1,75 @@ """Support for WebDav Calendar.""" +import copy from datetime import datetime, timedelta import logging import re +import caldav import voluptuous as vol from homeassistant.components.calendar import ( - PLATFORM_SCHEMA, CalendarEventDevice, get_date) + ENTITY_ID_FORMAT, + PLATFORM_SCHEMA, + CalendarEventDevice, + calculate_offset, + get_date, + is_offset_reached, +) from homeassistant.const import ( - CONF_NAME, CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL) + CONF_NAME, + CONF_PASSWORD, + CONF_URL, + CONF_USERNAME, + CONF_VERIFY_SSL, +) import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import generate_entity_id 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 -}) +CONF_CALENDARS = "calendars" +CONF_CUSTOM_CALENDARS = "custom_calendars" +CONF_CALENDAR = "calendar" +CONF_SEARCH = "search" + +OFFSET = "!!" + +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, [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.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) + url = config[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)) + client = caldav.DAVClient( + url, None, username, password, ssl_verify_cert=config[CONF_VERIFY_SSL] + ) calendars = client.principal().calendars() @@ -60,65 +77,78 @@ def setup_platform(hass, config, add_entities, disc_info=None): 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)): + if config[CONF_CALENDARS] and calendar.name not in config[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): + for cust_calendar in config[CONF_CUSTOM_CALENDARS]: # Check that the base calendar matches - if cust_calendar.get(CONF_CALENDAR) != calendar.name: + if cust_calendar[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)), - } - + name = cust_calendar[CONF_NAME] + device_id = f"{cust_calendar[CONF_CALENDAR]} {cust_calendar[CONF_NAME]}" + entity_id = generate_entity_id(ENTITY_ID_FORMAT, device_id, hass=hass) calendar_devices.append( WebDavCalendarEventDevice( - hass, device_data, calendar, True, - cust_calendar.get(CONF_SEARCH))) + name, calendar, entity_id, True, cust_calendar[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, - } + if not config[CONF_CUSTOM_CALENDARS]: + name = calendar.name + device_id = calendar.name + entity_id = generate_entity_id(ENTITY_ID_FORMAT, device_id, hass=hass) calendar_devices.append( - WebDavCalendarEventDevice(hass, device_data, calendar) + WebDavCalendarEventDevice(name, calendar, entity_id) ) - add_entities(calendar_devices) + add_entities(calendar_devices, True) 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): + def __init__(self, name, calendar, entity_id, all_day=False, search=None): """Create the WebDav Calendar Event Device.""" self.data = WebDavCalendarData(calendar, all_day, search) - super().__init__(hass, device_data) + self.entity_id = entity_id + self._event = None + self._name = name + self._offset_reached = False @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 {} + return {"offset_reached": self._offset_reached} - attributes = super().device_state_attributes - return attributes + @property + def event(self): + """Return the next upcoming event.""" + return self._event + + @property + def name(self): + """Return the name of the entity.""" + return self._name 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) + def update(self): + """Update event data.""" + self.data.update() + event = copy.deepcopy(self.data.event) + if event is None: + self._event = event + return + event = calculate_offset(event, OFFSET) + self._offset_reached = is_offset_reached(event) + self._event = event + class WebDavCalendarData: """Class to utilize the calendar dav client object to get next event.""" @@ -133,13 +163,14 @@ def __init__(self, calendar, include_all_day, search): 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) + 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'): + if hasattr(vevent, "uid"): uid = vevent.uid.value data = { "uid": uid, @@ -150,8 +181,8 @@ async def async_get_events(self, hass, start_date, end_date): "description": self.get_attr_value(vevent, "description"), } - data['start'] = get_date(data['start']).isoformat() - data['end'] = get_date(data['end']).isoformat() + data["start"] = get_date(data["start"]).isoformat() + data["end"] = get_date(data["end"]).isoformat() event_list.append(data) @@ -160,33 +191,64 @@ async def async_get_events(self, hass, start_date, end_date): @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): """Get the latest data.""" + start_of_today = dt.start_of_local_day() + start_of_tomorrow = dt.start_of_local_day() + timedelta(days=1) + # 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) - ) + results = self.calendar.date_search(start_of_today, start_of_tomorrow) + + # Create new events for each recurrence of an event that happens today. + # For recurring events, some servers return the original event with recurrence rules + # and they would not be properly parsed using their original start/end dates. + new_events = [] + for event in results: + vevent = event.instance.vevent + for start_dt in vevent.getrruleset() or []: + _start_of_today = start_of_today + _start_of_tomorrow = start_of_tomorrow + if self.is_all_day(vevent): + start_dt = start_dt.date() + _start_of_today = _start_of_today.date() + _start_of_tomorrow = _start_of_tomorrow.date() + if _start_of_today <= start_dt < _start_of_tomorrow: + new_event = event.copy() + new_vevent = new_event.instance.vevent + if hasattr(new_vevent, "dtend"): + dur = new_vevent.dtend.value - new_vevent.dtstart.value + new_vevent.dtend.value = start_dt + dur + new_vevent.dtstart.value = start_dt + new_events.append(new_event) + elif _start_of_tomorrow <= start_dt: + break + vevents = [event.instance.vevent for event in results + new_events] # 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) + vevents.sort(key=lambda x: self.to_datetime(x.dtstart.value)) + + vevent = next( + ( + vevent + for vevent in vevents + if ( + self.is_matching(vevent, self.search) + and (not self.is_all_day(vevent) or self.include_all_day) + and not self.is_over(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) + len(vevents), + self.calendar.name, + ) self.event = None - return True + return # Populate the entity attributes with the event values self.event = { @@ -194,9 +256,8 @@ def update(self): "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") + "description": self.get_attr_value(vevent, "description"), } - return True @staticmethod def is_matching(vevent, search): @@ -205,12 +266,14 @@ def is_matching(vevent, search): 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)) + 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): @@ -236,6 +299,10 @@ def get_hass_date(obj): def to_datetime(obj): """Return a datetime.""" if isinstance(obj, datetime): + if obj.tzinfo is None: + # floating value, not bound to any time zone in particular + # represent same time regardless of which time zone is currently being observed + return obj.replace(tzinfo=dt.DEFAULT_TIME_ZONE) return obj return dt.as_local(dt.dt.datetime.combine(obj, dt.dt.time.min)) diff --git a/homeassistant/components/caldav/manifest.json b/homeassistant/components/caldav/manifest.json index 55cd555d98955..94d786c88255d 100644 --- a/homeassistant/components/caldav/manifest.json +++ b/homeassistant/components/caldav/manifest.json @@ -1,10 +1,7 @@ { "domain": "caldav", - "name": "Caldav", - "documentation": "https://www.home-assistant.io/components/caldav", - "requirements": [ - "caldav==0.6.1" - ], - "dependencies": [], + "name": "CalDAV", + "documentation": "https://www.home-assistant.io/integrations/caldav", + "requirements": ["caldav==0.6.1"], "codeowners": [] } diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index 73a779816a3d1..6f7427a023471 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -1,124 +1,156 @@ """Support for Google Calendar event device sensors.""" -import logging from datetime import timedelta +import logging 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.components import http +from homeassistant.const import HTTP_BAD_REQUEST, STATE_OFF, STATE_ON +from homeassistant.helpers.config_validation import ( # noqa: F401 + PLATFORM_SCHEMA, + PLATFORM_SCHEMA_BASE, + time_period_str, +) +from homeassistant.helpers.entity import Entity 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 +# mypy: allow-untyped-defs, no-check-untyped-defs _LOGGER = logging.getLogger(__name__) -DOMAIN = 'calendar' - -ENTITY_ID_FORMAT = DOMAIN + '.{}' - +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) + component = hass.data[DOMAIN] = EntityComponent( + _LOGGER, DOMAIN, hass, SCAN_INTERVAL + ) 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 - # await hass.components.frontend.async_register_built_in_panel( + # 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'])) + 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"])) + + +def normalize_event(event): + """Normalize a calendar event.""" + normalized_event = {} + + start = event.get("start") + end = event.get("end") + start = get_date(start) if start is not None else None + end = get_date(end) if end is not None else None + normalized_event["dt_start"] = start + normalized_event["dt_end"] = end + + 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 + normalized_event["start"] = start + normalized_event["end"] = end + + # cleanup the string so we don't have a bunch of double+ spaces + summary = event.get("summary", "") + normalized_event["message"] = re.sub(" +", "", summary).strip() + normalized_event["location"] = event.get("location", "") + normalized_event["description"] = event.get("description", "") + normalized_event["all_day"] = "date" in event["start"] + + return normalized_event + + +def calculate_offset(event, offset): + """Calculate event offset. + + Return the updated event with the offset_time included. + """ + summary = event.get("summary", "") + # check if we have an offset tag in the message + # time is HH:MM or MM + reg = f"{offset}([+-]?[0-9]{{0,2}}(:[0-9]{{0,2}})?)" + 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 = f"{time[0]}0:{time[1:]}" + else: + time = f"0:{time}" + + offset_time = time_period_str(time) + summary = (summary[: search.start()] + summary[search.end() :]).strip() + event["summary"] = summary + else: + offset_time = dt.dt.timedelta() # default it + + event["offset_time"] = offset_time + return event + + +def is_offset_reached(event): + """Have we reached the offset time specified in the event title.""" + start = get_date(event["start"]) + if start is None or event["offset_time"] == dt.dt.timedelta(): + return False + + return start + event["offset_time"] <= dt.now(start.tzinfo) 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 + def event(self): + """Return the next upcoming event.""" + raise NotImplementedError() @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 + def state_attributes(self): + """Return the entity state attributes.""" + event = self.event + if event is None: + return None + event = normalize_event(event) 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), + "message": event["message"], + "all_day": event["all_day"], + "start_time": event["start"], + "end_time": event["end"], + "location": event["location"], + "description": event["description"], } @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) + event = self.event + if event is None: + return STATE_OFF + + event = normalize_event(event) + start = event["dt_start"] + end = event["dt_end"] + if start is None or end is None: return STATE_OFF @@ -127,72 +159,18 @@ def state(self): 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'] + async def async_get_events(self, hass, start_date, end_date): + """Return calendar events within a datetime range.""" + raise NotImplementedError() class CalendarEventView(http.HomeAssistantView): """View to retrieve calendar content.""" - url = '/api/calendars/{entity_id}' - name = 'api:calendars:calendar' + url = "/api/calendars/{entity_id}" + name = "api:calendars:calendar" def __init__(self, component): """Initialize calendar view.""" @@ -201,24 +179,25 @@ def __init__(self, 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') + start = request.query.get("start") + end = request.query.get("end") if None in (start, end, entity): - return web.Response(status=400) + return web.Response(status=HTTP_BAD_REQUEST) try: start_date = dt.parse_datetime(start) end_date = dt.parse_datetime(end) except (ValueError, AttributeError): - return web.Response(status=400) + return web.Response(status=HTTP_BAD_REQUEST) event_list = await entity.async_get_events( - request.app['hass'], start_date, end_date) + request.app["hass"], start_date, end_date + ) return self.json(event_list) class CalendarListView(http.HomeAssistantView): """View to retrieve calendar list.""" - url = '/api/calendars' + url = "/api/calendars" name = "api:calendars" def __init__(self, component): @@ -227,14 +206,11 @@ def __init__(self, component): async def get(self, request): """Retrieve calendar list.""" - get_state = request.app['hass'].states.get + hass = request.app["hass"] 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, - }) + state = hass.states.get(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'])) + 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 index 3a09cd090a523..1ae68100c069b 100644 --- a/homeassistant/components/calendar/manifest.json +++ b/homeassistant/components/calendar/manifest.json @@ -1,10 +1,7 @@ { "domain": "calendar", "name": "Calendar", - "documentation": "https://www.home-assistant.io/components/calendar", - "requirements": [], - "dependencies": [ - "http" - ], + "documentation": "https://www.home-assistant.io/integrations/calendar", + "dependencies": ["http"], "codeowners": [] } diff --git a/homeassistant/components/calendar/services.yaml b/homeassistant/components/calendar/services.yaml index ebf0c7b1591ab..8e2958f737015 100644 --- a/homeassistant/components/calendar/services.yaml +++ b/homeassistant/components/calendar/services.yaml @@ -1,26 +1 @@ # 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/calendar/strings.json b/homeassistant/components/calendar/strings.json new file mode 100644 index 0000000000000..3af9a78e60717 --- /dev/null +++ b/homeassistant/components/calendar/strings.json @@ -0,0 +1,9 @@ +{ + "title": "Calendar", + "state": { + "_": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" + } + } +} diff --git a/homeassistant/components/calendar/translations/af.json b/homeassistant/components/calendar/translations/af.json new file mode 100644 index 0000000000000..e9d01214e0841 --- /dev/null +++ b/homeassistant/components/calendar/translations/af.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "Af", + "on": "Aan" + } + }, + "title": "Kalender" +} \ No newline at end of file diff --git a/homeassistant/components/calendar/translations/ar.json b/homeassistant/components/calendar/translations/ar.json new file mode 100644 index 0000000000000..033a147f799f7 --- /dev/null +++ b/homeassistant/components/calendar/translations/ar.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "\u0625\u064a\u0642\u0627\u0641", + "on": "\u062a\u0634\u063a\u064a\u0644" + } + }, + "title": "\u0627\u0644\u062a\u0642\u0648\u064a\u0645" +} \ No newline at end of file diff --git a/homeassistant/components/calendar/translations/bg.json b/homeassistant/components/calendar/translations/bg.json new file mode 100644 index 0000000000000..bd4bb5fb584d1 --- /dev/null +++ b/homeassistant/components/calendar/translations/bg.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "\u0418\u0437\u043a\u043b\u044e\u0447\u0435\u043d", + "on": "\u0412\u043a\u043b\u044e\u0447\u0435\u043d" + } + }, + "title": "\u041a\u0430\u043b\u0435\u043d\u0434\u0430\u0440" +} \ No newline at end of file diff --git a/homeassistant/components/calendar/translations/bs.json b/homeassistant/components/calendar/translations/bs.json new file mode 100644 index 0000000000000..4655814c097fc --- /dev/null +++ b/homeassistant/components/calendar/translations/bs.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "Isklju\u010den", + "on": "Uklju\u010den" + } + }, + "title": "Kalendar" +} \ No newline at end of file diff --git a/homeassistant/components/calendar/translations/ca.json b/homeassistant/components/calendar/translations/ca.json new file mode 100644 index 0000000000000..5e842769c51f2 --- /dev/null +++ b/homeassistant/components/calendar/translations/ca.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "Desactivat", + "on": "Activat" + } + }, + "title": "Calendari" +} \ No newline at end of file diff --git a/homeassistant/components/calendar/translations/cs.json b/homeassistant/components/calendar/translations/cs.json new file mode 100644 index 0000000000000..315c67b070308 --- /dev/null +++ b/homeassistant/components/calendar/translations/cs.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "Neaktivn\u00ed", + "on": "Aktivn\u00ed" + } + }, + "title": "Kalend\u00e1\u0159" +} \ No newline at end of file diff --git a/homeassistant/components/calendar/translations/cy.json b/homeassistant/components/calendar/translations/cy.json new file mode 100644 index 0000000000000..9e348b3ed93f3 --- /dev/null +++ b/homeassistant/components/calendar/translations/cy.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "i ffwrdd", + "on": "Ar" + } + }, + "title": "Calendr" +} \ No newline at end of file diff --git a/homeassistant/components/calendar/translations/da.json b/homeassistant/components/calendar/translations/da.json new file mode 100644 index 0000000000000..c57af953ad238 --- /dev/null +++ b/homeassistant/components/calendar/translations/da.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "Fra", + "on": "Til" + } + }, + "title": "Kalender" +} \ No newline at end of file diff --git a/homeassistant/components/calendar/translations/de.json b/homeassistant/components/calendar/translations/de.json new file mode 100644 index 0000000000000..70c9fc7a31857 --- /dev/null +++ b/homeassistant/components/calendar/translations/de.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "Aus", + "on": "An" + } + }, + "title": "Kalender" +} \ No newline at end of file diff --git a/homeassistant/components/calendar/translations/el.json b/homeassistant/components/calendar/translations/el.json new file mode 100644 index 0000000000000..58a04bbeeacfb --- /dev/null +++ b/homeassistant/components/calendar/translations/el.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "\u0391\u03c0\u03b5\u03bd\u03b5\u03c1\u03b3\u03bf\u03c0\u03bf\u03b9\u03b7\u03bc\u03ad\u03bd\u03bf", + "on": "\u0395\u03bd\u03b5\u03c1\u03b3\u03bf\u03c0\u03bf\u03b9\u03b7\u03bc\u03ad\u03bd\u03bf" + } + }, + "title": "\u0397\u03bc\u03b5\u03c1\u03bf\u03bb\u03cc\u03b3\u03b9\u03bf" +} \ No newline at end of file diff --git a/homeassistant/components/calendar/translations/en.json b/homeassistant/components/calendar/translations/en.json new file mode 100644 index 0000000000000..1a454c483cdc7 --- /dev/null +++ b/homeassistant/components/calendar/translations/en.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "Off", + "on": "On" + } + }, + "title": "Calendar" +} \ No newline at end of file diff --git a/homeassistant/components/calendar/translations/es-419.json b/homeassistant/components/calendar/translations/es-419.json new file mode 100644 index 0000000000000..cab0bd1d81409 --- /dev/null +++ b/homeassistant/components/calendar/translations/es-419.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "Desactivado", + "on": "Activado" + } + }, + "title": "Calendario" +} \ No newline at end of file diff --git a/homeassistant/components/calendar/translations/es.json b/homeassistant/components/calendar/translations/es.json new file mode 100644 index 0000000000000..47da487c739ea --- /dev/null +++ b/homeassistant/components/calendar/translations/es.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "Apagado", + "on": "Encendido" + } + }, + "title": "Calendario" +} \ No newline at end of file diff --git a/homeassistant/components/calendar/translations/et.json b/homeassistant/components/calendar/translations/et.json new file mode 100644 index 0000000000000..bbdab07d5de85 --- /dev/null +++ b/homeassistant/components/calendar/translations/et.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "V\u00e4ljas", + "on": "Sees" + } + }, + "title": "Kalender" +} \ No newline at end of file diff --git a/homeassistant/components/calendar/translations/eu.json b/homeassistant/components/calendar/translations/eu.json new file mode 100644 index 0000000000000..22e0b3be84fd1 --- /dev/null +++ b/homeassistant/components/calendar/translations/eu.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "Itzalita", + "on": "Piztuta" + } + }, + "title": "Egutegia" +} \ No newline at end of file diff --git a/homeassistant/components/calendar/translations/fa.json b/homeassistant/components/calendar/translations/fa.json new file mode 100644 index 0000000000000..f6d09cd27ab37 --- /dev/null +++ b/homeassistant/components/calendar/translations/fa.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "\u063a\u06cc\u0631\u0641\u0639\u0627\u0644", + "on": "\u0641\u0639\u0627\u0644" + } + }, + "title": "\u062a\u0642\u0648\u06cc\u0645" +} \ No newline at end of file diff --git a/homeassistant/components/calendar/translations/fi.json b/homeassistant/components/calendar/translations/fi.json new file mode 100644 index 0000000000000..8aa704af01042 --- /dev/null +++ b/homeassistant/components/calendar/translations/fi.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "Pois p\u00e4\u00e4lt\u00e4", + "on": "P\u00e4\u00e4ll\u00e4" + } + }, + "title": "Kalenteri" +} \ No newline at end of file diff --git a/homeassistant/components/calendar/translations/fr.json b/homeassistant/components/calendar/translations/fr.json new file mode 100644 index 0000000000000..70aaa6f0292b7 --- /dev/null +++ b/homeassistant/components/calendar/translations/fr.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "Inactif", + "on": "Actif" + } + }, + "title": "Calendrier" +} \ No newline at end of file diff --git a/homeassistant/components/calendar/translations/gsw.json b/homeassistant/components/calendar/translations/gsw.json new file mode 100644 index 0000000000000..58d1042af2e07 --- /dev/null +++ b/homeassistant/components/calendar/translations/gsw.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "Us", + "on": "Ah" + } + }, + "title": "Kal\u00e4nder" +} \ No newline at end of file diff --git a/homeassistant/components/calendar/translations/he.json b/homeassistant/components/calendar/translations/he.json new file mode 100644 index 0000000000000..206528ef6a85c --- /dev/null +++ b/homeassistant/components/calendar/translations/he.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "\u05db\u05d1\u05d5\u05d9", + "on": "\u05d3\u05dc\u05d5\u05e7" + } + }, + "title": "\u05dc\u05d5\u05bc\u05d7\u05b7 \u05e9\u05c1\u05b8\u05e0\u05b8\u05d4" +} \ No newline at end of file diff --git a/homeassistant/components/calendar/translations/hi.json b/homeassistant/components/calendar/translations/hi.json new file mode 100644 index 0000000000000..5f1bd39058cbf --- /dev/null +++ b/homeassistant/components/calendar/translations/hi.json @@ -0,0 +1,8 @@ +{ + "state": { + "_": { + "off": "\u092c\u0902\u0926", + "on": "\u091a\u093e\u0932\u0942" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/calendar/translations/hr.json b/homeassistant/components/calendar/translations/hr.json new file mode 100644 index 0000000000000..4655814c097fc --- /dev/null +++ b/homeassistant/components/calendar/translations/hr.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "Isklju\u010den", + "on": "Uklju\u010den" + } + }, + "title": "Kalendar" +} \ No newline at end of file diff --git a/homeassistant/components/calendar/translations/hu.json b/homeassistant/components/calendar/translations/hu.json new file mode 100644 index 0000000000000..722f67aa095f0 --- /dev/null +++ b/homeassistant/components/calendar/translations/hu.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "Ki", + "on": "Be" + } + }, + "title": "Napt\u00e1r" +} \ No newline at end of file diff --git a/homeassistant/components/calendar/translations/hy.json b/homeassistant/components/calendar/translations/hy.json new file mode 100644 index 0000000000000..2bfad01c51257 --- /dev/null +++ b/homeassistant/components/calendar/translations/hy.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "\u0531\u0576\u057b\u0561\u057f\u057e\u0561\u056e", + "on": "\u0544\u056b\u0561\u0581\u0561\u056e" + } + }, + "title": "\u0555\u0580\u0561\u0581\u0578\u0582\u0575\u0581" +} \ No newline at end of file diff --git a/homeassistant/components/calendar/translations/id.json b/homeassistant/components/calendar/translations/id.json new file mode 100644 index 0000000000000..383a6ba77a13b --- /dev/null +++ b/homeassistant/components/calendar/translations/id.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "Off", + "on": "On" + } + }, + "title": "Kalender" +} \ No newline at end of file diff --git a/homeassistant/components/calendar/translations/is.json b/homeassistant/components/calendar/translations/is.json new file mode 100644 index 0000000000000..e693887c6e42f --- /dev/null +++ b/homeassistant/components/calendar/translations/is.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "\u00d3virkt", + "on": "Virkt" + } + }, + "title": "Dagatal" +} \ No newline at end of file diff --git a/homeassistant/components/calendar/translations/it.json b/homeassistant/components/calendar/translations/it.json new file mode 100644 index 0000000000000..f9dcc4d668e74 --- /dev/null +++ b/homeassistant/components/calendar/translations/it.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "Disattivo", + "on": "Attivo" + } + }, + "title": "Calendario" +} \ No newline at end of file diff --git a/homeassistant/components/calendar/translations/ja.json b/homeassistant/components/calendar/translations/ja.json new file mode 100644 index 0000000000000..07a3cd908e751 --- /dev/null +++ b/homeassistant/components/calendar/translations/ja.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "\u30aa\u30d5", + "on": "\u30aa\u30f3" + } + }, + "title": "\u30ab\u30ec\u30f3\u30c0\u30fc" +} \ No newline at end of file diff --git a/homeassistant/components/calendar/translations/ko.json b/homeassistant/components/calendar/translations/ko.json new file mode 100644 index 0000000000000..af8622be7d772 --- /dev/null +++ b/homeassistant/components/calendar/translations/ko.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "\uaebc\uc9d0", + "on": "\ucf1c\uc9d0" + } + }, + "title": "\uc77c\uc815" +} \ No newline at end of file diff --git a/homeassistant/components/calendar/translations/lb.json b/homeassistant/components/calendar/translations/lb.json new file mode 100644 index 0000000000000..df7b45c3636eb --- /dev/null +++ b/homeassistant/components/calendar/translations/lb.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "Aus", + "on": "Un" + } + }, + "title": "Kalenner" +} \ No newline at end of file diff --git a/homeassistant/components/calendar/translations/lt.json b/homeassistant/components/calendar/translations/lt.json new file mode 100644 index 0000000000000..3cf0e9b442d9f --- /dev/null +++ b/homeassistant/components/calendar/translations/lt.json @@ -0,0 +1,8 @@ +{ + "state": { + "_": { + "off": "I\u0161jungta", + "on": "\u012ejungta" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/calendar/translations/lv.json b/homeassistant/components/calendar/translations/lv.json new file mode 100644 index 0000000000000..25a5b3d2733d5 --- /dev/null +++ b/homeassistant/components/calendar/translations/lv.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "Izsl\u0113gts", + "on": "Iesl\u0113gts" + } + }, + "title": "Kalend\u0101rs" +} \ No newline at end of file diff --git a/homeassistant/components/calendar/translations/nb.json b/homeassistant/components/calendar/translations/nb.json new file mode 100644 index 0000000000000..516a3b7d443cd --- /dev/null +++ b/homeassistant/components/calendar/translations/nb.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "Av", + "on": "P\u00e5" + } + }, + "title": "Kalender" +} \ No newline at end of file diff --git a/homeassistant/components/calendar/translations/nl.json b/homeassistant/components/calendar/translations/nl.json new file mode 100644 index 0000000000000..af586da6f9691 --- /dev/null +++ b/homeassistant/components/calendar/translations/nl.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "Uit", + "on": "Aan" + } + }, + "title": "Kalender" +} \ No newline at end of file diff --git a/homeassistant/components/calendar/translations/nn.json b/homeassistant/components/calendar/translations/nn.json new file mode 100644 index 0000000000000..e72b238e1282c --- /dev/null +++ b/homeassistant/components/calendar/translations/nn.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "Av", + "on": "P\u00e5" + } + }, + "title": "Kalendar" +} \ No newline at end of file diff --git a/homeassistant/components/calendar/translations/no.json b/homeassistant/components/calendar/translations/no.json new file mode 100644 index 0000000000000..ba22ae540fca0 --- /dev/null +++ b/homeassistant/components/calendar/translations/no.json @@ -0,0 +1,3 @@ +{ + "title": "Kalender" +} \ No newline at end of file diff --git a/homeassistant/components/calendar/translations/pl.json b/homeassistant/components/calendar/translations/pl.json new file mode 100644 index 0000000000000..94ac2fd244d86 --- /dev/null +++ b/homeassistant/components/calendar/translations/pl.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "wy\u0142\u0105czony", + "on": "w\u0142\u0105czony" + } + }, + "title": "Kalendarz" +} \ No newline at end of file diff --git a/homeassistant/components/calendar/translations/pt-BR.json b/homeassistant/components/calendar/translations/pt-BR.json new file mode 100644 index 0000000000000..fca0b1a103bd9 --- /dev/null +++ b/homeassistant/components/calendar/translations/pt-BR.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "Inativo", + "on": "Ativo" + } + }, + "title": "Calend\u00e1rio" +} \ No newline at end of file diff --git a/homeassistant/components/calendar/translations/pt.json b/homeassistant/components/calendar/translations/pt.json new file mode 100644 index 0000000000000..0d47e41440b50 --- /dev/null +++ b/homeassistant/components/calendar/translations/pt.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "Desligado", + "on": "Ligado" + } + }, + "title": "Calend\u00e1rio" +} \ No newline at end of file diff --git a/homeassistant/components/calendar/translations/ro.json b/homeassistant/components/calendar/translations/ro.json new file mode 100644 index 0000000000000..6433538a1e169 --- /dev/null +++ b/homeassistant/components/calendar/translations/ro.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "Oprit", + "on": "Pornit" + } + }, + "title": "Calendar" +} \ No newline at end of file diff --git a/homeassistant/components/calendar/translations/ru.json b/homeassistant/components/calendar/translations/ru.json new file mode 100644 index 0000000000000..0a95a70ae0627 --- /dev/null +++ b/homeassistant/components/calendar/translations/ru.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "\u0412\u044b\u043a\u043b", + "on": "\u0412\u043a\u043b" + } + }, + "title": "\u041a\u0430\u043b\u0435\u043d\u0434\u0430\u0440\u044c" +} \ No newline at end of file diff --git a/homeassistant/components/calendar/translations/sk.json b/homeassistant/components/calendar/translations/sk.json new file mode 100644 index 0000000000000..8bda47c7ac673 --- /dev/null +++ b/homeassistant/components/calendar/translations/sk.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "Neakt\u00edvny", + "on": "Akt\u00edvny" + } + }, + "title": "Kalend\u00e1r" +} \ No newline at end of file diff --git a/homeassistant/components/calendar/translations/sl.json b/homeassistant/components/calendar/translations/sl.json new file mode 100644 index 0000000000000..bd917673e7851 --- /dev/null +++ b/homeassistant/components/calendar/translations/sl.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "Izklju\u010den", + "on": "Vklopljen" + } + }, + "title": "Koledar" +} \ No newline at end of file diff --git a/homeassistant/components/calendar/translations/sv.json b/homeassistant/components/calendar/translations/sv.json new file mode 100644 index 0000000000000..516a3b7d443cd --- /dev/null +++ b/homeassistant/components/calendar/translations/sv.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "Av", + "on": "P\u00e5" + } + }, + "title": "Kalender" +} \ No newline at end of file diff --git a/homeassistant/components/calendar/translations/ta.json b/homeassistant/components/calendar/translations/ta.json new file mode 100644 index 0000000000000..27ed507378f35 --- /dev/null +++ b/homeassistant/components/calendar/translations/ta.json @@ -0,0 +1,8 @@ +{ + "state": { + "_": { + "off": "\u0b86\u0b83\u0baa\u0bcd", + "on": "\u0b86\u0ba9\u0bcd " + } + } +} \ No newline at end of file diff --git a/homeassistant/components/calendar/translations/te.json b/homeassistant/components/calendar/translations/te.json new file mode 100644 index 0000000000000..5a7f88b221eca --- /dev/null +++ b/homeassistant/components/calendar/translations/te.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "\u0c06\u0c2b\u0c4d", + "on": "\u0c06\u0c28\u0c4d" + } + }, + "title": "\u0c15\u0c4d\u0c2f\u0c3e\u0c32\u0c46\u0c02\u0c21\u0c30\u0c41" +} \ No newline at end of file diff --git a/homeassistant/components/calendar/translations/th.json b/homeassistant/components/calendar/translations/th.json new file mode 100644 index 0000000000000..552424760c8bc --- /dev/null +++ b/homeassistant/components/calendar/translations/th.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "\u0e1b\u0e34\u0e14", + "on": "\u0e40\u0e1b\u0e34\u0e14" + } + }, + "title": "\u0e1b\u0e0f\u0e34\u0e17\u0e34\u0e19" +} \ No newline at end of file diff --git a/homeassistant/components/calendar/translations/tr.json b/homeassistant/components/calendar/translations/tr.json new file mode 100644 index 0000000000000..3925c50dd41b0 --- /dev/null +++ b/homeassistant/components/calendar/translations/tr.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "Kapal\u0131", + "on": "A\u00e7\u0131k" + } + }, + "title": "Takvim" +} \ No newline at end of file diff --git a/homeassistant/components/calendar/translations/uk.json b/homeassistant/components/calendar/translations/uk.json new file mode 100644 index 0000000000000..a456fad0f79e8 --- /dev/null +++ b/homeassistant/components/calendar/translations/uk.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "\u0412\u0438\u043c\u043a\u043d\u0435\u043d\u043e", + "on": "\u0423\u0432\u0456\u043c\u043a\u043d\u0435\u043d\u043e" + } + }, + "title": "\u041a\u0430\u043b\u0435\u043d\u0434\u0430\u0440" +} \ No newline at end of file diff --git a/homeassistant/components/calendar/translations/vi.json b/homeassistant/components/calendar/translations/vi.json new file mode 100644 index 0000000000000..82c3728ce0097 --- /dev/null +++ b/homeassistant/components/calendar/translations/vi.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "T\u1eaft", + "on": "B\u1eadt" + } + }, + "title": "L\u1ecbch" +} \ No newline at end of file diff --git a/homeassistant/components/calendar/translations/zh-Hans.json b/homeassistant/components/calendar/translations/zh-Hans.json new file mode 100644 index 0000000000000..8ac81c99fa5ed --- /dev/null +++ b/homeassistant/components/calendar/translations/zh-Hans.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "\u5173", + "on": "\u5f00" + } + }, + "title": "\u65e5\u5386" +} \ No newline at end of file diff --git a/homeassistant/components/calendar/translations/zh-Hant.json b/homeassistant/components/calendar/translations/zh-Hant.json new file mode 100644 index 0000000000000..98955bc45b211 --- /dev/null +++ b/homeassistant/components/calendar/translations/zh-Hant.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "\u95dc\u9589", + "on": "\u958b\u555f" + } + }, + "title": "\u884c\u4e8b\u66c6" +} \ No newline at end of file diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 7a37dffe3b826..2862805a333ed 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -4,93 +4,114 @@ import collections from contextlib import suppress from datetime import timedelta -import logging import hashlib +import logging from random import SystemRandom -import attr from aiohttp import web import async_timeout +import attr 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 ( # noqa - PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE) -from homeassistant.components.http import HomeAssistantView, KEY_AUTHENTICATED +from homeassistant.components import websocket_api +from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView from homeassistant.components.media_player.const import ( - ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE, - SERVICE_PLAY_MEDIA, DOMAIN as DOMAIN_MP) + ATTR_MEDIA_CONTENT_ID, + ATTR_MEDIA_CONTENT_TYPE, + DOMAIN as DOMAIN_MP, + SERVICE_PLAY_MEDIA, +) 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 + CONF_DURATION, + CONF_LOOKBACK, + CONF_STREAM_SOURCE, + DOMAIN as DOMAIN_STREAM, + FORMAT_CONTENT_TYPE, + OUTPUT_FORMATS, + SERVICE_RECORD, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_FILENAME, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.core import callback +from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.config_validation import ( # noqa: F401 + PLATFORM_SCHEMA, + PLATFORM_SCHEMA_BASE, +) +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.loader import bind_hass from homeassistant.setup import async_when_setup -from .const import DOMAIN, DATA_CAMERA_PREFS +from .const import DATA_CAMERA_PREFS, DOMAIN from .prefs import CameraPreferences +# mypy: allow-untyped-calls, allow-untyped-defs + _LOGGER = logging.getLogger(__name__) -SERVICE_ENABLE_MOTION = 'enable_motion_detection' -SERVICE_DISABLE_MOTION = 'disable_motion_detection' -SERVICE_SNAPSHOT = 'snapshot' -SERVICE_PLAY_STREAM = 'play_stream' +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 + '.{}' +ENTITY_ID_FORMAT = DOMAIN + ".{}" -ATTR_FILENAME = 'filename' -ATTR_MEDIA_PLAYER = 'media_player' -ATTR_FORMAT = 'format' +ATTR_FILENAME = "filename" +ATTR_MEDIA_PLAYER = "media_player" +ATTR_FORMAT = "format" -STATE_RECORDING = 'recording' -STATE_STREAMING = 'streaming' -STATE_IDLE = 'idle' +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}' +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_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_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_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), -}) +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 -}) +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 @@ -107,12 +128,21 @@ async def async_request_stream(hass, entity_id, fmt): camera = _get_camera_from_entity_id(hass, entity_id) camera_prefs = hass.data[DATA_CAMERA_PREFS].get(entity_id) - if not camera.stream_source: - raise HomeAssistantError("{} does not support play stream service" - .format(camera.entity_id)) + async with async_timeout.timeout(10): + source = await camera.stream_source() - return request_stream(hass, camera.stream_source, fmt=fmt, - keepalive=camera_prefs.preload_stream) + if not source: + raise HomeAssistantError( + f"{camera.entity_id} does not support play stream service" + ) + + return request_stream( + hass, + source, + fmt=fmt, + keepalive=camera_prefs.preload_stream, + options=camera.stream_options, + ) @bind_hass @@ -121,13 +151,13 @@ async def async_get_image(hass, entity_id, timeout=10): camera = _get_camera_from_entity_id(hass, entity_id) with suppress(asyncio.CancelledError, asyncio.TimeoutError): - with async_timeout.timeout(timeout, loop=hass.loop): + async with async_timeout.timeout(timeout): image = await camera.async_camera_image() if image: return Image(camera.content_type, image) - raise HomeAssistantError('Unable to get image') + raise HomeAssistantError("Unable to get image") @bind_hass @@ -144,18 +174,21 @@ async def async_get_still_stream(request, image_cb, content_type, interval): This method must be run in the event loop. """ response = web.StreamResponse() - response.content_type = ('multipart/x-mixed-replace; ' - 'boundary=--frameboundary') + 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') + 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 @@ -183,23 +216,24 @@ def _get_camera_from_entity_id(hass, entity_id): component = hass.data.get(DOMAIN) if component is None: - raise HomeAssistantError('Camera component not set up') + raise HomeAssistantError("Camera integration not set up") camera = component.get_entity(entity_id) if camera is None: - raise HomeAssistantError('Camera not found') + raise HomeAssistantError("Camera not found") if not camera.is_on: - raise HomeAssistantError('Camera is off') + 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) + component = hass.data[DOMAIN] = EntityComponent( + _LOGGER, DOMAIN, hass, SCAN_INTERVAL + ) prefs = CameraPreferences(hass) await prefs.async_initialize() @@ -208,21 +242,27 @@ async def async_setup(hass, config): 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 + 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) + 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 camera.stream_source and camera_prefs.preload_stream: - request_stream(hass, camera.stream_source, keepalive=True) + 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, options=camera.stream_options) async_when_setup(hass, DOMAIN_STREAM, preload_stream) @@ -231,38 +271,32 @@ 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()) + entity.async_write_ha_state() - hass.helpers.event.async_track_time_interval( - update_tokens, TOKEN_CHANGE_INTERVAL) + 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' + 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' + SERVICE_DISABLE_MOTION, CAMERA_SERVICE_SCHEMA, "async_disable_motion_detection" ) component.async_register_entity_service( - SERVICE_TURN_OFF, CAMERA_SERVICE_SCHEMA, - 'async_turn_off' + SERVICE_TURN_OFF, CAMERA_SERVICE_SCHEMA, "async_turn_off" ) component.async_register_entity_service( - SERVICE_TURN_ON, CAMERA_SERVICE_SCHEMA, - 'async_turn_on' + SERVICE_TURN_ON, CAMERA_SERVICE_SCHEMA, "async_turn_on" ) component.async_register_entity_service( - SERVICE_SNAPSHOT, CAMERA_SERVICE_SNAPSHOT, - async_handle_snapshot_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 + 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 + SERVICE_RECORD, CAMERA_SERVICE_RECORD, async_handle_record_service ) return True @@ -284,8 +318,9 @@ class Camera(Entity): def __init__(self): """Initialize a camera.""" self.is_streaming = False + self.stream_options = {} self.content_type = DEFAULT_CONTENT_TYPE - self.access_tokens = collections.deque([], 2) + self.access_tokens: collections.deque = collections.deque([], 2) self.async_update_token() @property @@ -328,8 +363,7 @@ def frame_interval(self): """Return the interval between frames of the mjpeg stream.""" return 0.5 - @property - def stream_source(self): + async def stream_source(self): """Return the source of the stream.""" return None @@ -337,31 +371,23 @@ def camera_image(self): """Return bytes of camera image.""" raise NotImplementedError() - @callback - def async_camera_image(self): - """Return bytes of camera image. - - This method must be run in the event loop and returns a coroutine. - """ - return self.hass.async_add_job(self.camera_image) + async def async_camera_image(self): + """Return bytes of camera image.""" + return await self.hass.async_add_executor_job(self.camera_image) 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. - """ - return await async_get_still_stream(request, self.async_camera_image, - self.content_type, interval) + """Generate an HTTP MJPEG stream from camera images.""" + 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 + This method can be overridden by camera platforms 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) + return await self.handle_async_still_stream(request, self.frame_interval) @property def state(self): @@ -381,53 +407,47 @@ def turn_off(self): """Turn off camera.""" raise NotImplementedError() - @callback - def async_turn_off(self): + async def async_turn_off(self): """Turn off camera.""" - return self.hass.async_add_job(self.turn_off) + await self.hass.async_add_executor_job(self.turn_off) def turn_on(self): """Turn off camera.""" raise NotImplementedError() - @callback - def async_turn_on(self): + async def async_turn_on(self): """Turn off camera.""" - return self.hass.async_add_job(self.turn_on) + await self.hass.async_add_executor_job(self.turn_on) def enable_motion_detection(self): """Enable motion detection in the camera.""" raise NotImplementedError() - @callback - def async_enable_motion_detection(self): + async def async_enable_motion_detection(self): """Call the job and enable motion detection.""" - return self.hass.async_add_job(self.enable_motion_detection) + await self.hass.async_add_executor_job(self.enable_motion_detection) def disable_motion_detection(self): """Disable motion detection in camera.""" raise NotImplementedError() - @callback - def async_disable_motion_detection(self): + async def async_disable_motion_detection(self): """Call the job and disable motion detection.""" - return self.hass.async_add_job(self.disable_motion_detection) + await self.hass.async_add_executor_job(self.disable_motion_detection) @property def state_attributes(self): """Return the camera state attributes.""" - attrs = { - 'access_token': self.access_tokens[-1], - } + attrs = {"access_token": self.access_tokens[-1]} if self.model: - attrs['model_name'] = self.model + attrs["model_name"] = self.model if self.brand: - attrs['brand'] = self.brand + attrs["brand"] = self.brand if self.motion_detection_enabled: - attrs['motion_detection'] = self.motion_detection_enabled + attrs["motion_detection"] = self.motion_detection_enabled return attrs @@ -435,8 +455,8 @@ def state_attributes(self): def async_update_token(self): """Update the used token.""" self.access_tokens.append( - hashlib.sha256( - _RND.getrandbits(256).to_bytes(32, 'little')).hexdigest()) + hashlib.sha256(_RND.getrandbits(256).to_bytes(32, "little")).hexdigest() + ) class CameraView(HomeAssistantView): @@ -455,14 +475,16 @@ async def get(self, request, entity_id): if camera is None: raise web.HTTPNotFound() - authenticated = (request[KEY_AUTHENTICATED] or - request.query.get('token') in camera.access_tokens) + authenticated = ( + request[KEY_AUTHENTICATED] + or request.query.get("token") in camera.access_tokens + ) if not authenticated: raise web.HTTPUnauthorized() if not camera.is_on: - _LOGGER.debug('Camera is off.') + _LOGGER.debug("Camera is off.") raise web.HTTPServiceUnavailable() return await self.handle(request, camera) @@ -475,18 +497,17 @@ async def handle(self, request, camera): 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" async def handle(self, request, camera): """Serve camera image.""" with suppress(asyncio.CancelledError, asyncio.TimeoutError): - with async_timeout.timeout(10, loop=request.app['hass'].loop): + async with async_timeout.timeout(10): image = await camera.async_camera_image() if image: - return web.Response(body=image, - content_type=camera.content_type) + return web.Response(body=image, content_type=camera.content_type) raise web.HTTPInternalServerError() @@ -494,21 +515,20 @@ async def handle(self, request, camera): 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" async def handle(self, request, camera): """Serve camera stream, possibly with interval.""" - interval = request.query.get('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')) + interval = float(request.query.get("interval")) if interval < MIN_STREAM_INTERVAL: - raise ValueError("Stream interval must be be > {}" - .format(MIN_STREAM_INTERVAL)) + raise ValueError(f"Stream interval must be be > {MIN_STREAM_INTERVAL}") return await camera.handle_async_still_stream(request, interval) except ValueError: raise web.HTTPBadRequest() @@ -520,77 +540,98 @@ async def websocket_camera_thumbnail(hass, connection, msg): Async friendly. """ + _LOGGER.warning("The websocket command 'camera_thumbnail' has been deprecated.") try: - image = await async_get_image(hass, msg['entity_id']) - connection.send_message(websocket_api.result_message( - msg['id'], { - 'content_type': image.content_type, - 'content': base64.b64encode(image.content).decode('utf-8') - } - )) + 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')) + 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), -}) +@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'] + entity_id = msg["entity_id"] camera = _get_camera_from_entity_id(hass, entity_id) camera_prefs = hass.data[DATA_CAMERA_PREFS].get(entity_id) - if not camera.stream_source: - raise HomeAssistantError("{} does not support play stream service" - .format(camera.entity_id)) - - fmt = msg['format'] - url = request_stream(hass, camera.stream_source, fmt=fmt, - keepalive=camera_prefs.preload_stream) - connection.send_result(msg['id'], {'url': url}) + async with async_timeout.timeout(10): + source = await camera.stream_source() + + if not source: + raise HomeAssistantError( + f"{camera.entity_id} does not support play stream service" + ) + + fmt = msg["format"] + url = request_stream( + hass, + source, + fmt=fmt, + keepalive=camera_prefs.preload_stream, + options=camera.stream_options, + ) + connection.send_result(msg["id"], {"url": url}) except HomeAssistantError as ex: - _LOGGER.error(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', str(ex)) + 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, -}) +@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()) + 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, -}) +@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') + 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()) + connection.send_result(msg["id"], prefs.get(entity_id).as_dict()) async def async_handle_snapshot_service(camera, service): @@ -599,72 +640,79 @@ async def async_handle_snapshot_service(camera, service): filename = service.data[ATTR_FILENAME] filename.hass = hass - snapshot_file = filename.async_render( - variables={ATTR_ENTITY_ID: camera}) + 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) + _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: + 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) + 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.""" - if not camera.stream_source: - raise HomeAssistantError("{} does not support play stream service" - .format(camera.entity_id)) + async with async_timeout.timeout(10): + source = await camera.stream_source() + + if not source: + raise HomeAssistantError( + f"{camera.entity_id} does not support play stream service" + ) 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, camera.stream_source, fmt=fmt, - keepalive=camera_prefs.preload_stream) + url = request_stream( + hass, + source, + fmt=fmt, + keepalive=camera_prefs.preload_stream, + options=camera.stream_options, + ) 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] + ATTR_MEDIA_CONTENT_ID: f"{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) + 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.""" - if not camera.stream_source: - raise HomeAssistantError("{} does not support record service" - .format(camera.entity_id)) + async with async_timeout.timeout(10): + source = await camera.stream_source() + + if not source: + raise HomeAssistantError(f"{camera.entity_id} does not support record service") hass = camera.hass filename = call.data[CONF_FILENAME] filename.hass = hass - video_path = filename.async_render( - variables={ATTR_ENTITY_ID: camera}) + video_path = filename.async_render(variables={ATTR_ENTITY_ID: camera}) data = { - CONF_STREAM_SOURCE: camera.stream_source, + 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) + DOMAIN_STREAM, SERVICE_RECORD, data, blocking=True, context=call.context + ) diff --git a/homeassistant/components/camera/const.py b/homeassistant/components/camera/const.py index f87ca47460e64..563f0554f0fa6 100644 --- a/homeassistant/components/camera/const.py +++ b/homeassistant/components/camera/const.py @@ -1,6 +1,6 @@ """Constants for Camera component.""" -DOMAIN = 'camera' +DOMAIN = "camera" -DATA_CAMERA_PREFS = 'camera_prefs' +DATA_CAMERA_PREFS = "camera_prefs" -PREF_PRELOAD_STREAM = 'preload_stream' +PREF_PRELOAD_STREAM = "preload_stream" diff --git a/homeassistant/components/camera/manifest.json b/homeassistant/components/camera/manifest.json index 3af6a15ca5249..ed8e10c1956f5 100644 --- a/homeassistant/components/camera/manifest.json +++ b/homeassistant/components/camera/manifest.json @@ -1,13 +1,9 @@ { "domain": "camera", "name": "Camera", - "documentation": "https://www.home-assistant.io/components/camera", - "requirements": [], - "dependencies": [ - "http" - ], - "after_dependencies": [ - "stream" - ], - "codeowners": [] + "documentation": "https://www.home-assistant.io/integrations/camera", + "dependencies": ["http"], + "after_dependencies": ["media_player"], + "codeowners": [], + "quality_scale": "internal" } diff --git a/homeassistant/components/camera/prefs.py b/homeassistant/components/camera/prefs.py index 927929bdf6eef..ae182c62dc638 100644 --- a/homeassistant/components/camera/prefs.py +++ b/homeassistant/components/camera/prefs.py @@ -1,6 +1,8 @@ """Preference management for camera component.""" from .const import DOMAIN, PREF_PRELOAD_STREAM +# mypy: allow-untyped-defs, no-check-untyped-defs + STORAGE_KEY = DOMAIN STORAGE_VERSION = 1 _UNDEF = object() @@ -41,15 +43,14 @@ async def async_initialize(self): self._prefs = prefs - async def async_update(self, entity_id, *, preload_stream=_UNDEF, - stream_options=_UNDEF): + 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), - ): + for key, value in ((PREF_PRELOAD_STREAM, preload_stream),): if value is not _UNDEF: self._prefs[entity_id][key] = value diff --git a/homeassistant/components/camera/services.yaml b/homeassistant/components/camera/services.yaml index 4c2d89db86d2d..14f9497698462 100644 --- a/homeassistant/components/camera/services.yaml +++ b/homeassistant/components/camera/services.yaml @@ -5,61 +5,61 @@ turn_off: fields: entity_id: description: Entity id. - example: 'camera.living_room' + example: "camera.living_room" turn_on: description: Turn on camera. fields: entity_id: description: Entity id. - example: 'camera.living_room' + 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' + 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' + 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' + example: "camera.living_room_camera" filename: description: Template of a Filename. Variable is entity_id. - example: '/tmp/snapshot_{{ 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' + example: "camera.living_room_camera" media_player: description: Name(s) of media player to stream to. - example: 'media_player.living_room_tv' + example: "media_player.living_room_tv" format: description: (Optional) Stream format supported by media player. - example: 'hls' + example: "hls" record: description: Record live camera feed. fields: entity_id: description: Name of entities to record. - example: 'camera.living_room_camera' + example: "camera.living_room_camera" filename: description: Template of a Filename. Variable is entity_id. Must be mp4. - example: '/tmp/snapshot_{{ entity_id }}.mp4' + example: "/tmp/snapshot_{{ entity_id }}.mp4" duration: description: (Optional) Target recording length (in seconds). default: 30 @@ -67,29 +67,3 @@ record: 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/strings.json b/homeassistant/components/camera/strings.json new file mode 100644 index 0000000000000..3b8767ec8cd78 --- /dev/null +++ b/homeassistant/components/camera/strings.json @@ -0,0 +1,10 @@ +{ + "title": "Camera", + "state": { + "_": { + "recording": "Recording", + "streaming": "Streaming", + "idle": "[%key:common::state::idle%]" + } + } +} diff --git a/homeassistant/components/camera/translations/af.json b/homeassistant/components/camera/translations/af.json new file mode 100644 index 0000000000000..1696c9045bbd1 --- /dev/null +++ b/homeassistant/components/camera/translations/af.json @@ -0,0 +1,10 @@ +{ + "state": { + "_": { + "idle": "Onaktief", + "recording": "Opname", + "streaming": "Stroming" + } + }, + "title": "Kamera" +} \ No newline at end of file diff --git a/homeassistant/components/camera/translations/ar.json b/homeassistant/components/camera/translations/ar.json new file mode 100644 index 0000000000000..3cd28abe6189f --- /dev/null +++ b/homeassistant/components/camera/translations/ar.json @@ -0,0 +1,10 @@ +{ + "state": { + "_": { + "idle": "\u062e\u0627\u0645\u0644", + "recording": "\u062c\u0627\u0631\u064a \u0627\u0644\u062a\u0633\u062c\u064a\u0644", + "streaming": "\u062c\u0627\u0631\u064a \u0627\u0644\u0628\u062b" + } + }, + "title": "\u0643\u0627\u0645\u064a\u0631\u0627" +} \ No newline at end of file diff --git a/homeassistant/components/camera/translations/bg.json b/homeassistant/components/camera/translations/bg.json new file mode 100644 index 0000000000000..b15bbc2f15307 --- /dev/null +++ b/homeassistant/components/camera/translations/bg.json @@ -0,0 +1,10 @@ +{ + "state": { + "_": { + "idle": "\u041d\u0435 \u0437\u0430\u043f\u0438\u0441\u0432\u0430", + "recording": "\u0417\u0430\u043f\u0438\u0441\u0432\u0430\u043d\u0435", + "streaming": "\u041f\u0440\u0435\u0434\u0430\u0432\u0430" + } + }, + "title": "\u041a\u0430\u043c\u0435\u0440\u0430" +} \ No newline at end of file diff --git a/homeassistant/components/camera/translations/bs.json b/homeassistant/components/camera/translations/bs.json new file mode 100644 index 0000000000000..746cbf74d1ee3 --- /dev/null +++ b/homeassistant/components/camera/translations/bs.json @@ -0,0 +1,10 @@ +{ + "state": { + "_": { + "idle": "Besposlen", + "recording": "Snimanje", + "streaming": "Predaja slike" + } + }, + "title": "Kamera" +} \ No newline at end of file diff --git a/homeassistant/components/camera/translations/ca.json b/homeassistant/components/camera/translations/ca.json new file mode 100644 index 0000000000000..0a7b029aced02 --- /dev/null +++ b/homeassistant/components/camera/translations/ca.json @@ -0,0 +1,10 @@ +{ + "state": { + "_": { + "idle": "Inactiu", + "recording": "Enregistrant", + "streaming": "Transmetent v\u00eddeo" + } + }, + "title": "C\u00e0mera" +} \ No newline at end of file diff --git a/homeassistant/components/camera/translations/cs.json b/homeassistant/components/camera/translations/cs.json new file mode 100644 index 0000000000000..3a310779d7974 --- /dev/null +++ b/homeassistant/components/camera/translations/cs.json @@ -0,0 +1,10 @@ +{ + "state": { + "_": { + "idle": "Ne\u010dinn\u00fd", + "recording": "Z\u00e1znam", + "streaming": "Streamov\u00e1n\u00ed" + } + }, + "title": "Kamera" +} \ No newline at end of file diff --git a/homeassistant/components/camera/translations/cy.json b/homeassistant/components/camera/translations/cy.json new file mode 100644 index 0000000000000..13363333d8a60 --- /dev/null +++ b/homeassistant/components/camera/translations/cy.json @@ -0,0 +1,10 @@ +{ + "state": { + "_": { + "idle": "Segur", + "recording": "Recordio", + "streaming": "Ffrydio" + } + }, + "title": "Camera" +} \ No newline at end of file diff --git a/homeassistant/components/camera/translations/da.json b/homeassistant/components/camera/translations/da.json new file mode 100644 index 0000000000000..41bdb7f4edd24 --- /dev/null +++ b/homeassistant/components/camera/translations/da.json @@ -0,0 +1,10 @@ +{ + "state": { + "_": { + "idle": "Inaktiv", + "recording": "Optager", + "streaming": "Streamer" + } + }, + "title": "Kamera" +} \ No newline at end of file diff --git a/homeassistant/components/camera/translations/de.json b/homeassistant/components/camera/translations/de.json new file mode 100644 index 0000000000000..d6f409f1a0e31 --- /dev/null +++ b/homeassistant/components/camera/translations/de.json @@ -0,0 +1,10 @@ +{ + "state": { + "_": { + "idle": "Unt\u00e4tig", + "recording": "Aufnehmen", + "streaming": "\u00dcbertr\u00e4gt" + } + }, + "title": "Kamera" +} \ No newline at end of file diff --git a/homeassistant/components/camera/translations/el.json b/homeassistant/components/camera/translations/el.json new file mode 100644 index 0000000000000..56a57402b4d29 --- /dev/null +++ b/homeassistant/components/camera/translations/el.json @@ -0,0 +1,10 @@ +{ + "state": { + "_": { + "idle": "\u0391\u03b4\u03c1\u03b1\u03bd\u03ad\u03c2", + "recording": "\u039a\u03b1\u03c4\u03b1\u03b3\u03c1\u03ac\u03c6\u03b5\u03b9", + "streaming": "\u039c\u03b5\u03c4\u03ac\u03b4\u03bf\u03c3\u03b7 \u03a1\u03bf\u03ae\u03c2" + } + }, + "title": "\u039a\u03ac\u03bc\u03b5\u03c1\u03b1" +} \ No newline at end of file diff --git a/homeassistant/components/camera/translations/en.json b/homeassistant/components/camera/translations/en.json new file mode 100644 index 0000000000000..f0e1ec40a9c92 --- /dev/null +++ b/homeassistant/components/camera/translations/en.json @@ -0,0 +1,10 @@ +{ + "state": { + "_": { + "idle": "Idle", + "recording": "Recording", + "streaming": "Streaming" + } + }, + "title": "Camera" +} \ No newline at end of file diff --git a/homeassistant/components/camera/translations/es-419.json b/homeassistant/components/camera/translations/es-419.json new file mode 100644 index 0000000000000..4e00ed763779f --- /dev/null +++ b/homeassistant/components/camera/translations/es-419.json @@ -0,0 +1,10 @@ +{ + "state": { + "_": { + "idle": "Inactivo", + "recording": "Grabando", + "streaming": "Streaming" + } + }, + "title": "C\u00e1mara" +} \ No newline at end of file diff --git a/homeassistant/components/camera/translations/es.json b/homeassistant/components/camera/translations/es.json new file mode 100644 index 0000000000000..54f22812cf2e7 --- /dev/null +++ b/homeassistant/components/camera/translations/es.json @@ -0,0 +1,10 @@ +{ + "state": { + "_": { + "idle": "Inactivo", + "recording": "Grabando", + "streaming": "Transmitiendo" + } + }, + "title": "C\u00e1mara" +} \ No newline at end of file diff --git a/homeassistant/components/camera/translations/et.json b/homeassistant/components/camera/translations/et.json new file mode 100644 index 0000000000000..1d33a9b1cafbe --- /dev/null +++ b/homeassistant/components/camera/translations/et.json @@ -0,0 +1,10 @@ +{ + "state": { + "_": { + "idle": "Ootel", + "recording": "Salvestab", + "streaming": "Voogedastab" + } + }, + "title": "Kaamera" +} \ No newline at end of file diff --git a/homeassistant/components/camera/translations/eu.json b/homeassistant/components/camera/translations/eu.json new file mode 100644 index 0000000000000..e470b6d13551e --- /dev/null +++ b/homeassistant/components/camera/translations/eu.json @@ -0,0 +1,8 @@ +{ + "state": { + "_": { + "recording": "Grabatzen" + } + }, + "title": "Kamera" +} \ No newline at end of file diff --git a/homeassistant/components/camera/translations/fa.json b/homeassistant/components/camera/translations/fa.json new file mode 100644 index 0000000000000..5d8020b55ea4e --- /dev/null +++ b/homeassistant/components/camera/translations/fa.json @@ -0,0 +1,10 @@ +{ + "state": { + "_": { + "idle": "\u0628\u06cc\u06a9\u0627\u0631", + "recording": "\u062f\u0631 \u062d\u0627\u0644 \u0636\u0628\u0637", + "streaming": "\u062f\u0631 \u062d\u0627\u0644 \u067e\u062e\u0634" + } + }, + "title": "\u062f\u0648\u0631\u0628\u06cc\u0646" +} \ No newline at end of file diff --git a/homeassistant/components/camera/translations/fi.json b/homeassistant/components/camera/translations/fi.json new file mode 100644 index 0000000000000..5fe10682a217e --- /dev/null +++ b/homeassistant/components/camera/translations/fi.json @@ -0,0 +1,10 @@ +{ + "state": { + "_": { + "idle": "Lepotilassa", + "recording": "Tallentaa", + "streaming": "Toistaa" + } + }, + "title": "Kamera" +} \ No newline at end of file diff --git a/homeassistant/components/camera/translations/fr.json b/homeassistant/components/camera/translations/fr.json new file mode 100644 index 0000000000000..d4f5cd31afcde --- /dev/null +++ b/homeassistant/components/camera/translations/fr.json @@ -0,0 +1,10 @@ +{ + "state": { + "_": { + "idle": "En veille", + "recording": "Enregistrement", + "streaming": "Diffusion en cours" + } + }, + "title": "Cam\u00e9ra" +} \ No newline at end of file diff --git a/homeassistant/components/camera/translations/gsw.json b/homeassistant/components/camera/translations/gsw.json new file mode 100644 index 0000000000000..5c09ff84d57dd --- /dev/null +++ b/homeassistant/components/camera/translations/gsw.json @@ -0,0 +1,10 @@ +{ + "state": { + "_": { + "idle": "L\u00e4\u00e4rlauf", + "recording": "Nimt uf", + "streaming": "Streamt" + } + }, + "title": "Kamera" +} \ No newline at end of file diff --git a/homeassistant/components/camera/translations/he.json b/homeassistant/components/camera/translations/he.json new file mode 100644 index 0000000000000..ccca3a7909966 --- /dev/null +++ b/homeassistant/components/camera/translations/he.json @@ -0,0 +1,10 @@ +{ + "state": { + "_": { + "idle": "\u05de\u05d7\u05db\u05d4", + "recording": "\u05de\u05e7\u05dc\u05d9\u05d8", + "streaming": "\u05de\u05d6\u05e8\u05d9\u05dd" + } + }, + "title": "\u05de\u05b7\u05e6\u05dc\u05b5\u05de\u05b8\u05d4" +} \ No newline at end of file diff --git a/homeassistant/components/camera/translations/hi.json b/homeassistant/components/camera/translations/hi.json new file mode 100644 index 0000000000000..376072b975993 --- /dev/null +++ b/homeassistant/components/camera/translations/hi.json @@ -0,0 +1,8 @@ +{ + "state": { + "_": { + "recording": "\u0930\u093f\u0915\u0949\u0930\u094d\u0921\u093f\u0902\u0917", + "streaming": "\u0938\u094d\u091f\u094d\u0930\u0940\u092e\u093f\u0902\u0917" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/camera/translations/hr.json b/homeassistant/components/camera/translations/hr.json new file mode 100644 index 0000000000000..40d11226a52f4 --- /dev/null +++ b/homeassistant/components/camera/translations/hr.json @@ -0,0 +1,10 @@ +{ + "state": { + "_": { + "idle": "Neaktivan", + "recording": "Snimanje", + "streaming": "Oda\u0161ilja" + } + }, + "title": "Kamera" +} \ No newline at end of file diff --git a/homeassistant/components/camera/translations/hu.json b/homeassistant/components/camera/translations/hu.json new file mode 100644 index 0000000000000..41a125e80bf55 --- /dev/null +++ b/homeassistant/components/camera/translations/hu.json @@ -0,0 +1,10 @@ +{ + "state": { + "_": { + "idle": "T\u00e9tlen", + "recording": "Felv\u00e9tel", + "streaming": "Streamel\u00e9s" + } + }, + "title": "Kamera" +} \ No newline at end of file diff --git a/homeassistant/components/camera/translations/hy.json b/homeassistant/components/camera/translations/hy.json new file mode 100644 index 0000000000000..48a722755f29e --- /dev/null +++ b/homeassistant/components/camera/translations/hy.json @@ -0,0 +1,10 @@ +{ + "state": { + "_": { + "idle": "\u057a\u0561\u0580\u0561\u057a", + "recording": "\u0541\u0561\u0575\u0576\u0561\u0563\u0580\u0578\u0582\u0569\u0575\u0578\u0582\u0576\u0568", + "streaming": "\u0540\u0578\u057d\u0584" + } + }, + "title": "\u054f\u0565\u057d\u0561\u056d\u0581\u056b\u056f" +} \ No newline at end of file diff --git a/homeassistant/components/camera/translations/id.json b/homeassistant/components/camera/translations/id.json new file mode 100644 index 0000000000000..7256dc88e5ad0 --- /dev/null +++ b/homeassistant/components/camera/translations/id.json @@ -0,0 +1,10 @@ +{ + "state": { + "_": { + "idle": "Siaga", + "recording": "Merekam", + "streaming": "Streaming" + } + }, + "title": "Kamera" +} \ No newline at end of file diff --git a/homeassistant/components/camera/translations/is.json b/homeassistant/components/camera/translations/is.json new file mode 100644 index 0000000000000..03c03bd560456 --- /dev/null +++ b/homeassistant/components/camera/translations/is.json @@ -0,0 +1,10 @@ +{ + "state": { + "_": { + "idle": "A\u00f0ger\u00f0alaus", + "recording": "\u00cd uppt\u00f6ku", + "streaming": "Streymi" + } + }, + "title": "Myndav\u00e9l" +} \ No newline at end of file diff --git a/homeassistant/components/camera/translations/it.json b/homeassistant/components/camera/translations/it.json new file mode 100644 index 0000000000000..a5f4be7967e4c --- /dev/null +++ b/homeassistant/components/camera/translations/it.json @@ -0,0 +1,10 @@ +{ + "state": { + "_": { + "idle": "Inattiva", + "recording": "In registrazione", + "streaming": "In trasmissione" + } + }, + "title": "Telecamera" +} \ No newline at end of file diff --git a/homeassistant/components/camera/translations/ja.json b/homeassistant/components/camera/translations/ja.json new file mode 100644 index 0000000000000..4ab2b8ed3b6a5 --- /dev/null +++ b/homeassistant/components/camera/translations/ja.json @@ -0,0 +1,8 @@ +{ + "state": { + "_": { + "idle": "\u30a2\u30a4\u30c9\u30eb" + } + }, + "title": "\u30ab\u30e1\u30e9" +} \ No newline at end of file diff --git a/homeassistant/components/camera/translations/ko.json b/homeassistant/components/camera/translations/ko.json new file mode 100644 index 0000000000000..8c054ff862cee --- /dev/null +++ b/homeassistant/components/camera/translations/ko.json @@ -0,0 +1,10 @@ +{ + "state": { + "_": { + "idle": "\ub300\uae30\uc911", + "recording": "\ub179\ud654\uc911", + "streaming": "\uc2a4\ud2b8\ub9ac\ubc0d" + } + }, + "title": "\uce74\uba54\ub77c" +} \ No newline at end of file diff --git a/homeassistant/components/camera/translations/lb.json b/homeassistant/components/camera/translations/lb.json new file mode 100644 index 0000000000000..aba896b00c3df --- /dev/null +++ b/homeassistant/components/camera/translations/lb.json @@ -0,0 +1,10 @@ +{ + "state": { + "_": { + "idle": "Roueg", + "recording": "H\u00eblt Op", + "streaming": "Streamt" + } + }, + "title": "Kamera" +} \ No newline at end of file diff --git a/homeassistant/components/camera/translations/lt.json b/homeassistant/components/camera/translations/lt.json new file mode 100644 index 0000000000000..24091a0573309 --- /dev/null +++ b/homeassistant/components/camera/translations/lt.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "idle": "Laukimo re\u017eimas", + "recording": "\u012era\u0161ymas", + "streaming": "Transliuojama" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/camera/translations/lv.json b/homeassistant/components/camera/translations/lv.json new file mode 100644 index 0000000000000..b4cede9284111 --- /dev/null +++ b/homeassistant/components/camera/translations/lv.json @@ -0,0 +1,10 @@ +{ + "state": { + "_": { + "idle": "D\u012bkst\u0101ve", + "recording": "Ieraksta", + "streaming": "Straum\u0113" + } + }, + "title": "Kamera" +} \ No newline at end of file diff --git a/homeassistant/components/camera/translations/nb.json b/homeassistant/components/camera/translations/nb.json new file mode 100644 index 0000000000000..f7f505e9d1c51 --- /dev/null +++ b/homeassistant/components/camera/translations/nb.json @@ -0,0 +1,10 @@ +{ + "state": { + "_": { + "idle": "Inaktiv", + "recording": "Opptak", + "streaming": "Str\u00f8mmer" + } + }, + "title": "Kamera" +} \ No newline at end of file diff --git a/homeassistant/components/camera/translations/nl.json b/homeassistant/components/camera/translations/nl.json new file mode 100644 index 0000000000000..976d8e651fb36 --- /dev/null +++ b/homeassistant/components/camera/translations/nl.json @@ -0,0 +1,10 @@ +{ + "state": { + "_": { + "idle": "Inactief", + "recording": "Opnemen", + "streaming": "Streamen" + } + }, + "title": "Camera" +} \ No newline at end of file diff --git a/homeassistant/components/camera/translations/nn.json b/homeassistant/components/camera/translations/nn.json new file mode 100644 index 0000000000000..39df070558b50 --- /dev/null +++ b/homeassistant/components/camera/translations/nn.json @@ -0,0 +1,10 @@ +{ + "state": { + "_": { + "idle": "Inaktiv", + "recording": "Opptak", + "streaming": "Str\u00f8ymer" + } + }, + "title": "Kamera" +} \ No newline at end of file diff --git a/homeassistant/components/camera/translations/no.json b/homeassistant/components/camera/translations/no.json new file mode 100644 index 0000000000000..9960881c81e9e --- /dev/null +++ b/homeassistant/components/camera/translations/no.json @@ -0,0 +1,10 @@ +{ + "state": { + "_": { + "idle": "Inaktiv", + "recording": "Opptak", + "streaming": "Str\u00f8mming" + } + }, + "title": "Kamera" +} \ No newline at end of file diff --git a/homeassistant/components/camera/translations/pl.json b/homeassistant/components/camera/translations/pl.json new file mode 100644 index 0000000000000..e7922e118df3d --- /dev/null +++ b/homeassistant/components/camera/translations/pl.json @@ -0,0 +1,10 @@ +{ + "state": { + "_": { + "idle": "nieaktywna", + "recording": "nagrywanie", + "streaming": "strumieniowanie" + } + }, + "title": "Kamera" +} \ No newline at end of file diff --git a/homeassistant/components/camera/translations/pt-BR.json b/homeassistant/components/camera/translations/pt-BR.json new file mode 100644 index 0000000000000..7534267a8751d --- /dev/null +++ b/homeassistant/components/camera/translations/pt-BR.json @@ -0,0 +1,10 @@ +{ + "state": { + "_": { + "idle": "Ocioso", + "recording": "Gravando", + "streaming": "Transmitindo" + } + }, + "title": "C\u00e2mera" +} \ No newline at end of file diff --git a/homeassistant/components/camera/translations/pt.json b/homeassistant/components/camera/translations/pt.json new file mode 100644 index 0000000000000..91d38a221584e --- /dev/null +++ b/homeassistant/components/camera/translations/pt.json @@ -0,0 +1,10 @@ +{ + "state": { + "_": { + "idle": "Em espera", + "recording": "A gravar", + "streaming": "A enviar" + } + }, + "title": "C\u00e2mara" +} \ No newline at end of file diff --git a/homeassistant/components/camera/translations/ro.json b/homeassistant/components/camera/translations/ro.json new file mode 100644 index 0000000000000..32fd0582d605c --- /dev/null +++ b/homeassistant/components/camera/translations/ro.json @@ -0,0 +1,10 @@ +{ + "state": { + "_": { + "idle": "Inactiv", + "recording": "\u00cenregistrare", + "streaming": "Streaming" + } + }, + "title": "Camera" +} \ No newline at end of file diff --git a/homeassistant/components/camera/translations/ru.json b/homeassistant/components/camera/translations/ru.json new file mode 100644 index 0000000000000..5334d49d2ffb2 --- /dev/null +++ b/homeassistant/components/camera/translations/ru.json @@ -0,0 +1,10 @@ +{ + "state": { + "_": { + "idle": "\u0411\u0435\u0437\u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0435", + "recording": "\u0417\u0430\u043f\u0438\u0441\u044c", + "streaming": "\u0422\u0440\u0430\u043d\u0441\u043b\u044f\u0446\u0438\u044f" + } + }, + "title": "\u041a\u0430\u043c\u0435\u0440\u0430" +} \ No newline at end of file diff --git a/homeassistant/components/camera/translations/sk.json b/homeassistant/components/camera/translations/sk.json new file mode 100644 index 0000000000000..496ac7e451ed9 --- /dev/null +++ b/homeassistant/components/camera/translations/sk.json @@ -0,0 +1,10 @@ +{ + "state": { + "_": { + "idle": "Ne\u010dinn\u00e1", + "recording": "Z\u00e1znam", + "streaming": "Streamovanie" + } + }, + "title": "Kamera" +} \ No newline at end of file diff --git a/homeassistant/components/camera/translations/sl.json b/homeassistant/components/camera/translations/sl.json new file mode 100644 index 0000000000000..969a4d46562d8 --- /dev/null +++ b/homeassistant/components/camera/translations/sl.json @@ -0,0 +1,10 @@ +{ + "state": { + "_": { + "idle": "V pripravljenosti", + "recording": "Snemanje", + "streaming": "Pretakanje" + } + }, + "title": "Kamera" +} \ No newline at end of file diff --git a/homeassistant/components/camera/translations/sv.json b/homeassistant/components/camera/translations/sv.json new file mode 100644 index 0000000000000..7c3d03430863e --- /dev/null +++ b/homeassistant/components/camera/translations/sv.json @@ -0,0 +1,10 @@ +{ + "state": { + "_": { + "idle": "Inaktiv", + "recording": "Spelar in", + "streaming": "Str\u00f6mmar" + } + }, + "title": "Kamera" +} \ No newline at end of file diff --git a/homeassistant/components/camera/translations/ta.json b/homeassistant/components/camera/translations/ta.json new file mode 100644 index 0000000000000..abf73c2c21018 --- /dev/null +++ b/homeassistant/components/camera/translations/ta.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "idle": "\u0baa\u0ba3\u0bbf\u0baf\u0bbf\u0ba9\u0bcd\u0bb1\u0bbf", + "recording": "\u0baa\u0ba4\u0bbf\u0bb5\u0bc1", + "streaming": "\u0bb8\u0bcd\u0b9f\u0bcd\u0bb0\u0bc0\u0bae\u0bbf\u0b99\u0bcd" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/camera/translations/te.json b/homeassistant/components/camera/translations/te.json new file mode 100644 index 0000000000000..e9c13f2fe36da --- /dev/null +++ b/homeassistant/components/camera/translations/te.json @@ -0,0 +1,10 @@ +{ + "state": { + "_": { + "idle": "\u0c10\u0c21\u0c3f\u0c32\u0c4d", + "recording": "\u0c30\u0c3f\u0c15\u0c3e\u0c30\u0c4d\u0c21\u0c3f\u0c02\u0c17\u0c4d", + "streaming": "\u0c2a\u0c4d\u0c30\u0c38\u0c3e\u0c30\u0c02" + } + }, + "title": "\u0c15\u0c46\u0c2e\u0c47\u0c30\u0c3e" +} \ No newline at end of file diff --git a/homeassistant/components/camera/translations/th.json b/homeassistant/components/camera/translations/th.json new file mode 100644 index 0000000000000..ac9b819a3b161 --- /dev/null +++ b/homeassistant/components/camera/translations/th.json @@ -0,0 +1,10 @@ +{ + "state": { + "_": { + "idle": "\u0e44\u0e21\u0e48\u0e44\u0e14\u0e49\u0e43\u0e0a\u0e49\u0e07\u0e32\u0e19", + "recording": "\u0e01\u0e33\u0e25\u0e31\u0e07\u0e1a\u0e31\u0e19\u0e17\u0e36\u0e01", + "streaming": "\u0e2a\u0e15\u0e23\u0e35\u0e21\u0e21\u0e34\u0e48\u0e07" + } + }, + "title": "\u0e01\u0e25\u0e49\u0e2d\u0e07" +} \ No newline at end of file diff --git a/homeassistant/components/camera/translations/tr.json b/homeassistant/components/camera/translations/tr.json new file mode 100644 index 0000000000000..313eaeb887b87 --- /dev/null +++ b/homeassistant/components/camera/translations/tr.json @@ -0,0 +1,10 @@ +{ + "state": { + "_": { + "idle": "Bo\u015fta", + "recording": "Kaydediliyor", + "streaming": "Yay\u0131n ak\u0131\u015f\u0131" + } + }, + "title": "Kamera" +} \ No newline at end of file diff --git a/homeassistant/components/camera/translations/uk.json b/homeassistant/components/camera/translations/uk.json new file mode 100644 index 0000000000000..2f31b0e017121 --- /dev/null +++ b/homeassistant/components/camera/translations/uk.json @@ -0,0 +1,10 @@ +{ + "state": { + "_": { + "idle": "\u041e\u0447\u0456\u043a\u0443\u0432\u0430\u043d\u043d\u044f", + "recording": "\u0417\u0430\u043f\u0438\u0441", + "streaming": "\u0422\u0440\u0430\u043d\u0441\u043b\u044f\u0446\u0456\u044f" + } + }, + "title": "\u041a\u0430\u043c\u0435\u0440\u0430" +} \ No newline at end of file diff --git a/homeassistant/components/camera/translations/vi.json b/homeassistant/components/camera/translations/vi.json new file mode 100644 index 0000000000000..6998526947756 --- /dev/null +++ b/homeassistant/components/camera/translations/vi.json @@ -0,0 +1,10 @@ +{ + "state": { + "_": { + "idle": "Kh\u00f4ng ho\u1ea1t \u0111\u1ed9ng", + "recording": "Ghi \u00e2m", + "streaming": "Ph\u00e1t tr\u1ef1c tuy\u1ebfn" + } + }, + "title": "M\u00e1y \u1ea3nh" +} \ No newline at end of file diff --git a/homeassistant/components/camera/translations/zh-Hans.json b/homeassistant/components/camera/translations/zh-Hans.json new file mode 100644 index 0000000000000..e50c157515b8d --- /dev/null +++ b/homeassistant/components/camera/translations/zh-Hans.json @@ -0,0 +1,10 @@ +{ + "state": { + "_": { + "idle": "\u5f85\u673a", + "recording": "\u5f55\u5236\u4e2d", + "streaming": "\u76d1\u63a7\u4e2d" + } + }, + "title": "\u6444\u50cf\u5934" +} \ No newline at end of file diff --git a/homeassistant/components/camera/translations/zh-Hant.json b/homeassistant/components/camera/translations/zh-Hant.json new file mode 100644 index 0000000000000..580728a7bf543 --- /dev/null +++ b/homeassistant/components/camera/translations/zh-Hant.json @@ -0,0 +1,10 @@ +{ + "state": { + "_": { + "idle": "\u5f85\u547d", + "recording": "\u9304\u5f71\u4e2d", + "streaming": "\u76e3\u63a7\u4e2d" + } + }, + "title": "\u651d\u5f71\u6a5f" +} \ No newline at end of file diff --git a/homeassistant/components/canary/__init__.py b/homeassistant/components/canary/__init__.py index 52b38f1479557..d6effc7eb80b5 100644 --- a/homeassistant/components/canary/__init__.py +++ b/homeassistant/components/canary/__init__.py @@ -1,55 +1,58 @@ """Support for Canary devices.""" -import logging from datetime import timedelta +import logging -import voluptuous as vol +from canary.api import Api from requests import ConnectTimeout, HTTPError +import voluptuous as vol -import homeassistant.helpers.config_validation as cv -from homeassistant.const import CONF_USERNAME, CONF_PASSWORD, CONF_TIMEOUT +from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME from homeassistant.helpers import discovery +import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle _LOGGER = logging.getLogger(__name__) -NOTIFICATION_ID = 'canary_notification' -NOTIFICATION_TITLE = 'Canary Setup' +NOTIFICATION_ID = "canary_notification" +NOTIFICATION_TITLE = "Canary Setup" -DOMAIN = 'canary' -DATA_CANARY = 'canary' +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) +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' -] +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) + username = conf[CONF_USERNAME] + password = conf[CONF_PASSWORD] + timeout = conf[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), + f"Error: {ex}
You will need to restart hass after fixing.", title=NOTIFICATION_TITLE, - notification_id=NOTIFICATION_ID) + notification_id=NOTIFICATION_ID, + ) return False for component in CANARY_COMPONENTS: @@ -63,7 +66,7 @@ class CanaryData: 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 = {} @@ -80,12 +83,14 @@ def update(self, **kwargs): 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) + 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) + self._readings_by_device_id[ + device.device_id + ] = self._api.get_latest_readings(device.device_id) @property def locations(self): @@ -107,9 +112,14 @@ def get_readings(self, 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) + 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.""" diff --git a/homeassistant/components/canary/alarm_control_panel.py b/homeassistant/components/canary/alarm_control_panel.py index 7402d7855324b..a5930e658fb9b 100644 --- a/homeassistant/components/canary/alarm_control_panel.py +++ b/homeassistant/components/canary/alarm_control_panel.py @@ -1,10 +1,20 @@ """Support for Canary alarm.""" import logging -from homeassistant.components.alarm_control_panel import AlarmControlPanel +from canary.api import LOCATION_MODE_AWAY, LOCATION_MODE_HOME, LOCATION_MODE_NIGHT + +from homeassistant.components.alarm_control_panel import AlarmControlPanelEntity +from homeassistant.components.alarm_control_panel.const import ( + SUPPORT_ALARM_ARM_AWAY, + SUPPORT_ALARM_ARM_HOME, + SUPPORT_ALARM_ARM_NIGHT, +) from homeassistant.const import ( - STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, - STATE_ALARM_DISARMED) + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_HOME, + STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_DISARMED, +) from . import DATA_CANARY @@ -22,7 +32,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): add_entities(devices, True) -class CanaryAlarm(AlarmControlPanel): +class CanaryAlarm(AlarmControlPanelEntity): """Representation of a Canary alarm control panel.""" def __init__(self, data, location_id): @@ -39,9 +49,6 @@ def name(self): @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: @@ -56,31 +63,34 @@ def state(self): return STATE_ALARM_ARMED_NIGHT return None + @property + def supported_features(self) -> int: + """Return the list of supported features.""" + return SUPPORT_ALARM_ARM_HOME | SUPPORT_ALARM_ARM_AWAY | SUPPORT_ALARM_ARM_NIGHT + @property def device_state_attributes(self): """Return the state attributes.""" location = self._data.get_location(self._location_id) - return { - 'private': location.is_private - } + 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) + 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) + + def update(self): + """Get the latest state of the sensor.""" + self._data.update() diff --git a/homeassistant/components/canary/camera.py b/homeassistant/components/canary/camera.py index 33e1265921f19..870256ffcff5e 100644 --- a/homeassistant/components/canary/camera.py +++ b/homeassistant/components/canary/camera.py @@ -3,6 +3,8 @@ from datetime import timedelta import logging +from haffmpeg.camera import CameraMjpeg +from haffmpeg.tools import IMAGE_JPEG, ImageFrame import voluptuous as vol from homeassistant.components.camera import PLATFORM_SCHEMA, Camera @@ -15,14 +17,14 @@ _LOGGER = logging.getLogger(__name__) -CONF_FFMPEG_ARGUMENTS = 'ffmpeg_arguments' -DEFAULT_ARGUMENTS = '-pred 1' +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, -}) +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): @@ -34,8 +36,15 @@ def setup_platform(hass, config, add_entities, discovery_info=None): for device in location.devices: if device.is_online: devices.append( - CanaryCamera(hass, data, location, device, DEFAULT_TIMEOUT, - config.get(CONF_FFMPEG_ARGUMENTS))) + CanaryCamera( + hass, + data, + location, + device, + DEFAULT_TIMEOUT, + config[CONF_FFMPEG_ARGUMENTS], + ) + ) add_entities(devices, True) @@ -72,14 +81,16 @@ def motion_detection_enabled(self): async def async_camera_image(self): """Return a still image response from the camera.""" - self.renew_live_stream_session() + await self.hass.async_add_executor_job(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), 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): @@ -87,22 +98,23 @@ async def handle_async_mjpeg_stream(self, request): 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) + 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) + 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) + 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 index 346c1c99f6df6..e383cb7514b9f 100644 --- a/homeassistant/components/canary/manifest.json +++ b/homeassistant/components/canary/manifest.json @@ -1,12 +1,8 @@ { "domain": "canary", "name": "Canary", - "documentation": "https://www.home-assistant.io/components/canary", - "requirements": [ - "py-canary==0.5.0" - ], - "dependencies": [ - "ffmpeg" - ], + "documentation": "https://www.home-assistant.io/integrations/canary", + "requirements": ["py-canary==0.5.0"], + "dependencies": ["ffmpeg"], "codeowners": [] } diff --git a/homeassistant/components/canary/sensor.py b/homeassistant/components/canary/sensor.py index 220abc9b38725..0be5171af485e 100644 --- a/homeassistant/components/canary/sensor.py +++ b/homeassistant/components/canary/sensor.py @@ -1,6 +1,7 @@ """Support for Canary sensors.""" +from canary.api import SensorType -from homeassistant.const import TEMP_CELSIUS +from homeassistant.const import TEMP_CELSIUS, UNIT_PERCENTAGE from homeassistant.helpers.entity import Entity from homeassistant.helpers.icon import icon_for_battery_level @@ -9,14 +10,21 @@ SENSOR_VALUE_PRECISION = 2 ATTR_AIR_QUALITY = "air_quality" +# Define variables to store the device names, as referred to by the Canary API. +# Note: If Canary change the name of any of their devices (which they have done), +# then these variables will need updating, otherwise the sensors will stop working +# and disappear in Home Assistant. +CANARY_PRO = "Canary Pro" +CANARY_FLEX = "Canary Flex" + # 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"]], + ["temperature", TEMP_CELSIUS, "mdi:thermometer", [CANARY_PRO]], + ["humidity", UNIT_PERCENTAGE, "mdi:water-percent", [CANARY_PRO]], + ["air_quality", None, "mdi:weather-windy", [CANARY_PRO]], + ["wifi", "dBm", "mdi:wifi", [CANARY_FLEX]], + ["battery", UNIT_PERCENTAGE, "mdi:battery-50", [CANARY_FLEX]], ] STATE_AIR_QUALITY_NORMAL = "normal" @@ -35,8 +43,9 @@ def setup_platform(hass, config, add_entities, discovery_info=None): 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)) + devices.append( + CanarySensor(data, sensor_type, location, device) + ) add_entities(devices, True) @@ -52,9 +61,7 @@ def __init__(self, data, sensor_type, location, device): self._sensor_value = None sensor_type_name = sensor_type[0].replace("_", " ").title() - self._name = '{} {} {}'.format(location.name, - device.name, - sensor_type_name) + self._name = f"{location.name} {device.name} {sensor_type_name}" @property def name(self): @@ -69,7 +76,7 @@ def state(self): @property def unique_id(self): """Return the unique ID of this sensor.""" - return "{}_{}".format(self._device_id, self._sensor_type[0]) + return f"{self._device_id}_{self._sensor_type[0]}" @property def unit_of_measurement(self): @@ -87,19 +94,16 @@ def icon(self): @property def device_state_attributes(self): """Return the state attributes.""" - if self._sensor_type[0] == "air_quality" \ - and self._sensor_value is not None: + if self._sensor_type[0] == "air_quality" and self._sensor_value is not None: air_quality = None - if self._sensor_value <= .4: + if self._sensor_value <= 0.4: air_quality = STATE_AIR_QUALITY_VERY_ABNORMAL - elif self._sensor_value <= .59: + elif self._sensor_value <= 0.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 {ATTR_AIR_QUALITY: air_quality} return None @@ -107,7 +111,6 @@ 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 diff --git a/homeassistant/components/cast/.translations/ca.json b/homeassistant/components/cast/.translations/ca.json deleted file mode 100644 index 26236397dec77..0000000000000 --- a/homeassistant/components/cast/.translations/ca.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "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 deleted file mode 100644 index 82f063b365f1e..0000000000000 --- a/homeassistant/components/cast/.translations/cs.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "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 deleted file mode 100644 index 5d8ab2362377c..0000000000000 --- a/homeassistant/components/cast/.translations/da.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "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 deleted file mode 100644 index ac1ebbeb23653..0000000000000 --- a/homeassistant/components/cast/.translations/de.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "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 deleted file mode 100644 index f908f41e3289e..0000000000000 --- a/homeassistant/components/cast/.translations/en.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "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 deleted file mode 100644 index 2f8d4982afdd0..0000000000000 --- a/homeassistant/components/cast/.translations/es-419.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "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 deleted file mode 100644 index 6dc41196af56f..0000000000000 --- a/homeassistant/components/cast/.translations/es.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "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 deleted file mode 100644 index 987c54955f20e..0000000000000 --- a/homeassistant/components/cast/.translations/et.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "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 deleted file mode 100644 index 99feeb3c89837..0000000000000 --- a/homeassistant/components/cast/.translations/fr.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "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 deleted file mode 100644 index 40d2514b59ce0..0000000000000 --- a/homeassistant/components/cast/.translations/he.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "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 deleted file mode 100644 index 66dc4ea8dd843..0000000000000 --- a/homeassistant/components/cast/.translations/hu.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "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 deleted file mode 100644 index 86fb32c0844fa..0000000000000 --- a/homeassistant/components/cast/.translations/id.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "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 deleted file mode 100644 index 21c8e60518e2a..0000000000000 --- a/homeassistant/components/cast/.translations/it.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "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 deleted file mode 100644 index 25b9c10b2e743..0000000000000 --- a/homeassistant/components/cast/.translations/ja.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "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 deleted file mode 100644 index 32c744c8f20b0..0000000000000 --- a/homeassistant/components/cast/.translations/ko.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "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 deleted file mode 100644 index f1daff8306955..0000000000000 --- a/homeassistant/components/cast/.translations/lb.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "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 deleted file mode 100644 index 91c428770f5fc..0000000000000 --- a/homeassistant/components/cast/.translations/nl.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "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 deleted file mode 100644 index 7f55015565892..0000000000000 --- a/homeassistant/components/cast/.translations/nn.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "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 deleted file mode 100644 index d36c929e7211b..0000000000000 --- a/homeassistant/components/cast/.translations/no.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "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 deleted file mode 100644 index c4399f95defe8..0000000000000 --- a/homeassistant/components/cast/.translations/pl.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "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 deleted file mode 100644 index bd670d7c72f56..0000000000000 --- a/homeassistant/components/cast/.translations/pt-BR.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "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 deleted file mode 100644 index 85d1b14484d17..0000000000000 --- a/homeassistant/components/cast/.translations/pt.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "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 deleted file mode 100644 index 8a1d19c0ecf56..0000000000000 --- a/homeassistant/components/cast/.translations/ro.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "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 deleted file mode 100644 index da03eae701dd9..0000000000000 --- a/homeassistant/components/cast/.translations/ru.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "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 deleted file mode 100644 index 24a7215574dbd..0000000000000 --- a/homeassistant/components/cast/.translations/sl.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "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 deleted file mode 100644 index aea55058d108f..0000000000000 --- a/homeassistant/components/cast/.translations/sv.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "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 deleted file mode 100644 index 372a9cf0760df..0000000000000 --- a/homeassistant/components/cast/.translations/th.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "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/vi.json b/homeassistant/components/cast/.translations/vi.json deleted file mode 100644 index 2f2982293cfda..0000000000000 --- a/homeassistant/components/cast/.translations/vi.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "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 deleted file mode 100644 index d4f1cf4c1a590..0000000000000 --- a/homeassistant/components/cast/.translations/zh-Hans.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "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 deleted file mode 100644 index d5383fb1a2bdb..0000000000000 --- a/homeassistant/components/cast/.translations/zh-Hant.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "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 index 1a93020c22956..4dfb58ef3b7d6 100644 --- a/homeassistant/components/cast/__init__.py +++ b/homeassistant/components/cast/__init__.py @@ -1,8 +1,8 @@ """Component to embed Google Cast.""" from homeassistant import config_entries -from homeassistant.helpers import config_entry_flow -DOMAIN = 'cast' +from . import home_assistant_cast +from .const import DOMAIN async def async_setup(hass, config): @@ -12,26 +12,20 @@ async def async_setup(hass, config): 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})) + 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): +async def async_setup_entry(hass, entry: config_entries.ConfigEntry): """Set up Cast from a config entry.""" - hass.async_create_task(hass.config_entries.async_forward_entry_setup( - entry, 'media_player')) - return True - - -async def _async_has_devices(hass): - """Return if there are devices that can be discovered.""" - from pychromecast.discovery import discover_chromecasts + await home_assistant_cast.async_setup_ha_cast(hass, entry) - 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) + 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..5c2b6dca9325a --- /dev/null +++ b/homeassistant/components/cast/config_flow.py @@ -0,0 +1,18 @@ +"""Config flow for Cast.""" +from pychromecast.discovery import discover_chromecasts + +from homeassistant import config_entries +from homeassistant.helpers import config_entry_flow + +from .const import DOMAIN + + +async def _async_has_devices(hass): + """Return if there are devices that can be discovered.""" + + 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..c6164484dbbb1 --- /dev/null +++ b/homeassistant/components/cast/const.py @@ -0,0 +1,26 @@ +"""Consts for Cast integration.""" + +DOMAIN = "cast" +DEFAULT_PORT = 8009 + +# 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" + +# Dispatcher signal fired when a Chromecast should show a Home Assistant Cast view. +SIGNAL_HASS_CAST_SHOW_VIEW = "cast_show_view" diff --git a/homeassistant/components/cast/discovery.py b/homeassistant/components/cast/discovery.py new file mode 100644 index 0000000000000..9f90b074c3d8b --- /dev/null +++ b/homeassistant/components/cast/discovery.py @@ -0,0 +1,98 @@ +"""Deal with Cast discovery.""" +import logging +import threading + +import pychromecast + +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import dispatcher_send + +from .const import ( + INTERNAL_DISCOVERY_RUNNING_KEY, + KNOWN_CHROMECAST_INFO_KEY, + SIGNAL_CAST_DISCOVERED, + SIGNAL_CAST_REMOVED, +) +from .helpers import ChromecastInfo, ChromeCastZeroconf + +_LOGGER = logging.getLogger(__name__) + + +def discover_chromecast(hass: HomeAssistant, info: ChromecastInfo): + """Discover a Chromecast.""" + 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. + _LOGGER.debug("Discovered chromecast %s", info) + + if info.uuid is not None: + # Remove previous cast infos with same uuid from known chromecasts. + same_uuid = { + 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: HomeAssistant, info: ChromecastInfo): + # Removed chromecast + _LOGGER.debug("Removed chromecast %s", info) + + dispatcher_send(hass, SIGNAL_CAST_REMOVED, info) + + +def setup_internal_discovery(hass: HomeAssistant) -> 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 + + 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) diff --git a/homeassistant/components/cast/helpers.py b/homeassistant/components/cast/helpers.py new file mode 100644 index 0000000000000..5a99d30f08710 --- /dev/null +++ b/homeassistant/components/cast/helpers.py @@ -0,0 +1,125 @@ +"""Helpers to deal with Cast devices.""" +from typing import Optional, Tuple + +import attr +from pychromecast.const import CAST_MANUFACTURERS + +from .const import DEFAULT_PORT + + +@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 + model_name = attr.ib(type=str, default="") + friendly_name = attr.ib(type=Optional[str], default=None) + + @property + def is_audio_group(self) -> bool: + """Return if this is an audio group.""" + return self.port != DEFAULT_PORT + + @property + def host_port(self) -> Tuple[str, int]: + """Return the host+port tuple.""" + return self.host, self.port + + @property + def manufacturer(self) -> str: + """Return the manufacturer.""" + if not self.model_name: + return None + return CAST_MANUFACTURERS.get(self.model_name.lower(), "Google Inc.") + + +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 + + +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) + 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.""" + + 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.""" + + 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 diff --git a/homeassistant/components/cast/home_assistant_cast.py b/homeassistant/components/cast/home_assistant_cast.py new file mode 100644 index 0000000000000..c933136d14032 --- /dev/null +++ b/homeassistant/components/cast/home_assistant_cast.py @@ -0,0 +1,81 @@ +"""Home Assistant Cast integration for Cast.""" +from typing import Optional + +from pychromecast.controllers.homeassistant import HomeAssistantController +import voluptuous as vol + +from homeassistant import auth, config_entries, core +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.helpers import config_validation as cv, dispatcher + +from .const import DOMAIN, SIGNAL_HASS_CAST_SHOW_VIEW + +SERVICE_SHOW_VIEW = "show_lovelace_view" +ATTR_VIEW_PATH = "view_path" +ATTR_URL_PATH = "dashboard_path" + + +async def async_setup_ha_cast( + hass: core.HomeAssistant, entry: config_entries.ConfigEntry +): + """Set up Home Assistant Cast.""" + user_id: Optional[str] = entry.data.get("user_id") + user: Optional[auth.models.User] = None + + if user_id is not None: + user = await hass.auth.async_get_user(user_id) + + if user is None: + user = await hass.auth.async_create_system_user( + "Home Assistant Cast", [auth.GROUP_ID_ADMIN] + ) + hass.config_entries.async_update_entry( + entry, data={**entry.data, "user_id": user.id} + ) + + if user.refresh_tokens: + refresh_token: auth.models.RefreshToken = list(user.refresh_tokens.values())[0] + else: + refresh_token = await hass.auth.async_create_refresh_token(user) + + async def handle_show_view(call: core.ServiceCall): + """Handle a Show View service call.""" + hass_url = hass.config.api.base_url + + # Home Assistant Cast only works with https urls. If user has no configured + # base url, use their remote url. + if not hass_url.lower().startswith("https://"): + try: + hass_url = hass.components.cloud.async_remote_ui_url() + except hass.components.cloud.CloudNotAvailable: + pass + + controller = HomeAssistantController( + # If you are developing Home Assistant Cast, uncomment and set to your dev app id. + # app_id="5FE44367", + hass_url=hass_url, + client_id=None, + refresh_token=refresh_token.token, + ) + + dispatcher.async_dispatcher_send( + hass, + SIGNAL_HASS_CAST_SHOW_VIEW, + controller, + call.data[ATTR_ENTITY_ID], + call.data[ATTR_VIEW_PATH], + call.data.get(ATTR_URL_PATH), + ) + + hass.helpers.service.async_register_admin_service( + DOMAIN, + SERVICE_SHOW_VIEW, + handle_show_view, + vol.Schema( + { + ATTR_ENTITY_ID: cv.entity_id, + ATTR_VIEW_PATH: str, + vol.Optional(ATTR_URL_PATH): str, + } + ), + ) diff --git a/homeassistant/components/cast/manifest.json b/homeassistant/components/cast/manifest.json index dd189ac91e7a5..b8ad1fe67cc42 100644 --- a/homeassistant/components/cast/manifest.json +++ b/homeassistant/components/cast/manifest.json @@ -1,10 +1,10 @@ { "domain": "cast", - "name": "Cast", - "documentation": "https://www.home-assistant.io/components/cast", - "requirements": [ - "pychromecast==3.2.1" - ], - "dependencies": [], - "codeowners": [] + "name": "Google Cast", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/cast", + "requirements": ["pychromecast==5.0.0"], + "after_dependencies": ["cloud"], + "zeroconf": ["_googlecast._tcp.local."], + "codeowners": ["@emontnemery"] } diff --git a/homeassistant/components/cast/media_player.py b/homeassistant/components/cast/media_player.py index ee10f06c985e0..9b6e78f156af1 100644 --- a/homeassistant/components/cast/media_player.py +++ b/homeassistant/components/cast/media_player.py @@ -1,273 +1,100 @@ """Provide functionality to interact with Cast devices on the network.""" import asyncio import logging -import threading -from typing import Optional, Tuple - -import attr +from typing import Optional + +import pychromecast +from pychromecast.controllers.homeassistant import HomeAssistantController +from pychromecast.controllers.multizone import MultizoneManager +from pychromecast.socket_client import ( + CONNECTION_STATUS_CONNECTED, + CONNECTION_STATUS_DISCONNECTED, +) import voluptuous as vol -from homeassistant.components.media_player import ( - PLATFORM_SCHEMA, MediaPlayerDevice) +from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerEntity 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) + 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) + 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.dispatcher import async_dispatcher_connect 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',) +from .const import ( + ADDED_CAST_DEVICES_KEY, + CAST_MULTIZONE_MANAGER_KEY, + DEFAULT_PORT, + DOMAIN as CAST_DOMAIN, + KNOWN_CHROMECAST_INFO_KEY, + SIGNAL_CAST_DISCOVERED, + SIGNAL_CAST_REMOVED, + SIGNAL_HASS_CAST_SHOW_VIEW, +) +from .discovery import setup_internal_discovery +from .helpers import CastStatusListener, ChromecastInfo, ChromeCastZeroconf _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], - )) +CONF_IGNORE_CEC = "ignore_cec" +CAST_SPLASH = "https://www.home-assistant.io/images/cast/splash.png" - _LOGGER.debug("Starting internal pychromecast discovery.") - listener, browser = pychromecast.start_discovery(internal_add_callback, - internal_remove_callback) - ChromeCastZeroconf.set_zeroconf(browser.zc) +SUPPORT_CAST = ( + SUPPORT_PAUSE + | SUPPORT_PLAY + | SUPPORT_PLAY_MEDIA + | SUPPORT_STOP + | SUPPORT_TURN_OFF + | SUPPORT_TURN_ON + | SUPPORT_VOLUME_MUTE + | SUPPORT_VOLUME_SET +) - 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) +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_HOST): cv.string, + vol.Optional(CONF_IGNORE_CEC, default=[]): vol.All(cv.ensure_list, [cv.string]), + } +) @callback -def _async_create_cast_device(hass: HomeAssistantType, - info: ChromecastInfo): +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. + _LOGGER.error("_async_create_cast_device uuid none: %s", info) return None + # Found a cast with UUID 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 @@ -278,29 +105,30 @@ def _async_create_cast_device(hass: HomeAssistantType, return CastDevice(info) -async def async_setup_platform(hass: HomeAssistantType, config: ConfigType, - async_add_entities, discovery_info=None): +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) + "Setting configuration for Cast via platform is deprecated. " + "Configure via Cast integration 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', {}) + 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]) + 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: @@ -308,11 +136,10 @@ async def async_setup_entry(hass, config_entry, async_add_entities): raise PlatformNotReady -async def _async_setup_platform(hass: HomeAssistantType, config: ConfigType, - async_add_entities, discovery_info): +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()) @@ -320,163 +147,31 @@ async def _async_setup_platform(hass: HomeAssistantType, config: ConfigType, info = None if discovery_info is not None: - info = ChromecastInfo(host=discovery_info['host'], - port=discovery_info['port']) + info = ChromecastInfo(host=discovery_info["host"], port=discovery_info["port"]) elif CONF_HOST in config: - info = ChromecastInfo(host=config[CONF_HOST], - port=DEFAULT_PORT) + 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. + # Waiting for a specific cast device, this is not it. 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) + 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) + hass.async_add_executor_job(setup_internal_discovery, hass) - 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): +class CastDevice(MediaPlayerEntity): """Representation of a Cast device on the network. This class is the holder of the pychromecast.Chromecast object and its @@ -484,101 +179,45 @@ class CastDevice(MediaPlayerDevice): "elected leader" itself. """ - def __init__(self, cast_info): + def __init__(self, cast_info: ChromecastInfo): """Initialize the cast device.""" - import pychromecast # noqa: pylint: disable=unused-import - self._cast_info = cast_info # type: ChromecastInfo + + self._cast_info = cast_info self.services = None if cast_info.service: self.services = set() self.services.add(cast_info.service) - self._chromecast = None # type: Optional[pychromecast.Chromecast] + self._chromecast: Optional[pychromecast.Chromecast] = None 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._available = False + self._status_listener: Optional[CastStatusListener] = None + self._hass_cast_controller: Optional[HomeAssistantController] = None + self._add_remove_handler = None self._del_remove_handler = None + self._cast_view_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.hass, SIGNAL_CAST_DISCOVERED, self._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 + self.hass, SIGNAL_CAST_REMOVED, self._async_cast_removed + ) + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._async_stop) + self.hass.async_create_task( + async_create_catching_coro(self.async_set_cast_info(self._cast_info)) + ) + + self._cast_view_remove_handler = async_dispatcher_connect( + self.hass, SIGNAL_HASS_CAST_SHOW_VIEW, self._handle_signal_show_view + ) async def async_will_remove_from_hass(self) -> None: """Disconnect Chromecast object when removed.""" @@ -589,20 +228,30 @@ async def async_will_remove_from_hass(self) -> None: self.hass.data[ADDED_CAST_DEVICES_KEY].remove(self._cast_info.uuid) if self._add_remove_handler: self._add_remove_handler() + self._add_remove_handler = None if self._del_remove_handler: self._del_remove_handler() + self._del_remove_handler = None + if self._cast_view_remove_handler: + self._cast_view_remove_handler() + self._cast_view_remove_handler = None 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) + _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) @@ -611,120 +260,71 @@ async def async_set_cast_info(self, cast_info): # 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 - )) + _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_executor_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._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() + self.async_write_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() + "[%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_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) + _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() + self.async_write_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() + self.async_write_ha_state() def _invalidate(self): """Invalidate some attributes.""" @@ -735,19 +335,11 @@ def _invalidate(self): self.mz_media_status = {} self.mz_media_status_received = {} self.mz_mgr = None + self._hass_cast_controller = 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.""" @@ -762,14 +354,14 @@ def new_media_status(self, media_status): 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) + 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() @@ -783,58 +375,26 @@ def new_connection_status(self, connection_status): # 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.entity_id, + self._cast_info.friendly_name, + self._cast_info.host, + self._cast_info.port, + connection_status.status, + ) 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.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() @@ -844,31 +404,22 @@ 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. + First try from our own cast, then 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) + 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 @@ -879,8 +430,7 @@ def turn_on(self): 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) + self._chromecast.play_media(CAST_SPLASH, pychromecast.STREAM_TYPE_BUFFERED) def turn_off(self): """Turn off the cast device.""" @@ -926,7 +476,7 @@ def media_seek(self, 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 + # We do not want this to be forwarded to a group self._chromecast.media_controller.play_media(media_id, media_type) # ========== Properties ========== @@ -949,29 +499,21 @@ def device_info(self): return None return { - 'name': cast_info.friendly_name, - 'identifiers': { - (CAST_DOMAIN, cast_info.uuid.replace('-', '')) - }, - 'model': cast_info.model_name, - 'manufacturer': cast_info.manufacturer, + "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. + First try from our own cast, then 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(): @@ -1134,10 +676,11 @@ def supported_features(self): 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): + 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 @@ -1154,3 +697,60 @@ def media_position_updated_at(self): def unique_id(self) -> Optional[str]: """Return a unique ID.""" return self._cast_info.uuid + + async def _async_cast_discovered(self, discover: ChromecastInfo): + """Handle discovery of new Chromecast.""" + if self._cast_info.uuid is None: + # We can't handle empty UUIDs + 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) + await self.async_set_cast_info(discover) + + async def _async_cast_removed(self, discover: ChromecastInfo): + """Handle removal of Chromecast.""" + if self._cast_info.uuid is None: + # We can't handle empty UUIDs + return + + if self._cast_info.uuid != discover.uuid: + # Removed is not our device. + return + + _LOGGER.debug("Removed chromecast with same UUID: %s", discover) + await self.async_del_cast_info(discover) + + async def _async_stop(self, event): + """Disconnect socket on Home Assistant stop.""" + await self._async_disconnect() + + def _handle_signal_show_view( + self, + controller: HomeAssistantController, + entity_id: str, + view_path: str, + url_path: Optional[str], + ): + """Handle a show view signal.""" + if entity_id != self.entity_id: + return + + if self._hass_cast_controller is None: + self._hass_cast_controller = controller + self._chromecast.register_handler(controller) + + self._hass_cast_controller.show_lovelace_view(view_path, url_path) diff --git a/homeassistant/components/cast/services.yaml b/homeassistant/components/cast/services.yaml new file mode 100644 index 0000000000000..d1c29281aad69 --- /dev/null +++ b/homeassistant/components/cast/services.yaml @@ -0,0 +1,12 @@ +show_lovelace_view: + description: Show a Lovelace view on a Chromecast. + fields: + entity_id: + description: Media Player entity to show the Lovelace view on. + example: "media_player.kitchen" + dashboard_path: + description: The url path of the Lovelace dashboard to show. + example: lovelace-cast + view_path: + description: The path of the Lovelace view to show. + example: downstairs diff --git a/homeassistant/components/cast/strings.json b/homeassistant/components/cast/strings.json index eecdecbfdf9e5..aed62243f31e3 100644 --- a/homeassistant/components/cast/strings.json +++ b/homeassistant/components/cast/strings.json @@ -1,9 +1,7 @@ { "config": { - "title": "Google Cast", "step": { "confirm": { - "title": "Google Cast", "description": "Do you want to set up Google Cast?" } }, diff --git a/homeassistant/components/cast/translations/bg.json b/homeassistant/components/cast/translations/bg.json new file mode 100644 index 0000000000000..746278595d066 --- /dev/null +++ b/homeassistant/components/cast/translations/bg.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "no_devices_found": "\u0412 \u043c\u0440\u0435\u0436\u0430\u0442\u0430 \u043d\u044f\u043c\u0430 \u043d\u0430\u043c\u0435\u0440\u0435\u043d\u0438 Google Cast \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430.", + "single_instance_allowed": "\u0420\u0430\u0437\u0440\u0435\u0448\u0435\u043d\u0430 \u0435 \u0441\u0430\u043c\u043e \u0435\u0434\u043d\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f \u043d\u0430 Google Cast." + }, + "step": { + "confirm": { + "description": "\u0418\u0441\u043a\u0430\u0442\u0435 \u043b\u0438 \u0434\u0430 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u0435 Google Cast?", + "title": "Google Cast" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/cast/translations/ca.json b/homeassistant/components/cast/translations/ca.json new file mode 100644 index 0000000000000..0b358293304d2 --- /dev/null +++ b/homeassistant/components/cast/translations/ca.json @@ -0,0 +1,14 @@ +{ + "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" + } + } + } +} \ 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..79694e427cafc --- /dev/null +++ b/homeassistant/components/cast/translations/cs.json @@ -0,0 +1,14 @@ +{ + "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" + } + } + } +} \ 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..3230e215bff78 --- /dev/null +++ b/homeassistant/components/cast/translations/da.json @@ -0,0 +1,14 @@ +{ + "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" + } + } + } +} \ 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..5a87e714bf6de --- /dev/null +++ b/homeassistant/components/cast/translations/de.json @@ -0,0 +1,14 @@ +{ + "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" + } + } + } +} \ 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..81ba0457240bf --- /dev/null +++ b/homeassistant/components/cast/translations/en.json @@ -0,0 +1,14 @@ +{ + "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" + } + } + } +} \ 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..c4374997d8a53 --- /dev/null +++ b/homeassistant/components/cast/translations/es-419.json @@ -0,0 +1,14 @@ +{ + "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" + } + } + } +} \ 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..3a5f56532377f --- /dev/null +++ b/homeassistant/components/cast/translations/es.json @@ -0,0 +1,14 @@ +{ + "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" + } + } + } +} \ 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..0e652624ef615 --- /dev/null +++ b/homeassistant/components/cast/translations/et.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "confirm": { + "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..a4fef5a7c525c --- /dev/null +++ b/homeassistant/components/cast/translations/fr.json @@ -0,0 +1,14 @@ +{ + "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" + } + } + } +} \ 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..019561c2b87a8 --- /dev/null +++ b/homeassistant/components/cast/translations/he.json @@ -0,0 +1,14 @@ +{ + "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" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/cast/translations/hr.json b/homeassistant/components/cast/translations/hr.json new file mode 100644 index 0000000000000..e3f09f8b09c0e --- /dev/null +++ b/homeassistant/components/cast/translations/hr.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "confirm": { + "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..83f0f959c71d0 --- /dev/null +++ b/homeassistant/components/cast/translations/hu.json @@ -0,0 +1,14 @@ +{ + "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" + } + } + } +} \ 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..f4d66facce1d0 --- /dev/null +++ b/homeassistant/components/cast/translations/id.json @@ -0,0 +1,14 @@ +{ + "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" + } + } + } +} \ 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..ba3cdc0645d35 --- /dev/null +++ b/homeassistant/components/cast/translations/it.json @@ -0,0 +1,14 @@ +{ + "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" + } + } + } +} \ 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..f078c7c13e91f --- /dev/null +++ b/homeassistant/components/cast/translations/ja.json @@ -0,0 +1,13 @@ +{ + "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" + } + } + } +} \ 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..1de8e74ec8490 --- /dev/null +++ b/homeassistant/components/cast/translations/ko.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "no_devices_found": "Google \uce90\uc2a4\ud2b8 \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 \uce90\uc2a4\ud2b8\ub9cc \uad6c\uc131\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4." + }, + "step": { + "confirm": { + "description": "Google \uce90\uc2a4\ud2b8\ub97c \uc124\uc815\ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?", + "title": "Google \uce90\uc2a4\ud2b8" + } + } + } +} \ 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..9ea5e36b37918 --- /dev/null +++ b/homeassistant/components/cast/translations/lb.json @@ -0,0 +1,14 @@ +{ + "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" + } + } + } +} \ 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..b22c25f1292ba --- /dev/null +++ b/homeassistant/components/cast/translations/nl.json @@ -0,0 +1,14 @@ +{ + "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" + } + } + } +} \ 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..0c7d56d3ad812 --- /dev/null +++ b/homeassistant/components/cast/translations/nn.json @@ -0,0 +1,14 @@ +{ + "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" + } + } + } +} \ 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..de041d89f8116 --- /dev/null +++ b/homeassistant/components/cast/translations/no.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "no_devices_found": "Ingen Google Cast enheter funnet p\u00e5 nettverket.", + "single_instance_allowed": "Kun en konfigurasjon av Google Cast er n\u00f8dvendig." + }, + "step": { + "confirm": { + "description": "\u00d8nsker du \u00e5 sette opp Google Cast?", + "title": "" + } + } + } +} \ 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..26897da5da3cc --- /dev/null +++ b/homeassistant/components/cast/translations/pl.json @@ -0,0 +1,14 @@ +{ + "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" + } + } + } +} \ 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..25c0adf866fdd --- /dev/null +++ b/homeassistant/components/cast/translations/pt-BR.json @@ -0,0 +1,14 @@ +{ + "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": "Google Cast" + } + } + } +} \ 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..dcf0391c4201c --- /dev/null +++ b/homeassistant/components/cast/translations/pt.json @@ -0,0 +1,14 @@ +{ + "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" + } + } + } +} \ 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..4dd8c04c38151 --- /dev/null +++ b/homeassistant/components/cast/translations/ro.json @@ -0,0 +1,14 @@ +{ + "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" + } + } + } +} \ 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..fae0cd417ffb6 --- /dev/null +++ b/homeassistant/components/cast/translations/ru.json @@ -0,0 +1,14 @@ +{ + "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" + } + } + } +} \ 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..eb4e930af865e --- /dev/null +++ b/homeassistant/components/cast/translations/sl.json @@ -0,0 +1,14 @@ +{ + "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" + } + } + } +} \ 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..937604b1000b6 --- /dev/null +++ b/homeassistant/components/cast/translations/sv.json @@ -0,0 +1,14 @@ +{ + "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" + } + } + } +} \ 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..9806057716a80 --- /dev/null +++ b/homeassistant/components/cast/translations/th.json @@ -0,0 +1,13 @@ +{ + "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" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/cast/.translations/uk.json b/homeassistant/components/cast/translations/uk.json similarity index 100% rename from homeassistant/components/cast/.translations/uk.json rename to homeassistant/components/cast/translations/uk.json diff --git a/homeassistant/components/cast/translations/vi.json b/homeassistant/components/cast/translations/vi.json new file mode 100644 index 0000000000000..7e75cfce4fab6 --- /dev/null +++ b/homeassistant/components/cast/translations/vi.json @@ -0,0 +1,14 @@ +{ + "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" + } + } + } +} \ 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..9f834a9899077 --- /dev/null +++ b/homeassistant/components/cast/translations/zh-Hans.json @@ -0,0 +1,14 @@ +{ + "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" + } + } + } +} \ 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..0b7101b5cc8e3 --- /dev/null +++ b/homeassistant/components/cast/translations/zh-Hant.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "no_devices_found": "\u5728\u7db2\u8def\u4e0a\u627e\u4e0d\u5230 Google Cast \u8a2d\u5099\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" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/cert_expiry/__init__.py b/homeassistant/components/cert_expiry/__init__.py index 78ceb60dd404b..28a79a3e5058a 100644 --- a/homeassistant/components/cert_expiry/__init__.py +++ b/homeassistant/components/cert_expiry/__init__.py @@ -1 +1,22 @@ """The cert_expiry component.""" +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.typing import HomeAssistantType + + +async def async_setup(hass, config): + """Platform setup, do nothing.""" + return True + + +async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry): + """Load the saved entities.""" + + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, "sensor") + ) + return True + + +async def async_unload_entry(hass, entry): + """Unload a config entry.""" + return await hass.config_entries.async_forward_entry_unload(entry, "sensor") diff --git a/homeassistant/components/cert_expiry/config_flow.py b/homeassistant/components/cert_expiry/config_flow.py new file mode 100644 index 0000000000000..3f77701906f9b --- /dev/null +++ b/homeassistant/components/cert_expiry/config_flow.py @@ -0,0 +1,93 @@ +"""Config flow for the Cert Expiry platform.""" +import logging + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST, CONF_PORT + +from .const import DEFAULT_PORT, DOMAIN # pylint: disable=unused-import +from .errors import ( + ConnectionRefused, + ConnectionTimeout, + ResolveFailed, + ValidationFailure, +) +from .helper import get_cert_time_to_expiry + +_LOGGER = logging.getLogger(__name__) + + +class CertexpiryConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + def __init__(self) -> None: + """Initialize the config flow.""" + self._errors = {} + + async def _test_connection(self, user_input=None): + """Test connection to the server and try to get the certificate.""" + try: + await get_cert_time_to_expiry( + self.hass, + user_input[CONF_HOST], + user_input.get(CONF_PORT, DEFAULT_PORT), + ) + return True + except ResolveFailed: + self._errors[CONF_HOST] = "resolve_failed" + except ConnectionTimeout: + self._errors[CONF_HOST] = "connection_timeout" + except ConnectionRefused: + self._errors[CONF_HOST] = "connection_refused" + except ValidationFailure: + return True + return False + + async def async_step_user(self, user_input=None): + """Step when user initializes a integration.""" + self._errors = {} + if user_input is not None: + host = user_input[CONF_HOST] + port = user_input.get(CONF_PORT, DEFAULT_PORT) + await self.async_set_unique_id(f"{host}:{port}") + self._abort_if_unique_id_configured() + + if await self._test_connection(user_input): + title_port = f":{port}" if port != DEFAULT_PORT else "" + title = f"{host}{title_port}" + return self.async_create_entry( + title=title, data={CONF_HOST: host, CONF_PORT: port}, + ) + if ( # pylint: disable=no-member + self.context["source"] == config_entries.SOURCE_IMPORT + ): + _LOGGER.error("Config import failed for %s", user_input[CONF_HOST]) + return self.async_abort(reason="import_failed") + else: + user_input = {} + user_input[CONF_HOST] = "" + user_input[CONF_PORT] = DEFAULT_PORT + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_HOST, default=user_input[CONF_HOST]): str, + vol.Required( + CONF_PORT, default=user_input.get(CONF_PORT, DEFAULT_PORT) + ): int, + } + ), + errors=self._errors, + ) + + async def async_step_import(self, user_input=None): + """Import a config entry. + + Only host was required in the yaml file all other fields are optional + """ + return await self.async_step_user(user_input) diff --git a/homeassistant/components/cert_expiry/const.py b/homeassistant/components/cert_expiry/const.py new file mode 100644 index 0000000000000..00d5ac9e923f6 --- /dev/null +++ b/homeassistant/components/cert_expiry/const.py @@ -0,0 +1,5 @@ +"""Const for Cert Expiry.""" + +DOMAIN = "cert_expiry" +DEFAULT_PORT = 443 +TIMEOUT = 10.0 diff --git a/homeassistant/components/cert_expiry/errors.py b/homeassistant/components/cert_expiry/errors.py new file mode 100644 index 0000000000000..a3b73c84f2a82 --- /dev/null +++ b/homeassistant/components/cert_expiry/errors.py @@ -0,0 +1,26 @@ +"""Errors for the cert_expiry integration.""" +from homeassistant.exceptions import HomeAssistantError + + +class CertExpiryException(HomeAssistantError): + """Base class for cert_expiry exceptions.""" + + +class TemporaryFailure(CertExpiryException): + """Temporary failure has occurred.""" + + +class ValidationFailure(CertExpiryException): + """Certificate validation failure has occurred.""" + + +class ResolveFailed(TemporaryFailure): + """Name resolution failed.""" + + +class ConnectionTimeout(TemporaryFailure): + """Network connection timed out.""" + + +class ConnectionRefused(TemporaryFailure): + """Network connection refused.""" diff --git a/homeassistant/components/cert_expiry/helper.py b/homeassistant/components/cert_expiry/helper.py new file mode 100644 index 0000000000000..bb9f2762f3a90 --- /dev/null +++ b/homeassistant/components/cert_expiry/helper.py @@ -0,0 +1,44 @@ +"""Helper functions for the Cert Expiry platform.""" +from datetime import datetime +import socket +import ssl + +from .const import TIMEOUT +from .errors import ( + ConnectionRefused, + ConnectionTimeout, + ResolveFailed, + ValidationFailure, +) + + +def get_cert(host, port): + """Get the certificate for the host and port combination.""" + ctx = ssl.create_default_context() + address = (host, port) + with socket.create_connection(address, timeout=TIMEOUT) as sock: + with ctx.wrap_socket(sock, server_hostname=address[0]) as ssock: + # pylint disable: https://github.com/PyCQA/pylint/issues/3166 + cert = ssock.getpeercert() # pylint: disable=no-member + return cert + + +async def get_cert_time_to_expiry(hass, hostname, port): + """Return the certificate's time to expiry in days.""" + try: + cert = await hass.async_add_executor_job(get_cert, hostname, port) + except socket.gaierror: + raise ResolveFailed(f"Cannot resolve hostname: {hostname}") + except socket.timeout: + raise ConnectionTimeout(f"Connection timeout with server: {hostname}:{port}") + except ConnectionRefusedError: + raise ConnectionRefused(f"Connection refused by server: {hostname}:{port}") + except ssl.CertificateError as err: + raise ValidationFailure(err.verify_message) + except ssl.SSLError as err: + raise ValidationFailure(err.args[0]) + + ts_seconds = ssl.cert_time_to_seconds(cert["notAfter"]) + timestamp = datetime.fromtimestamp(ts_seconds) + expiry = timestamp - datetime.today() + return expiry.days diff --git a/homeassistant/components/cert_expiry/manifest.json b/homeassistant/components/cert_expiry/manifest.json index 7ef2e0b7d105d..62216290b805b 100644 --- a/homeassistant/components/cert_expiry/manifest.json +++ b/homeassistant/components/cert_expiry/manifest.json @@ -1,8 +1,7 @@ { "domain": "cert_expiry", - "name": "Cert expiry", - "documentation": "https://www.home-assistant.io/components/cert_expiry", - "requirements": [], - "dependencies": [], - "codeowners": [] + "name": "Certificate Expiry", + "documentation": "https://www.home-assistant.io/integrations/cert_expiry", + "config_flow": true, + "codeowners": ["@Cereal2nd", "@jjlawren"] } diff --git a/homeassistant/components/cert_expiry/sensor.py b/homeassistant/components/cert_expiry/sensor.py index 54ba378f91cc8..ec1e9110317b5 100644 --- a/homeassistant/components/cert_expiry/sensor.py +++ b/homeassistant/components/cert_expiry/sensor.py @@ -1,70 +1,113 @@ """Counter for the days until an HTTPS (TLS) certificate will expire.""" +from datetime import timedelta 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.config_entries import SOURCE_IMPORT +from homeassistant.const import ( + CONF_HOST, + CONF_PORT, + EVENT_HOMEASSISTANT_START, + TIME_DAYS, +) +from homeassistant.core import callback +from homeassistant.exceptions import PlatformNotReady +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import async_call_later -_LOGGER = logging.getLogger(__name__) +from .const import DEFAULT_PORT, DOMAIN +from .errors import TemporaryFailure, ValidationFailure +from .helper import get_cert_time_to_expiry -DEFAULT_NAME = 'SSL Certificate Expiry' -DEFAULT_PORT = 443 +_LOGGER = logging.getLogger(__name__) 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, -}) +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + } +) -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_platform(hass, config, async_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) + @callback + def schedule_import(_): + """Schedule delayed import after HA is fully started.""" + async_call_later(hass, 10, do_import) + + @callback + def do_import(_): + """Process YAML import.""" + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=dict(config) + ) + ) + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, schedule_import) + + +async def async_setup_entry(hass, entry, async_add_entities): + """Add cert-expiry entry.""" + days = 0 + error = None + hostname = entry.data[CONF_HOST] + port = entry.data[CONF_PORT] - add_entities([SSLCertificate(sensor_name, server_name, server_port)], - True) + if entry.unique_id is None: + hass.config_entries.async_update_entry(entry, unique_id=f"{hostname}:{port}") - # To allow checking of the HA certificate we must first be running. - hass.bus.listen_once(EVENT_HOMEASSISTANT_START, run_setup) + try: + days = await get_cert_time_to_expiry(hass, hostname, port) + except TemporaryFailure as err: + _LOGGER.error(err) + raise PlatformNotReady + except ValidationFailure as err: + error = err + + async_add_entities( + [SSLCertificate(hostname, port, days, error)], False, + ) + return True class SSLCertificate(Entity): """Implementation of the certificate expiry sensor.""" - def __init__(self, sensor_name, server_name, server_port): + def __init__(self, server_name, server_port, days, error): """Initialize the sensor.""" self.server_name = server_name self.server_port = server_port - self._name = sensor_name - self._state = None + display_port = f":{server_port}" if server_port != DEFAULT_PORT else "" + self._name = f"Cert Expiry ({self.server_name}{display_port})" + self._available = True + self._error = error + self._state = days + self._valid = False + if error is None: + self._valid = True @property def name(self): """Return the name of the sensor.""" return self._name + @property + def unique_id(self): + """Return a unique id for the sensor.""" + return f"{self.server_name}:{self.server_port}" + @property def unit_of_measurement(self): """Return the unit this state is expressed in.""" - return 'days' + return TIME_DAYS @property def state(self): @@ -74,36 +117,45 @@ def state(self): @property def icon(self): """Icon to use in the frontend, if any.""" - return 'mdi:certificate' + return "mdi:certificate" + + @property + def available(self): + """Return the availability of the sensor.""" + return self._available - def update(self): + async def async_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) + days_to_expiry = await get_cert_time_to_expiry( + self.hass, self.server_name, self.server_port + ) + except TemporaryFailure as err: + _LOGGER.error(err.args[0]) + self._available = False return - except socket.timeout: + except ValidationFailure as err: _LOGGER.error( - "Connection timeout with server: %s", self.server_name) + "Certificate validation error: %s [%s]", self.server_name, err + ) + self._available = True + self._error = err + self._state = 0 + self._valid = False return - except OSError: - _LOGGER.error("Cannot connect to %s", self.server_name) + except Exception: # pylint: disable=broad-except + _LOGGER.exception( + "Unknown error checking %s:%s", self.server_name, self.server_port + ) + self._available = False return - try: - cert = sock.getpeercert() - except OSError: - _LOGGER.error("Cannot fetch certificate from %s", self.server_name) - return + self._available = True + self._error = None + self._state = days_to_expiry + self._valid = True - ts_seconds = ssl.cert_time_to_seconds(cert['notAfter']) - timestamp = datetime.fromtimestamp(ts_seconds) - expiry = timestamp - datetime.today() - self._state = expiry.days + @property + def device_state_attributes(self): + """Return additional sensor state attributes.""" + return {"is_valid": self._valid, "error": str(self._error)} diff --git a/homeassistant/components/cert_expiry/strings.json b/homeassistant/components/cert_expiry/strings.json new file mode 100644 index 0000000000000..f456809147b74 --- /dev/null +++ b/homeassistant/components/cert_expiry/strings.json @@ -0,0 +1,24 @@ +{ + "title": "Certificate Expiry", + "config": { + "step": { + "user": { + "title": "Define the certificate to test", + "data": { + "name": "The name of the certificate", + "host": "The hostname of the certificate", + "port": "The port of the certificate" + } + } + }, + "error": { + "resolve_failed": "This host can not be resolved", + "connection_timeout": "Timeout when connecting to this host", + "connection_refused": "Connection refused when connecting to host" + }, + "abort": { + "already_configured": "This host and port combination is already configured", + "import_failed": "Import from config failed" + } + } +} diff --git a/homeassistant/components/cert_expiry/translations/bg.json b/homeassistant/components/cert_expiry/translations/bg.json new file mode 100644 index 0000000000000..7ff68cd7fae34 --- /dev/null +++ b/homeassistant/components/cert_expiry/translations/bg.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "connection_timeout": "\u041d\u0435\u0432\u044a\u0437\u043c\u043e\u0436\u043d\u043e\u0441\u0442 \u0437\u0430 \u0441\u0432\u043e\u0435\u0432\u0440\u0435\u043c\u0435\u043d\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435 \u0441 \u0442\u043e\u0437\u0438 \u0430\u0434\u0440\u0435\u0441", + "resolve_failed": "\u0422\u043e\u0437\u0438 \u0430\u0434\u0440\u0435\u0441 \u043d\u0435 \u043c\u043e\u0436\u0435 \u0434\u0430 \u0431\u044a\u0434\u0435 \u043d\u0430\u043c\u0435\u0440\u0435\u043d" + }, + "step": { + "user": { + "data": { + "host": "\u0410\u0434\u0440\u0435\u0441 \u0432 \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u0430", + "name": "\u0418\u043c\u0435 \u043d\u0430 \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u0430", + "port": "\u041f\u043e\u0440\u0442 \u043d\u0430 \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u0430" + }, + "title": "\u0414\u0435\u0444\u0438\u043d\u0438\u0440\u0430\u043d\u0435 \u043d\u0430 \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u0430 \u0437\u0430 \u0442\u0435\u0441\u0442\u0432\u0430\u043d\u0435" + } + } + }, + "title": "\u0421\u0440\u043e\u043a \u043d\u0430 \u0432\u0430\u043b\u0438\u0434\u043d\u043e\u0441\u0442 \u043d\u0430 \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u0430" +} \ No newline at end of file diff --git a/homeassistant/components/cert_expiry/translations/ca.json b/homeassistant/components/cert_expiry/translations/ca.json new file mode 100644 index 0000000000000..5b9b095acbc19 --- /dev/null +++ b/homeassistant/components/cert_expiry/translations/ca.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "Aquesta combinaci\u00f3 d'amfitri\u00f3 i port ja est\u00e0 configurada", + "import_failed": "La importaci\u00f3 des de configuraci\u00f3 ha fallat" + }, + "error": { + "connection_refused": "La connexi\u00f3 s'ha rebutjat en connectar-se a l'amfitri\u00f3", + "connection_timeout": "S'ha acabat el temps d'espera durant la connexi\u00f3 amb l'amfitri\u00f3.", + "resolve_failed": "No s'ha pogut resoldre l'amfitri\u00f3" + }, + "step": { + "user": { + "data": { + "host": "Nom de l'amfitri\u00f3 del certificat", + "name": "Nom del certificat", + "port": "Port del certificat" + }, + "title": "Configuraci\u00f3 del certificat a provar" + } + } + }, + "title": "Caducitat del certificat" +} \ No newline at end of file diff --git a/homeassistant/components/cert_expiry/translations/cs.json b/homeassistant/components/cert_expiry/translations/cs.json new file mode 100644 index 0000000000000..58a5a281ea275 --- /dev/null +++ b/homeassistant/components/cert_expiry/translations/cs.json @@ -0,0 +1,8 @@ +{ + "config": { + "error": { + "certificate_error": "Certifik\u00e1t nelze ov\u011b\u0159it", + "wrong_host": "Certifik\u00e1t neodpov\u00edd\u00e1 n\u00e1zvu hostitele" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/cert_expiry/translations/da.json b/homeassistant/components/cert_expiry/translations/da.json new file mode 100644 index 0000000000000..6c94fe09806a9 --- /dev/null +++ b/homeassistant/components/cert_expiry/translations/da.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "connection_timeout": "Timeout ved tilslutning til denne v\u00e6rt", + "resolve_failed": "V\u00e6rten kunne ikke findes" + }, + "step": { + "user": { + "data": { + "host": "Certifikatets v\u00e6rtsnavn", + "name": "Certifikatets navn", + "port": "Certifikatets port" + }, + "title": "Definer certifikatet, der skal testes" + } + } + }, + "title": "Certifikatets udl\u00f8bsdato" +} \ No newline at end of file diff --git a/homeassistant/components/cert_expiry/translations/de.json b/homeassistant/components/cert_expiry/translations/de.json new file mode 100644 index 0000000000000..e3733a4fcf183 --- /dev/null +++ b/homeassistant/components/cert_expiry/translations/de.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "Diese Kombination aus Host und Port ist bereits konfiguriert.", + "import_failed": "Import aus Konfiguration fehlgeschlagen" + }, + "error": { + "connection_refused": "Verbindung beim Herstellen einer Verbindung zum Host abgelehnt", + "connection_timeout": "Zeit\u00fcberschreitung beim Herstellen einer Verbindung mit diesem Host", + "resolve_failed": "Dieser Host kann nicht aufgel\u00f6st werden" + }, + "step": { + "user": { + "data": { + "host": "Der Hostname des Zertifikats", + "name": "Der Name des Zertifikats", + "port": "Der Port des Zertifikats" + }, + "title": "Definiere das zu testende Zertifikat" + } + } + }, + "title": "Zertifikatsablauf" +} \ No newline at end of file diff --git a/homeassistant/components/cert_expiry/translations/en.json b/homeassistant/components/cert_expiry/translations/en.json new file mode 100644 index 0000000000000..5844868a6e4bd --- /dev/null +++ b/homeassistant/components/cert_expiry/translations/en.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "This host and port combination is already configured", + "import_failed": "Import from config failed" + }, + "error": { + "connection_refused": "Connection refused when connecting to host", + "connection_timeout": "Timeout when connecting to this host", + "resolve_failed": "This host can not be resolved" + }, + "step": { + "user": { + "data": { + "host": "The hostname of the certificate", + "name": "The name of the certificate", + "port": "The port of the certificate" + }, + "title": "Define the certificate to test" + } + } + }, + "title": "Certificate Expiry" +} \ No newline at end of file diff --git a/homeassistant/components/cert_expiry/translations/es-419.json b/homeassistant/components/cert_expiry/translations/es-419.json new file mode 100644 index 0000000000000..772e37e25c825 --- /dev/null +++ b/homeassistant/components/cert_expiry/translations/es-419.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "import_failed": "La importaci\u00f3n desde la configuraci\u00f3n fall\u00f3" + }, + "error": { + "connection_timeout": "Tiempo de espera al conectarse a este host", + "resolve_failed": "Este host no puede resolverse" + }, + "step": { + "user": { + "data": { + "host": "El nombre de host del certificado", + "name": "El nombre del certificado", + "port": "El puerto del certificado" + }, + "title": "Definir el certificado para probar" + } + } + }, + "title": "Expiraci\u00f3n del certificado" +} \ No newline at end of file diff --git a/homeassistant/components/cert_expiry/translations/es.json b/homeassistant/components/cert_expiry/translations/es.json new file mode 100644 index 0000000000000..d616634fdeafe --- /dev/null +++ b/homeassistant/components/cert_expiry/translations/es.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "Esta combinaci\u00f3n de host y puerto ya est\u00e1 configurada", + "import_failed": "No se pudo importar desde la configuraci\u00f3n" + }, + "error": { + "connection_refused": "Conexi\u00f3n rechazada al conectarse al host", + "connection_timeout": "Tiempo de espera agotado al conectar a este host", + "resolve_failed": "Este host no se puede resolver" + }, + "step": { + "user": { + "data": { + "host": "El nombre de host del certificado", + "name": "El nombre del certificado", + "port": "El puerto del certificado" + }, + "title": "Defina el certificado para probar" + } + } + }, + "title": "Caducidad del certificado" +} \ No newline at end of file diff --git a/homeassistant/components/cert_expiry/translations/fr.json b/homeassistant/components/cert_expiry/translations/fr.json new file mode 100644 index 0000000000000..8c3d92edf9299 --- /dev/null +++ b/homeassistant/components/cert_expiry/translations/fr.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "Cette combinaison h\u00f4te et port est d\u00e9j\u00e0 configur\u00e9e", + "import_failed": "\u00c9chec de l'importation \u00e0 partir de la configuration" + }, + "error": { + "connection_refused": "Connexion refus\u00e9e lors de la connexion \u00e0 l'h\u00f4te", + "connection_timeout": "D\u00e9lai d'attente lors de la connexion \u00e0 cet h\u00f4te", + "resolve_failed": "Cet h\u00f4te ne peut pas \u00eatre r\u00e9solu" + }, + "step": { + "user": { + "data": { + "host": "Le nom d'h\u00f4te du certificat", + "name": "Le nom du certificat", + "port": "Le port du certificat" + }, + "title": "D\u00e9finir le certificat \u00e0 tester" + } + } + }, + "title": "Expiration du certificat" +} \ No newline at end of file diff --git a/homeassistant/components/cert_expiry/translations/hu.json b/homeassistant/components/cert_expiry/translations/hu.json new file mode 100644 index 0000000000000..584f4c2b75961 --- /dev/null +++ b/homeassistant/components/cert_expiry/translations/hu.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "name": "A tan\u00fas\u00edtv\u00e1ny neve", + "port": "A tan\u00fas\u00edtv\u00e1ny portja" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/cert_expiry/translations/it.json b/homeassistant/components/cert_expiry/translations/it.json new file mode 100644 index 0000000000000..c5e56bc95a27d --- /dev/null +++ b/homeassistant/components/cert_expiry/translations/it.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "Questa combinazione di host e porta \u00e8 gi\u00e0 configurata", + "import_failed": "Importazione dalla configurazione non riuscita" + }, + "error": { + "connection_refused": "Connessione rifiutata durante la connessione all'host", + "connection_timeout": "Tempo scaduto collegandosi a questo host", + "resolve_failed": "Questo host non pu\u00f2 essere risolto" + }, + "step": { + "user": { + "data": { + "host": "L'hostname del certificato", + "name": "Il nome del certificato", + "port": "La porta del certificato" + }, + "title": "Definire il certificato da testare" + } + } + }, + "title": "Scadenza certificato" +} \ No newline at end of file diff --git a/homeassistant/components/cert_expiry/translations/ko.json b/homeassistant/components/cert_expiry/translations/ko.json new file mode 100644 index 0000000000000..699ca413604fc --- /dev/null +++ b/homeassistant/components/cert_expiry/translations/ko.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "\ud638\uc2a4\ud2b8\uc640 \ud3ec\ud2b8\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "import_failed": "\uad6c\uc131\uc5d0\uc11c \uac00\uc838\uc624\uae30 \uc2e4\ud328" + }, + "error": { + "connection_refused": "\ud638\uc2a4\ud2b8\uc5d0 \uc5f0\uacb0\uc774 \uac70\ubd80\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", + "connection_timeout": "\ud638\uc2a4\ud2b8 \uc5f0\uacb0 \uc2dc\uac04\uc774 \ucd08\uacfc\ud588\uc2b5\ub2c8\ub2e4", + "resolve_failed": "\ud638\uc2a4\ud2b8\ub97c \ucc3e\uc744 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4" + }, + "step": { + "user": { + "data": { + "host": "\uc778\uc99d\uc11c\uc758 \ud638\uc2a4\ud2b8 \uc774\ub984", + "name": "\uc778\uc99d\uc11c\uc758 \uc774\ub984", + "port": "\uc778\uc99d\uc11c\uc758 \ud3ec\ud2b8" + }, + "title": "\uc778\uc99d\uc11c \uc815\uc758 \ud14c\uc2a4\ud2b8 \ub300\uc0c1" + } + } + }, + "title": "\uc778\uc99d\uc11c \ub9cc\ub8cc" +} \ No newline at end of file diff --git a/homeassistant/components/cert_expiry/translations/lb.json b/homeassistant/components/cert_expiry/translations/lb.json new file mode 100644 index 0000000000000..db6d5c7ccb0f6 --- /dev/null +++ b/homeassistant/components/cert_expiry/translations/lb.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "D\u00ebs Kombinatioun vun Host an Port sinn scho konfigur\u00e9iert", + "import_failed": "Import vun der Konfiguratioun feelgeschloen" + }, + "error": { + "connection_refused": "Verbindung refus\u00e9iert beim verbannen mam Host", + "connection_timeout": "Z\u00e4it Iwwerschreidung beim verbannen.", + "resolve_failed": "D\u00ebsen Host kann net opgel\u00e9ist ginn" + }, + "step": { + "user": { + "data": { + "host": "Den Hostnumm vum Zertifikat", + "name": "De Numm vum Zertifikat", + "port": "De Port vum Zertifikat" + }, + "title": "W\u00e9ieen Zertifikat soll getest ginn" + } + } + }, + "title": "Zertifikat Verfallsdatum" +} \ No newline at end of file diff --git a/homeassistant/components/cert_expiry/translations/nl.json b/homeassistant/components/cert_expiry/translations/nl.json new file mode 100644 index 0000000000000..d844d28e62fe8 --- /dev/null +++ b/homeassistant/components/cert_expiry/translations/nl.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "Deze combinatie van host en poort is al geconfigureerd", + "import_failed": "Importeren vanuit configuratie is mislukt" + }, + "error": { + "connection_refused": "Verbinding geweigerd bij verbinding met host", + "connection_timeout": "Time-out bij verbinding maken met deze host", + "resolve_failed": "Deze host kon niet gevonden worden" + }, + "step": { + "user": { + "data": { + "host": "De hostnaam van het certificaat", + "name": "De naam van het certificaat", + "port": "De poort van het certificaat" + }, + "title": "Het certificaat defini\u00ebren dat moet worden getest" + } + } + }, + "title": "Vervaldatum certificaat" +} \ No newline at end of file diff --git a/homeassistant/components/cert_expiry/translations/no.json b/homeassistant/components/cert_expiry/translations/no.json new file mode 100644 index 0000000000000..341efe2d93215 --- /dev/null +++ b/homeassistant/components/cert_expiry/translations/no.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "Denne verts- og portkombinasjonen er allerede konfigurert", + "import_failed": "Import fra config mislyktes" + }, + "error": { + "connection_refused": "Tilkoblingen ble nektet da den koblet til verten", + "connection_timeout": "Tidsavbrudd n\u00e5r du kobler til denne verten", + "resolve_failed": "Denne verten kan ikke l\u00f8ses" + }, + "step": { + "user": { + "data": { + "host": "Sertifikatets vertsnavn", + "name": "Sertifikatets navn", + "port": "Sertifikatets port" + }, + "title": "Definer sertifikatet som skal testes" + } + } + }, + "title": "Sertifikat utl\u00f8p" +} \ No newline at end of file diff --git a/homeassistant/components/cert_expiry/translations/pl.json b/homeassistant/components/cert_expiry/translations/pl.json new file mode 100644 index 0000000000000..f213befed4f7b --- /dev/null +++ b/homeassistant/components/cert_expiry/translations/pl.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "Ta kombinacja hosta i portu jest ju\u017c skonfigurowana", + "import_failed": "Import z konfiguracji nie powi\u00f3d\u0142 si\u0119" + }, + "error": { + "connection_refused": "Po\u0142\u0105czenie odrzucone podczas \u0142\u0105czenia z hostem", + "connection_timeout": "Przekroczono limit czasu po\u0142\u0105czenia z hostem.", + "resolve_failed": "Tego hosta nie mo\u017cna rozwi\u0105za\u0107" + }, + "step": { + "user": { + "data": { + "host": "Nazwa hosta certyfikatu", + "name": "Nazwa certyfikatu", + "port": "Port certyfikatu" + }, + "title": "Zdefiniuj certyfikat do przetestowania" + } + } + }, + "title": "Wa\u017cno\u015b\u0107 certyfikatu" +} \ No newline at end of file diff --git a/homeassistant/components/cert_expiry/translations/pt-BR.json b/homeassistant/components/cert_expiry/translations/pt-BR.json new file mode 100644 index 0000000000000..6a395059625ce --- /dev/null +++ b/homeassistant/components/cert_expiry/translations/pt-BR.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "connection_timeout": "Tempo limite ao conectar-se a este host", + "resolve_failed": "Este host n\u00e3o pode ser resolvido" + }, + "step": { + "user": { + "data": { + "host": "O nome do host do certificado", + "name": "O nome do certificado", + "port": "A porta do certificado" + }, + "title": "Defina o certificado para testar" + } + } + }, + "title": "Expira\u00e7\u00e3o do certificado" +} \ No newline at end of file diff --git a/homeassistant/components/cert_expiry/translations/ru.json b/homeassistant/components/cert_expiry/translations/ru.json new file mode 100644 index 0000000000000..5219caa057dd3 --- /dev/null +++ b/homeassistant/components/cert_expiry/translations/ru.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "\u042d\u0442\u0430 \u043a\u043e\u043c\u0431\u0438\u043d\u0430\u0446\u0438\u044f \u0445\u043e\u0441\u0442\u0430 \u0438 \u043f\u043e\u0440\u0442\u0430 \u0443\u0436\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d\u0430.", + "import_failed": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0438\u043c\u043f\u043e\u0440\u0442\u0430 \u0438\u0437 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438." + }, + "error": { + "connection_refused": "\u041f\u0440\u0438 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0438 \u043a \u0445\u043e\u0441\u0442\u0443 \u0431\u044b\u043b\u043e \u043e\u0442\u043a\u0430\u0437\u0430\u043d\u043e \u0432 \u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u0438.", + "connection_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 \u0445\u043e\u0441\u0442\u0443.", + "resolve_failed": "\u041d\u0435\u0432\u043e\u0437\u043c\u043e\u0436\u043d\u043e \u043f\u0440\u0435\u043e\u0431\u0440\u0430\u0437\u043e\u0432\u0430\u0442\u044c \u0445\u043e\u0441\u0442." + }, + "step": { + "user": { + "data": { + "host": "\u0414\u043e\u043c\u0435\u043d\u043d\u043e\u0435 \u0438\u043c\u044f", + "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435", + "port": "\u041f\u043e\u0440\u0442" + }, + "title": "\u0421\u0440\u043e\u043a \u0434\u0435\u0439\u0441\u0442\u0432\u0438\u044f \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u0430" + } + } + }, + "title": "\u0421\u0440\u043e\u043a \u0434\u0435\u0439\u0441\u0442\u0432\u0438\u044f \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u0430" +} \ No newline at end of file diff --git a/homeassistant/components/cert_expiry/translations/sl.json b/homeassistant/components/cert_expiry/translations/sl.json new file mode 100644 index 0000000000000..1da2a7921f3e7 --- /dev/null +++ b/homeassistant/components/cert_expiry/translations/sl.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "Ta kombinacija gostitelja in vrat je \u017ee konfigurirana", + "import_failed": "Uvoz iz konfiguracije ni uspel" + }, + "error": { + "connection_refused": "Povezava zavrnjena, ko ste se povezali z gostiteljem", + "connection_timeout": "\u010casovna omejitev za povezavo s tem gostiteljem je potekla", + "resolve_failed": "Tega gostitelja ni mogo\u010de razre\u0161iti" + }, + "step": { + "user": { + "data": { + "host": "Ime gostitelja potrdila", + "name": "Ime potrdila", + "port": "Vrata potrdila" + }, + "title": "Dolo\u010dite potrdilo za testiranje" + } + } + }, + "title": "Veljavnost certifikata" +} \ No newline at end of file diff --git a/homeassistant/components/cert_expiry/translations/sv.json b/homeassistant/components/cert_expiry/translations/sv.json new file mode 100644 index 0000000000000..23703f11e5bed --- /dev/null +++ b/homeassistant/components/cert_expiry/translations/sv.json @@ -0,0 +1,20 @@ +{ + "config": { + "error": { + "connection_refused": "Anslutningen blev tillbakavisad under anslutning till v\u00e4rd.", + "connection_timeout": "Timeout vid anslutning till den h\u00e4r v\u00e4rden", + "resolve_failed": "Denna v\u00e4rd kan inte resolveras" + }, + "step": { + "user": { + "data": { + "host": "Certifikatets v\u00e4rdnamn", + "name": "Certifikatets namn", + "port": "Certifikatets port" + }, + "title": "Definiera certifikatet som ska testas" + } + } + }, + "title": "Certifikatets utg\u00e5ng" +} \ No newline at end of file diff --git a/homeassistant/components/cert_expiry/translations/zh-Hans.json b/homeassistant/components/cert_expiry/translations/zh-Hans.json new file mode 100644 index 0000000000000..07affc990a817 --- /dev/null +++ b/homeassistant/components/cert_expiry/translations/zh-Hans.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "connection_timeout": "\u8fde\u63a5\u5230\u6b64\u4e3b\u673a\u65f6\u7684\u8d85\u65f6" + }, + "step": { + "user": { + "data": { + "host": "\u8bc1\u4e66\u7684\u4e3b\u673a\u540d", + "name": "\u8bc1\u4e66\u7684\u540d\u79f0", + "port": "\u8bc1\u4e66\u7684\u7aef\u53e3" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/cert_expiry/translations/zh-Hant.json b/homeassistant/components/cert_expiry/translations/zh-Hant.json new file mode 100644 index 0000000000000..1968f3d866c29 --- /dev/null +++ b/homeassistant/components/cert_expiry/translations/zh-Hant.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "\u6b64\u4e3b\u6a5f\u7aef\u8207\u901a\u8a0a\u57e0\u7d44\u5408\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "import_failed": "\u532f\u5165\u8a2d\u5b9a\u5931\u6557" + }, + "error": { + "connection_refused": "\u9023\u7dda\u81f3\u4e3b\u6a5f\u6642\u906d\u62d2\u7d55", + "connection_timeout": "\u9023\u7dda\u81f3\u4e3b\u6a5f\u7aef\u903e\u6642", + "resolve_failed": "\u4e3b\u6a5f\u7aef\u7121\u6cd5\u89e3\u6790" + }, + "step": { + "user": { + "data": { + "host": "\u8a8d\u8b49\u4e3b\u6a5f\u7aef\u540d\u7a31", + "name": "\u8a8d\u8b49\u540d\u7a31", + "port": "\u8a8d\u8b49\u901a\u8a0a\u57e0" + }, + "title": "\u5b9a\u7fa9\u8a8d\u8b49\u9032\u884c\u6e2c\u8a66" + } + } + }, + "title": "\u6191\u8b49\u671f\u9650" +} \ No newline at end of file diff --git a/homeassistant/components/channels/const.py b/homeassistant/components/channels/const.py new file mode 100644 index 0000000000000..5ae7fdebb0b19 --- /dev/null +++ b/homeassistant/components/channels/const.py @@ -0,0 +1,5 @@ +"""Constants for the Channels component.""" +DOMAIN = "channels" +SERVICE_SEEK_FORWARD = "seek_forward" +SERVICE_SEEK_BACKWARD = "seek_backward" +SERVICE_SEEK_BY = "seek_by" diff --git a/homeassistant/components/channels/manifest.json b/homeassistant/components/channels/manifest.json index 152c7d3a2dc5e..45248bf1e7d25 100644 --- a/homeassistant/components/channels/manifest.json +++ b/homeassistant/components/channels/manifest.json @@ -1,10 +1,7 @@ { "domain": "channels", "name": "Channels", - "documentation": "https://www.home-assistant.io/components/channels", - "requirements": [ - "pychannels==1.0.0" - ], - "dependencies": [], + "documentation": "https://www.home-assistant.io/integrations/channels", + "requirements": ["pychannels==1.0.0"], "codeowners": [] } diff --git a/homeassistant/components/channels/media_player.py b/homeassistant/components/channels/media_player.py index abd3281d11a73..65be051ad178c 100644 --- a/homeassistant/components/channels/media_player.py +++ b/homeassistant/components/channels/media_player.py @@ -1,56 +1,76 @@ """Support for interfacing with an instance of getchannels.com.""" import logging +from pychannels import Channels import voluptuous as vol -from homeassistant.components.media_player import ( - MediaPlayerDevice, PLATFORM_SCHEMA) +from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerEntity 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) + 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) + ATTR_ENTITY_ID, + CONF_HOST, + CONF_NAME, + CONF_PORT, + STATE_IDLE, + STATE_PAUSED, + STATE_PLAYING, +) import homeassistant.helpers.config_validation as cv +from .const import DOMAIN, SERVICE_SEEK_BACKWARD, SERVICE_SEEK_BY, SERVICE_SEEK_FORWARD + _LOGGER = logging.getLogger(__name__) -DATA_CHANNELS = 'channels' -DEFAULT_NAME = 'Channels' +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, -}) +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' +ATTR_SECONDS = "seconds" -CHANNELS_SCHEMA = vol.Schema({ - vol.Required(ATTR_ENTITY_ID): cv.entity_id, -}) +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), -}) +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)) + device = ChannelsPlayer(config[CONF_NAME], config[CONF_HOST], config[CONF_PORT]) if DATA_CHANNELS not in hass.data: hass.data[DATA_CHANNELS] = [] @@ -62,12 +82,17 @@ 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) + 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) + _LOGGER.warning("Unable to find Channels with entity_id: %s", entity_id) return if service.service == SERVICE_SEEK_FORWARD: @@ -75,26 +100,27 @@ def service_handler(service): elif service.service == SERVICE_SEEK_BACKWARD: device.seek_backward() elif service.service == SERVICE_SEEK_BY: - seconds = service.data.get('seconds') + seconds = service.data.get("seconds") device.seek_by(seconds) hass.services.register( - DOMAIN, SERVICE_SEEK_FORWARD, service_handler, schema=CHANNELS_SCHEMA) + DOMAIN, SERVICE_SEEK_FORWARD, service_handler, schema=CHANNELS_SCHEMA + ) hass.services.register( - DOMAIN, SERVICE_SEEK_BACKWARD, service_handler, schema=CHANNELS_SCHEMA) + DOMAIN, SERVICE_SEEK_BACKWARD, service_handler, schema=CHANNELS_SCHEMA + ) hass.services.register( - DOMAIN, SERVICE_SEEK_BY, service_handler, - schema=CHANNELS_SEEK_BY_SCHEMA) + DOMAIN, SERVICE_SEEK_BY, service_handler, schema=CHANNELS_SEEK_BY_SCHEMA + ) -class ChannelsPlayer(MediaPlayerDevice): +class ChannelsPlayer(MediaPlayerEntity): """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 @@ -124,28 +150,28 @@ def update_favorite_channels(self): 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) + 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') + 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') + 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') + 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 @@ -162,13 +188,13 @@ def name(self): @property def state(self): """Return the state of the player.""" - if self.status == 'stopped': + if self.status == "stopped": return STATE_IDLE - if self.status == 'paused': + if self.status == "paused": return STATE_PAUSED - if self.status == 'playing': + if self.status == "playing": return STATE_PLAYING return None @@ -181,7 +207,7 @@ def update(self): @property def source_list(self): """List of favorite channels.""" - sources = [channel['name'] for channel in self.favorite_channels] + sources = [channel["name"] for channel in self.favorite_channels] return sources @property @@ -207,7 +233,7 @@ def media_image_url(self): if self.channel_image_url: return self.channel_image_url - return 'https://getchannels.com/assets/img/icon-1024.png' + return "https://getchannels.com/assets/img/icon-1024.png" @property def media_title(self): @@ -267,8 +293,7 @@ def play_media(self, media_type, media_id, **kwargs): 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]: + elif media_type in [MEDIA_TYPE_MOVIE, MEDIA_TYPE_EPISODE, MEDIA_TYPE_TVSHOW]: response = self.client.play_recording(media_id) self.update_state(response) diff --git a/homeassistant/components/channels/services.yaml b/homeassistant/components/channels/services.yaml index e69de29bb2d1d..f06b2bfd90556 100644 --- a/homeassistant/components/channels/services.yaml +++ b/homeassistant/components/channels/services.yaml @@ -0,0 +1,23 @@ +seek_forward: + description: Seek forward by a set number of seconds. + fields: + entity_id: + description: Name of entity for the instance of Channels to seek in. + example: "media_player.family_room_channels" + +seek_backward: + description: Seek backward by a set number of seconds. + fields: + entity_id: + description: Name of entity for the instance of Channels to seek in. + example: "media_player.family_room_channels" + +seek_by: + description: Seek by an inputted number of seconds. + fields: + entity_id: + description: Name of entity for the instance of Channels to seek in. + example: "media_player.family_room_channels" + seconds: + description: Number of seconds to seek by. Negative numbers seek backwards. + example: 120 diff --git a/homeassistant/components/cisco_ios/device_tracker.py b/homeassistant/components/cisco_ios/device_tracker.py index 5eb039709890c..8bf2b77fa2502 100644 --- a/homeassistant/components/cisco_ios/device_tracker.py +++ b/homeassistant/components/cisco_ios/device_tracker.py @@ -1,23 +1,29 @@ """Support for Cisco IOS Routers.""" import logging +import re +from pexpect import pxssh 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 + DOMAIN, + PLATFORM_SCHEMA, + DeviceScanner, +) +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME +import homeassistant.helpers.config_validation as cv _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, - }) + 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, + } + ) ) @@ -36,12 +42,12 @@ def __init__(self, config): self.host = config[CONF_HOST] self.username = config[CONF_USERNAME] self.port = config.get(CONF_PORT) - self.password = config.get(CONF_PASSWORD) + self.password = config[CONF_PASSWORD] self.last_results = {} self.success_init = self._update_info() - _LOGGER.info('cisco_ios scanner initialized') + _LOGGER.info("cisco_ios scanner initialized") def get_device_name(self, device): """Get the firmware doesn't save the name of the wireless device.""" @@ -96,20 +102,23 @@ def _update_info(self): 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) + 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() + 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() + 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") @@ -120,7 +129,7 @@ def _get_arp_data(self): devices_result = cisco_ssh.before - return devices_result.decode('utf-8') + return devices_result.decode("utf-8") except pxssh.ExceptionPxssh as px_e: _LOGGER.error("pxssh failed on login") _LOGGER.error(px_e) @@ -141,8 +150,9 @@ def _parse_cisco_mac_address(cisco_hardware_addr): 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)] + 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() + return ":".join(blocks).upper() diff --git a/homeassistant/components/cisco_ios/manifest.json b/homeassistant/components/cisco_ios/manifest.json index 9a12ba252e374..b485cf831b17b 100644 --- a/homeassistant/components/cisco_ios/manifest.json +++ b/homeassistant/components/cisco_ios/manifest.json @@ -1,10 +1,7 @@ { "domain": "cisco_ios", - "name": "Cisco ios", - "documentation": "https://www.home-assistant.io/components/cisco_ios", - "requirements": [ - "pexpect==4.6.0" - ], - "dependencies": [], + "name": "Cisco IOS", + "documentation": "https://www.home-assistant.io/integrations/cisco_ios", + "requirements": ["pexpect==4.6.0"], "codeowners": ["@fbradyirl"] } diff --git a/homeassistant/components/cisco_mobility_express/device_tracker.py b/homeassistant/components/cisco_mobility_express/device_tracker.py index 4af94588d3b18..b032ca30fc3e7 100644 --- a/homeassistant/components/cisco_mobility_express/device_tracker.py +++ b/homeassistant/components/cisco_mobility_express/device_tracker.py @@ -1,39 +1,51 @@ """Support for Cisco Mobility Express.""" import logging +from ciscomobilityexpress.ciscome import CiscoMobilityExpress import voluptuous as vol -import homeassistant.helpers.config_validation as cv from homeassistant.components.device_tracker import ( - DOMAIN, PLATFORM_SCHEMA, DeviceScanner) + DOMAIN, + PLATFORM_SCHEMA, + DeviceScanner, +) from homeassistant.const import ( - CONF_HOST, CONF_USERNAME, CONF_PASSWORD, CONF_SSL, CONF_VERIFY_SSL) + CONF_HOST, + CONF_PASSWORD, + CONF_SSL, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +import homeassistant.helpers.config_validation as cv _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, -}) +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)) + config[CONF_SSL], + config[CONF_VERIFY_SSL], + ) if not controller.is_logged_in(): return None return CiscoMEDeviceScanner(controller) @@ -55,9 +67,10 @@ def scan_devices(self): 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) + name = next( + (result.clId for result in self.last_results if result.macaddr == device), + None, + ) return name def get_extra_attributes(self, device): @@ -67,13 +80,14 @@ def get_extra_attributes(self, 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) + 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) + _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 index d1b4687c2cdef..972903e53e6db 100644 --- a/homeassistant/components/cisco_mobility_express/manifest.json +++ b/homeassistant/components/cisco_mobility_express/manifest.json @@ -1,10 +1,7 @@ { "domain": "cisco_mobility_express", - "name": "Cisco mobility express", - "documentation": "https://www.home-assistant.io/components/cisco_mobility_express", - "requirements": [ - "ciscomobilityexpress==0.1.5" - ], - "dependencies": [], + "name": "Cisco Mobility Express", + "documentation": "https://www.home-assistant.io/integrations/cisco_mobility_express", + "requirements": ["ciscomobilityexpress==0.3.3"], "codeowners": ["@fbradyirl"] } diff --git a/homeassistant/components/cisco_webex_teams/manifest.json b/homeassistant/components/cisco_webex_teams/manifest.json index 21c4efe071c95..d10f9641846ef 100644 --- a/homeassistant/components/cisco_webex_teams/manifest.json +++ b/homeassistant/components/cisco_webex_teams/manifest.json @@ -1,10 +1,7 @@ { "domain": "cisco_webex_teams", - "name": "Cisco webex teams", - "documentation": "https://www.home-assistant.io/components/cisco_webex_teams", - "requirements": [ - "webexteamssdk==1.1.1" - ], - "dependencies": [], + "name": "Cisco Webex Teams", + "documentation": "https://www.home-assistant.io/integrations/cisco_webex_teams", + "requirements": ["webexteamssdk==1.1.1"], "codeowners": ["@fbradyirl"] } diff --git a/homeassistant/components/cisco_webex_teams/notify.py b/homeassistant/components/cisco_webex_teams/notify.py index 22f8679f6184b..271d58fcc8e87 100644 --- a/homeassistant/components/cisco_webex_teams/notify.py +++ b/homeassistant/components/cisco_webex_teams/notify.py @@ -2,25 +2,28 @@ import logging import voluptuous as vol +from webexteamssdk import ApiError, WebexTeamsAPI, exceptions from homeassistant.components.notify import ( - PLATFORM_SCHEMA, BaseNotificationService, ATTR_TITLE) -from homeassistant.const import (CONF_TOKEN) + ATTR_TITLE, + PLATFORM_SCHEMA, + BaseNotificationService, +) +from homeassistant.const import CONF_TOKEN import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -CONF_ROOM_ID = 'room_id' +CONF_ROOM_ID = "room_id" -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_TOKEN): cv.string, - vol.Required(CONF_ROOM_ID): cv.string, -}) +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 @@ -29,9 +32,7 @@ def get_service(hass, config, discovery_info=None): _LOGGER.error(error) return None - return CiscoWebexTeamsNotificationService( - client, - config[CONF_ROOM_ID]) + return CiscoWebexTeamsNotificationService(client, config[CONF_ROOM_ID]) class CiscoWebexTeamsNotificationService(BaseNotificationService): @@ -44,15 +45,14 @@ def __init__(self, client, room): 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), "
") + title = f"{kwargs.get(ATTR_TITLE)}
" try: - self.client.messages.create(roomId=self.room, - html="{}{}".format(title, message)) + self.client.messages.create(roomId=self.room, html=f"{title}{message}") except ApiError as api_error: - _LOGGER.error("Could not send CiscoWebexTeams notification. " - "Error: %s", - 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 deleted file mode 100644 index f872a0257f7dd..0000000000000 --- a/homeassistant/components/ciscospark/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""The ciscospark component.""" diff --git a/homeassistant/components/ciscospark/manifest.json b/homeassistant/components/ciscospark/manifest.json deleted file mode 100644 index 926925a7bf19e..0000000000000 --- a/homeassistant/components/ciscospark/manifest.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "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 deleted file mode 100644 index 320c342b1433b..0000000000000 --- a/homeassistant/components/ciscospark/notify.py +++ /dev/null @@ -1,50 +0,0 @@ -"""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/manifest.json b/homeassistant/components/citybikes/manifest.json index ea1ceaa9531a5..1470832e899e0 100644 --- a/homeassistant/components/citybikes/manifest.json +++ b/homeassistant/components/citybikes/manifest.json @@ -1,8 +1,6 @@ { "domain": "citybikes", - "name": "Citybikes", - "documentation": "https://www.home-assistant.io/components/citybikes", - "requirements": [], - "dependencies": [], + "name": "CityBikes", + "documentation": "https://www.home-assistant.io/integrations/citybikes", "codeowners": [] } diff --git a/homeassistant/components/citybikes/sensor.py b/homeassistant/components/citybikes/sensor.py index 344311aa231dd..799fe6acc70a8 100644 --- a/homeassistant/components/citybikes/sensor.py +++ b/homeassistant/components/citybikes/sensor.py @@ -9,9 +9,19 @@ 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) + 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 @@ -21,92 +31,107 @@ _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' +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' +CONF_NETWORK = "network" +CONF_STATIONS_LIST = "stations" -DEFAULT_ENDPOINT = 'https://api.citybik.es/{uri}' -PLATFORM = 'citybikes' +DEFAULT_ENDPOINT = "https://api.citybik.es/{uri}" +PLATFORM = "citybikes" -MONITORED_NETWORKS = 'monitored-networks' +MONITORED_NETWORKS = "monitored-networks" -NETWORKS_URI = 'v2/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' +STATIONS_URI = "v2/networks/{uid}?fields=network.stations" -CITYBIKES_ATTRIBUTION = "Information provided by the CityBikes Project "\ - "(https://citybik.es/#about)" +CITYBIKES_ATTRIBUTION = ( + "Information provided by the CityBikes Project (https://citybik.es/#about)" +) -CITYBIKES_NETWORKS = 'citybikes_networks' +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({ + 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, - }, 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) -}) + 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, loop=hass.loop): + with async_timeout.timeout(REQUEST_TIMEOUT): req = await session.get(DEFAULT_ENDPOINT.format(uri=uri)) json_response = await req.json() @@ -116,13 +141,11 @@ async def async_citybikes_request(hass, uri, schema): 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) + _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): +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: {}} @@ -137,8 +160,7 @@ async def async_setup_platform(hass, config, async_add_entities, radius = distance.convert(radius, LENGTH_FEET, LENGTH_METERS) # Create a single instance of CityBikesNetworks. - networks = hass.data.setdefault( - CITYBIKES_NETWORKS, CityBikesNetworks(hass)) + networks = hass.data.setdefault(CITYBIKES_NETWORKS, CityBikesNetworks(hass)) if not network_id: network_id = await networks.get_closest_network_id(latitude, longitude) @@ -156,19 +178,17 @@ async def async_setup_platform(hass, config, async_add_entities, devices = [] for station in network.stations: dist = location.distance( - latitude, longitude, station[ATTR_LATITUDE], - station[ATTR_LONGITUDE]) + latitude, longitude, station[ATTR_LATITUDE], station[ATTR_LONGITUDE] + ) station_id = station[ATTR_ID] - station_uid = str(station.get(ATTR_EXTRA, {}).get(ATTR_UID, '')) + station_uid = str(station.get(ATTR_EXTRA, {}).get(ATTR_UID, "")) - if radius > dist or stations_list.intersection( - (station_id, station_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) + 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) @@ -181,7 +201,7 @@ def __init__(self, hass): """Initialize the networks instance.""" self.hass = hass self.networks = None - self.networks_loading = asyncio.Condition(loop=hass.loop) + self.networks_loading = asyncio.Condition() async def get_closest_network_id(self, latitude, longitude): """Return the id of the network closest to provided location.""" @@ -189,7 +209,8 @@ async def get_closest_network_id(self, latitude, longitude): await self.networks_loading.acquire() if self.networks is None: networks = await async_citybikes_request( - self.hass, NETWORKS_URI, NETWORKS_RESPONSE_SCHEMA) + self.hass, NETWORKS_URI, NETWORKS_RESPONSE_SCHEMA + ) self.networks = networks[ATTR_NETWORKS_LIST] result = None minimum_dist = None @@ -197,7 +218,8 @@ async def get_closest_network_id(self, latitude, longitude): network_latitude = network[ATTR_LOCATION][ATTR_LATITUDE] network_longitude = network[ATTR_LOCATION][ATTR_LONGITUDE] dist = location.distance( - latitude, longitude, network_latitude, network_longitude) + latitude, longitude, network_latitude, network_longitude + ) if minimum_dist is None or dist < minimum_dist: minimum_dist = dist result = network[ATTR_ID] @@ -217,14 +239,16 @@ def __init__(self, hass, network_id): self.hass = hass self.network_id = network_id self.stations = [] - self.ready = asyncio.Event(loop=hass.loop) + 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.hass, + STATIONS_URI.format(uid=self.network_id), + STATIONS_RESPONSE_SCHEMA, + ) self.stations = network[ATTR_NETWORK][ATTR_STATIONS_LIST] self.ready.set() except CityBikesRequestError: @@ -278,9 +302,9 @@ def device_state_attributes(self): @property def unit_of_measurement(self): """Return the unit of measurement.""" - return 'bikes' + return "bikes" @property def icon(self): """Return the icon.""" - return 'mdi:bike' + return "mdi:bike" diff --git a/homeassistant/components/clementine/manifest.json b/homeassistant/components/clementine/manifest.json index 4d835ed4e7c2d..53ae0cbe5332b 100644 --- a/homeassistant/components/clementine/manifest.json +++ b/homeassistant/components/clementine/manifest.json @@ -1,10 +1,7 @@ { "domain": "clementine", - "name": "Clementine", - "documentation": "https://www.home-assistant.io/components/clementine", - "requirements": [ - "python-clementine-remote==1.0.1" - ], - "dependencies": [], + "name": "Clementine Music Player", + "documentation": "https://www.home-assistant.io/integrations/clementine", + "requirements": ["python-clementine-remote==1.0.1"], "codeowners": [] } diff --git a/homeassistant/components/clementine/media_player.py b/homeassistant/components/clementine/media_player.py index fc6e27be1bd61..7478c9a7f2b5c 100644 --- a/homeassistant/components/clementine/media_player.py +++ b/homeassistant/components/clementine/media_player.py @@ -3,44 +3,63 @@ import logging import time +from clementineremote import ClementineRemote import voluptuous as vol -from homeassistant.components.media_player import ( - MediaPlayerDevice, PLATFORM_SCHEMA) +from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerEntity 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) + 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) + 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_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, -}) +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) + + host = config[CONF_HOST] + port = config[CONF_PORT] token = config.get(CONF_ACCESS_TOKEN) client = ClementineRemote(host, port, token, reconnect=True) @@ -48,7 +67,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): add_entities([ClementineDevice(client, config[CONF_NAME])]) -class ClementineDevice(MediaPlayerDevice): +class ClementineDevice(MediaPlayerEntity): """Representation of Clementine Player.""" def __init__(self, client, name): @@ -59,9 +78,9 @@ def __init__(self, client, name): self._volume = 0.0 self._track_id = 0 self._last_track_id = 0 - self._track_name = '' - self._track_artist = '' - self._track_album_name = '' + self._track_name = "" + self._track_artist = "" + self._track_album_name = "" self._state = None def update(self): @@ -69,11 +88,11 @@ def update(self): try: client = self._client - if client.state == 'Playing': + if client.state == "Playing": self._state = STATE_PLAYING - elif client.state == 'Paused': + elif client.state == "Paused": self._state = STATE_PAUSED - elif client.state == 'Disconnected': + elif client.state == "Disconnected": self._state = STATE_OFF else: self._state = STATE_PAUSED @@ -84,10 +103,10 @@ def update(self): 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'] + 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 @@ -114,7 +133,7 @@ def source(self): source_name = "Unknown" client = self._client if client.active_playlist_id in client.playlists: - source_name = client.playlists[client.active_playlist_id]['name'] + source_name = client.playlists[client.active_playlist_id]["name"] return source_name @property @@ -126,9 +145,9 @@ def source_list(self): def select_source(self, source): """Select input source.""" client = self._client - sources = [s for s in client.playlists.values() if s['name'] == source] + sources = [s for s in client.playlists.values() if s["name"] == source] if len(sources) == 1: - client.change_song(sources[0]['id'], 0) + client.change_song(sources[0]["id"], 0) @property def media_content_type(self): @@ -159,15 +178,15 @@ def supported_features(self): def media_image_hash(self): """Hash value for media image.""" if self._client.current_track: - return self._client.current_track['track_id'] + 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') + image = bytes(self._client.current_track["art"]) + return (image, "image/png") return None, None diff --git a/homeassistant/components/clickatell/manifest.json b/homeassistant/components/clickatell/manifest.json index ffd550eebee87..520fce157cda4 100644 --- a/homeassistant/components/clickatell/manifest.json +++ b/homeassistant/components/clickatell/manifest.json @@ -1,8 +1,6 @@ { "domain": "clickatell", "name": "Clickatell", - "documentation": "https://www.home-assistant.io/components/clickatell", - "requirements": [], - "dependencies": [], + "documentation": "https://www.home-assistant.io/integrations/clickatell", "codeowners": [] } diff --git a/homeassistant/components/clickatell/notify.py b/homeassistant/components/clickatell/notify.py index b512a288ed569..0c1ce2e9585bb 100644 --- a/homeassistant/components/clickatell/notify.py +++ b/homeassistant/components/clickatell/notify.py @@ -4,22 +4,19 @@ import requests import voluptuous as vol -from homeassistant.const import CONF_API_KEY, CONF_RECIPIENT +from homeassistant.components.notify import PLATFORM_SCHEMA, BaseNotificationService +from homeassistant.const import CONF_API_KEY, CONF_RECIPIENT, HTTP_OK import homeassistant.helpers.config_validation as cv -from homeassistant.components.notify import (PLATFORM_SCHEMA, - BaseNotificationService) - _LOGGER = logging.getLogger(__name__) -DEFAULT_NAME = 'clickatell' +DEFAULT_NAME = "clickatell" -BASE_API_URL = 'https://platform.clickatell.com/messages/http/send' +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, -}) +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): @@ -32,17 +29,13 @@ class ClickatellNotificationService(BaseNotificationService): def __init__(self, config): """Initialize the service.""" - self.api_key = config.get(CONF_API_KEY) - self.recipient = config.get(CONF_RECIPIENT) + self.api_key = config[CONF_API_KEY] + self.recipient = config[CONF_RECIPIENT] def send_message(self, message="", **kwargs): """Send a message to a user.""" - data = { - 'apiKey': self.api_key, - 'to': self.recipient, - 'content': message, - } + 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): + if (resp.status_code != HTTP_OK) or (resp.status_code != 201): _LOGGER.error("Error %s : %s", resp.status_code, resp.text) diff --git a/homeassistant/components/clicksend/manifest.json b/homeassistant/components/clicksend/manifest.json index 3831982509431..ee72e056b30c3 100644 --- a/homeassistant/components/clicksend/manifest.json +++ b/homeassistant/components/clicksend/manifest.json @@ -1,8 +1,6 @@ { "domain": "clicksend", - "name": "Clicksend", - "documentation": "https://www.home-assistant.io/components/clicksend", - "requirements": [], - "dependencies": [], + "name": "ClickSend SMS", + "documentation": "https://www.home-assistant.io/integrations/clicksend", "codeowners": [] } diff --git a/homeassistant/components/clicksend/notify.py b/homeassistant/components/clicksend/notify.py index 111ae63601faa..18562260431c5 100644 --- a/homeassistant/components/clicksend/notify.py +++ b/homeassistant/components/clicksend/notify.py @@ -6,31 +6,40 @@ import requests import voluptuous as vol +from homeassistant.components.notify import PLATFORM_SCHEMA, BaseNotificationService from homeassistant.const import ( - CONF_API_KEY, CONF_RECIPIENT, CONF_SENDER, CONF_USERNAME, - CONTENT_TYPE_JSON) + CONF_API_KEY, + CONF_RECIPIENT, + CONF_SENDER, + CONF_USERNAME, + CONTENT_TYPE_JSON, + HTTP_OK, +) 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' +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, - }),)) + 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): @@ -55,37 +64,43 @@ 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: + data["messages"].append( + { + "source": "hass.notify", + "from": self.sender, + "to": recipient, + "body": message, + } + ) + + api_url = f"{BASE_API_URL}/sms/send" + resp = requests.post( + api_url, + data=json.dumps(data), + headers=HEADERS, + auth=(self.username, self.api_key), + timeout=TIMEOUT, + ) + if resp.status_code == HTTP_OK: 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) + 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: + api_url = f"{BASE_API_URL}/account" + resp = requests.get( + api_url, + headers=HEADERS, + auth=(config[CONF_USERNAME], config[CONF_API_KEY]), + timeout=TIMEOUT, + ) + if resp.status_code != HTTP_OK: return False return True diff --git a/homeassistant/components/clicksend_tts/manifest.json b/homeassistant/components/clicksend_tts/manifest.json index c2a86f426e43e..f5d3390d00576 100644 --- a/homeassistant/components/clicksend_tts/manifest.json +++ b/homeassistant/components/clicksend_tts/manifest.json @@ -1,8 +1,6 @@ { "domain": "clicksend_tts", - "name": "Clicksend tts", - "documentation": "https://www.home-assistant.io/components/clicksend_tts", - "requirements": [], - "dependencies": [], + "name": "ClickSend TTS", + "documentation": "https://www.home-assistant.io/integrations/clicksend_tts", "codeowners": [] } diff --git a/homeassistant/components/clicksend_tts/notify.py b/homeassistant/components/clicksend_tts/notify.py index feb4481fb5660..6648333bb5411 100644 --- a/homeassistant/components/clicksend_tts/notify.py +++ b/homeassistant/components/clicksend_tts/notify.py @@ -6,35 +6,40 @@ import requests import voluptuous as vol +from homeassistant.components.notify import PLATFORM_SCHEMA, BaseNotificationService from homeassistant.const import ( - CONF_API_KEY, CONF_RECIPIENT, CONF_USERNAME, CONTENT_TYPE_JSON) + CONF_API_KEY, + CONF_RECIPIENT, + CONF_USERNAME, + CONTENT_TYPE_JSON, + HTTP_OK, +) 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' +BASE_API_URL = "https://rest.clicksend.com/v3" HEADERS = {CONTENT_TYPE: CONTENT_TYPE_JSON} -CONF_LANGUAGE = 'language' -CONF_VOICE = 'voice' -CONF_CALLER = 'caller' +CONF_LANGUAGE = "language" +CONF_VOICE = "voice" +CONF_CALLER = "caller" -DEFAULT_LANGUAGE = 'en-us' -DEFAULT_VOICE = 'female' +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, -}) +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): @@ -51,44 +56,59 @@ class ClicksendNotificationService(BaseNotificationService): 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.username = config[CONF_USERNAME] + self.api_key = config[CONF_API_KEY] + self.recipient = config[CONF_RECIPIENT] + self.language = config[CONF_LANGUAGE] + self.voice = config[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: + data = { + "messages": [ + { + "source": "hass.notify", + "from": self.caller, + "to": self.recipient, + "body": message, + "lang": self.language, + "voice": self.voice, + } + ] + } + api_url = f"{BASE_API_URL}/voice/send" + resp = requests.post( + api_url, + data=json.dumps(data), + headers=HEADERS, + auth=(self.username, self.api_key), + timeout=TIMEOUT, + ) + + if resp.status_code == HTTP_OK: 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) + 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: + api_url = f"{BASE_API_URL}/account" + resp = requests.get( + api_url, + headers=HEADERS, + auth=(config.get(CONF_USERNAME), config.get(CONF_API_KEY)), + timeout=TIMEOUT, + ) + + if resp.status_code != HTTP_OK: return False return True diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index 18b56049f83ba..d3241791cf243 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -1,238 +1,242 @@ """Provides functionality to interact with climate devices.""" +from abc import abstractmethod from datetime import timedelta -import logging import functools as ft +import logging +from typing import Any, Dict, List, Optional import voluptuous as vol +from homeassistant.const import ( + ATTR_TEMPERATURE, + PRECISION_TENTHS, + PRECISION_WHOLE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, + TEMP_CELSIUS, +) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.config_validation import ( # noqa: F401 + PLATFORM_SCHEMA, + PLATFORM_SCHEMA_BASE, + make_entity_service_schema, +) +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.temperature import display_temp as show_temp +from homeassistant.helpers.typing import ConfigType, HomeAssistantType, ServiceDataType from homeassistant.util.temperature import convert as convert_temperature -from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.entity import Entity -from homeassistant.helpers.config_validation import ( # noqa - PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE) -import homeassistant.helpers.config_validation as cv -from homeassistant.const import ( - ATTR_ENTITY_ID, ATTR_TEMPERATURE, SERVICE_TURN_ON, SERVICE_TURN_OFF, - STATE_ON, STATE_OFF, TEMP_CELSIUS, PRECISION_WHOLE, - PRECISION_TENTHS) 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_FAN_MODES, ATTR_HUMIDITY, + ATTR_HVAC_ACTION, + ATTR_HVAC_MODE, + ATTR_HVAC_MODES, ATTR_MAX_HUMIDITY, ATTR_MAX_TEMP, ATTR_MIN_HUMIDITY, ATTR_MIN_TEMP, - ATTR_OPERATION_LIST, - ATTR_OPERATION_MODE, - ATTR_SWING_LIST, + ATTR_PRESET_MODE, + ATTR_PRESET_MODES, ATTR_SWING_MODE, + ATTR_SWING_MODES, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, ATTR_TARGET_TEMP_STEP, DOMAIN, + HVAC_MODE_COOL, + HVAC_MODE_HEAT, + HVAC_MODE_HEAT_COOL, + HVAC_MODE_OFF, + HVAC_MODES, 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_HVAC_MODE, + SERVICE_SET_PRESET_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_AUX_HEAT, SUPPORT_FAN_MODE, - SUPPORT_OPERATION_MODE, - SUPPORT_HOLD_MODE, + SUPPORT_PRESET_MODE, SUPPORT_SWING_MODE, - SUPPORT_AWAY_MODE, - SUPPORT_AUX_HEAT, + SUPPORT_TARGET_HUMIDITY, + SUPPORT_TARGET_TEMPERATURE, + SUPPORT_TARGET_TEMPERATURE_RANGE, ) -from .reproduce_state import async_reproduce_states # noqa DEFAULT_MIN_TEMP = 7 DEFAULT_MAX_TEMP = 35 -DEFAULT_MIN_HUMITIDY = 30 +DEFAULT_MIN_HUMIDITY = 30 DEFAULT_MAX_HUMIDITY = 99 -ENTITY_ID_FORMAT = DOMAIN + '.{}' +ENTITY_ID_FORMAT = DOMAIN + ".{}" SCAN_INTERVAL = timedelta(seconds=60) -CONVERTIBLE_ATTRIBUTE = [ - ATTR_TEMPERATURE, - ATTR_TARGET_TEMP_LOW, - ATTR_TARGET_TEMP_HIGH, -] +CONVERTIBLE_ATTRIBUTE = [ATTR_TEMPERATURE, ATTR_TARGET_TEMP_LOW, ATTR_TARGET_TEMP_HIGH] _LOGGER = logging.getLogger(__name__) -ON_OFF_SERVICE_SCHEMA = vol.Schema({ - vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids, -}) - -SET_AWAY_MODE_SCHEMA = vol.Schema({ - vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids, - vol.Required(ATTR_AWAY_MODE): cv.boolean, -}) -SET_AUX_HEAT_SCHEMA = vol.Schema({ - vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids, - vol.Required(ATTR_AUX_HEAT): cv.boolean, -}) -SET_TEMPERATURE_SCHEMA = vol.Schema(vol.All( + +SET_TEMPERATURE_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.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.comp_entity_ids, - vol.Required(ATTR_OPERATION_MODE): cv.string, -}) -SET_HUMIDITY_SCHEMA = vol.Schema({ - 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.comp_entity_ids, - vol.Required(ATTR_SWING_MODE): cv.string, -}) - - -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) + ATTR_TEMPERATURE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW + ), + make_entity_service_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_HVAC_MODE): vol.In(HVAC_MODES), + } + ), +) - 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 + +async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: + """Set up climate entities.""" + component = hass.data[DOMAIN] = EntityComponent( + _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) + await component.async_setup(config) + + component.async_register_entity_service(SERVICE_TURN_ON, {}, "async_turn_on") + component.async_register_entity_service(SERVICE_TURN_OFF, {}, "async_turn_off") component.async_register_entity_service( - SERVICE_SET_TEMPERATURE, SET_TEMPERATURE_SCHEMA, - async_service_temperature_set + SERVICE_SET_HVAC_MODE, + {vol.Required(ATTR_HVAC_MODE): vol.In(HVAC_MODES)}, + "async_set_hvac_mode", ) component.async_register_entity_service( - SERVICE_SET_HUMIDITY, SET_HUMIDITY_SCHEMA, - 'async_set_humidity' + SERVICE_SET_PRESET_MODE, + {vol.Required(ATTR_PRESET_MODE): cv.string}, + "async_set_preset_mode", ) component.async_register_entity_service( - SERVICE_SET_FAN_MODE, SET_FAN_MODE_SCHEMA, - 'async_set_fan_mode' + SERVICE_SET_AUX_HEAT, + {vol.Required(ATTR_AUX_HEAT): cv.boolean}, + async_service_aux_heat, ) component.async_register_entity_service( - SERVICE_SET_OPERATION_MODE, SET_OPERATION_MODE_SCHEMA, - 'async_set_operation_mode' + SERVICE_SET_TEMPERATURE, SET_TEMPERATURE_SCHEMA, async_service_temperature_set, ) component.async_register_entity_service( - SERVICE_SET_SWING_MODE, SET_SWING_MODE_SCHEMA, - 'async_set_swing_mode' + SERVICE_SET_HUMIDITY, + {vol.Required(ATTR_HUMIDITY): vol.Coerce(float)}, + "async_set_humidity", ) component.async_register_entity_service( - SERVICE_TURN_OFF, ON_OFF_SERVICE_SCHEMA, - 'async_turn_off' + SERVICE_SET_FAN_MODE, + {vol.Required(ATTR_FAN_MODE): cv.string}, + "async_set_fan_mode", ) component.async_register_entity_service( - SERVICE_TURN_ON, ON_OFF_SERVICE_SCHEMA, - 'async_turn_on' + SERVICE_SET_SWING_MODE, + {vol.Required(ATTR_SWING_MODE): cv.string}, + "async_set_swing_mode", ) return True -async def async_setup_entry(hass, entry): +async def async_setup_entry(hass: HomeAssistantType, entry): """Set up a config entry.""" return await hass.data[DOMAIN].async_setup_entry(entry) -async def async_unload_entry(hass, entry): +async def async_unload_entry(hass: HomeAssistantType, entry): """Unload a config entry.""" return await hass.data[DOMAIN].async_unload_entry(entry) -class ClimateDevice(Entity): - """Representation of a climate device.""" +class ClimateEntity(Entity): + """Representation of a climate entity.""" @property - def state(self): + def state(self) -> str: """Return the current state.""" - if self.is_on is False: - return STATE_OFF - if self.current_operation: - return self.current_operation - if self.is_on: - return STATE_ON - return None + return self.hvac_mode @property - def precision(self): + def precision(self) -> float: """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.""" + def capability_attributes(self) -> Optional[Dict[str, Any]]: + """Return the capability attributes.""" + supported_features = self.supported_features data = { - ATTR_CURRENT_TEMPERATURE: show_temp( - self.hass, self.current_temperature, self.temperature_unit, - self.precision), + ATTR_HVAC_MODES: self.hvac_modes, ATTR_MIN_TEMP: show_temp( - self.hass, self.min_temp, self.temperature_unit, - self.precision), + 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), + self.hass, self.max_temp, self.temperature_unit, self.precision + ), } - supported_features = self.supported_features - if self.target_temperature_step is not None: + if self.target_temperature_step: 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_HUMIDITY: + data[ATTR_MIN_HUMIDITY] = self.min_humidity + data[ATTR_MAX_HUMIDITY] = self.max_humidity - if supported_features & SUPPORT_TARGET_TEMPERATURE_LOW: + if supported_features & SUPPORT_FAN_MODE: + data[ATTR_FAN_MODES] = self.fan_modes + + if supported_features & SUPPORT_PRESET_MODE: + data[ATTR_PRESET_MODES] = self.preset_modes + + if supported_features & SUPPORT_SWING_MODE: + data[ATTR_SWING_MODES] = self.swing_modes + + return data + + @property + def state_attributes(self) -> Dict[str, Any]: + """Return the optional state attributes.""" + supported_features = self.supported_features + data = { + ATTR_CURRENT_TEMPERATURE: show_temp( + self.hass, + self.current_temperature, + self.temperature_unit, + self.precision, + ), + } + + if supported_features & SUPPORT_TARGET_TEMPERATURE: + data[ATTR_TEMPERATURE] = show_temp( + self.hass, + self.target_temperature, + self.temperature_unit, + self.precision, + ) + + if supported_features & SUPPORT_TARGET_TEMPERATURE_RANGE: + data[ATTR_TARGET_TEMP_HIGH] = show_temp( + self.hass, + self.target_temperature_high, + self.temperature_unit, + self.precision, + ) data[ATTR_TARGET_TEMP_LOW] = show_temp( - self.hass, self.target_temperature_low, self.temperature_unit, - self.precision) + 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 @@ -240,300 +244,273 @@ def state_attributes(self): 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 + data[ATTR_FAN_MODE] = self.fan_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 + if self.hvac_action: + data[ATTR_HVAC_ACTION] = self.hvac_action - if supported_features & SUPPORT_HOLD_MODE: - data[ATTR_HOLD_MODE] = self.current_hold_mode + if supported_features & SUPPORT_PRESET_MODE: + data[ATTR_PRESET_MODE] = self.preset_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 - - 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 + data[ATTR_SWING_MODE] = self.swing_mode 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 + data[ATTR_AUX_HEAT] = STATE_ON if self.is_aux_heat else STATE_OFF return data @property - def temperature_unit(self): + def temperature_unit(self) -> str: """Return the unit of measurement used by the platform.""" - raise NotImplementedError + raise NotImplementedError() @property - def current_humidity(self): + def current_humidity(self) -> Optional[int]: """Return the current humidity.""" return None @property - def target_humidity(self): + def target_humidity(self) -> Optional[int]: """Return the humidity we try to reach.""" return None @property - def current_operation(self): - """Return current operation ie. heat, cool, idle.""" - return None + @abstractmethod + def hvac_mode(self) -> str: + """Return hvac operation ie. heat, cool mode. + + Need to be one of HVAC_MODE_*. + """ @property - def operation_list(self): - """Return the list of available operation modes.""" + @abstractmethod + def hvac_modes(self) -> List[str]: + """Return the list of available hvac operation modes. + + Need to be a subset of HVAC_MODES. + """ + + @property + def hvac_action(self) -> Optional[str]: + """Return the current running hvac operation if supported. + + Need to be one of CURRENT_HVAC_*. + """ return None @property - def current_temperature(self): + def current_temperature(self) -> Optional[float]: """Return the current temperature.""" return None @property - def target_temperature(self): + def target_temperature(self) -> Optional[float]: """Return the temperature we try to reach.""" return None @property - def target_temperature_step(self): + def target_temperature_step(self) -> Optional[float]: """Return the supported step of target temperature.""" return None @property - def target_temperature_high(self): - """Return the highbound target temperature we try to reach.""" - return None + def target_temperature_high(self) -> Optional[float]: + """Return the highbound target temperature we try to reach. - @property - def target_temperature_low(self): - """Return the lowbound target temperature we try to reach.""" - return None - - @property - def is_away_mode_on(self): - """Return true if away mode is on.""" - return None + Requires SUPPORT_TARGET_TEMPERATURE_RANGE. + """ + raise NotImplementedError @property - def current_hold_mode(self): - """Return the current hold mode, e.g., home, away, temp.""" - return None + def target_temperature_low(self) -> Optional[float]: + """Return the lowbound target temperature we try to reach. - @property - def is_on(self): - """Return true if on.""" - return None + Requires SUPPORT_TARGET_TEMPERATURE_RANGE. + """ + raise NotImplementedError @property - def is_aux_heat_on(self): - """Return true if aux heater.""" - return None + def preset_mode(self) -> Optional[str]: + """Return the current preset mode, e.g., home, away, temp. - @property - def current_fan_mode(self): - """Return the fan setting.""" - return None + Requires SUPPORT_PRESET_MODE. + """ + raise NotImplementedError @property - def fan_list(self): - """Return the list of available fan modes.""" - return None + def preset_modes(self) -> Optional[List[str]]: + """Return a list of available preset modes. - @property - def current_swing_mode(self): - """Return the fan setting.""" - return None + Requires SUPPORT_PRESET_MODE. + """ + raise NotImplementedError @property - def swing_list(self): - """Return the list of available swing modes.""" - return None + def is_aux_heat(self) -> Optional[bool]: + """Return true if aux heater. - def set_temperature(self, **kwargs): - """Set new target temperature.""" - raise NotImplementedError() + Requires SUPPORT_AUX_HEAT. + """ + raise NotImplementedError - def async_set_temperature(self, **kwargs): - """Set new target temperature. + @property + def fan_mode(self) -> Optional[str]: + """Return the fan setting. - This method must be run in the event loop and returns a coroutine. + Requires SUPPORT_FAN_MODE. """ - return self.hass.async_add_job( - ft.partial(self.set_temperature, **kwargs)) - - def set_humidity(self, humidity): - """Set new target humidity.""" - raise NotImplementedError() + raise NotImplementedError - def async_set_humidity(self, humidity): - """Set new target humidity. + @property + def fan_modes(self) -> Optional[List[str]]: + """Return the list of available fan modes. - This method must be run in the event loop and returns a coroutine. + Requires SUPPORT_FAN_MODE. """ - return self.hass.async_add_job(self.set_humidity, humidity) - - def set_fan_mode(self, fan_mode): - """Set new target fan mode.""" - raise NotImplementedError() + raise NotImplementedError - def async_set_fan_mode(self, fan_mode): - """Set new target fan mode. + @property + def swing_mode(self) -> Optional[str]: + """Return the swing setting. - This method must be run in the event loop and returns a coroutine. + Requires SUPPORT_SWING_MODE. """ - 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() + raise NotImplementedError - def async_set_operation_mode(self, operation_mode): - """Set new target operation mode. + @property + def swing_modes(self) -> Optional[List[str]]: + """Return the list of available swing modes. - This method must be run in the event loop and returns a coroutine. + Requires SUPPORT_SWING_MODE. """ - return self.hass.async_add_job(self.set_operation_mode, operation_mode) + raise NotImplementedError - def set_swing_mode(self, swing_mode): - """Set new target swing operation.""" + def set_temperature(self, **kwargs) -> None: + """Set new target temperature.""" 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) + async def async_set_temperature(self, **kwargs) -> None: + """Set new target temperature.""" + await self.hass.async_add_executor_job( + ft.partial(self.set_temperature, **kwargs) + ) - def turn_away_mode_on(self): - """Turn away mode on.""" + def set_humidity(self, humidity: int) -> None: + """Set new target humidity.""" 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) + async def async_set_humidity(self, humidity: int) -> None: + """Set new target humidity.""" + await self.hass.async_add_executor_job(self.set_humidity, humidity) - def turn_away_mode_off(self): - """Turn away mode off.""" + def set_fan_mode(self, fan_mode: str) -> None: + """Set new target fan mode.""" 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) + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set new target fan mode.""" + await self.hass.async_add_executor_job(self.set_fan_mode, fan_mode) - def set_hold_mode(self, hold_mode): - """Set new target hold mode.""" + def set_hvac_mode(self, hvac_mode: str) -> None: + """Set new target hvac mode.""" raise NotImplementedError() - def async_set_hold_mode(self, hold_mode): - """Set new target hold mode. + async def async_set_hvac_mode(self, hvac_mode: str) -> None: + """Set new target hvac mode.""" + await self.hass.async_add_executor_job(self.set_hvac_mode, hvac_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 auxiliary heater on.""" + def set_swing_mode(self, swing_mode: str) -> None: + """Set new target swing operation.""" 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) + async def async_set_swing_mode(self, swing_mode: str) -> None: + """Set new target swing operation.""" + await self.hass.async_add_executor_job(self.set_swing_mode, swing_mode) - def turn_aux_heat_off(self): - """Turn auxiliary heater off.""" + def set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" 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) + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + await self.hass.async_add_executor_job(self.set_preset_mode, preset_mode) - def turn_on(self): - """Turn device on.""" + def turn_aux_heat_on(self) -> None: + """Turn auxiliary heater 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) + async def async_turn_aux_heat_on(self) -> None: + """Turn auxiliary heater on.""" + await self.hass.async_add_executor_job(self.turn_aux_heat_on) - def turn_off(self): - """Turn device off.""" + def turn_aux_heat_off(self) -> None: + """Turn auxiliary heater 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) + async def async_turn_aux_heat_off(self) -> None: + """Turn auxiliary heater off.""" + await self.hass.async_add_executor_job(self.turn_aux_heat_off) + + async def async_turn_on(self) -> None: + """Turn the entity on.""" + if hasattr(self, "turn_on"): + # pylint: disable=no-member + await self.hass.async_add_executor_job(self.turn_on) + return + + # Fake turn on + for mode in (HVAC_MODE_HEAT_COOL, HVAC_MODE_HEAT, HVAC_MODE_COOL): + if mode not in self.hvac_modes: + continue + await self.async_set_hvac_mode(mode) + break + + async def async_turn_off(self) -> None: + """Turn the entity off.""" + if hasattr(self, "turn_off"): + # pylint: disable=no-member + await self.hass.async_add_executor_job(self.turn_off) + return + + # Fake turn off + if HVAC_MODE_OFF in self.hvac_modes: + await self.async_set_hvac_mode(HVAC_MODE_OFF) @property - def supported_features(self): + def supported_features(self) -> int: """Return the list of supported features.""" raise NotImplementedError() @property - def min_temp(self): + def min_temp(self) -> float: """Return the minimum temperature.""" - return convert_temperature(DEFAULT_MIN_TEMP, TEMP_CELSIUS, - self.temperature_unit) + return convert_temperature( + DEFAULT_MIN_TEMP, TEMP_CELSIUS, self.temperature_unit + ) @property - def max_temp(self): + def max_temp(self) -> float: """Return the maximum temperature.""" - return convert_temperature(DEFAULT_MAX_TEMP, TEMP_CELSIUS, - self.temperature_unit) + return convert_temperature( + DEFAULT_MAX_TEMP, TEMP_CELSIUS, self.temperature_unit + ) @property - def min_humidity(self): + def min_humidity(self) -> int: """Return the minimum humidity.""" - return DEFAULT_MIN_HUMITIDY + return DEFAULT_MIN_HUMIDITY @property - def max_humidity(self): + def max_humidity(self) -> int: """Return the maximum humidity.""" 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): +async def async_service_aux_heat( + entity: ClimateEntity, service: ServiceDataType +) -> None: """Handle aux heat service.""" if service.data[ATTR_AUX_HEAT]: await entity.async_turn_aux_heat_on() @@ -541,7 +518,9 @@ async def async_service_aux_heat(entity, service): await entity.async_turn_aux_heat_off() -async def async_service_temperature_set(entity, service): +async def async_service_temperature_set( + entity: ClimateEntity, service: ServiceDataType +) -> None: """Handle set temperature service.""" hass = entity.hass kwargs = {} @@ -549,11 +528,21 @@ async def async_service_temperature_set(entity, service): 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 + temp, hass.config.units.temperature_unit, entity.temperature_unit ) else: kwargs[value] = temp await entity.async_set_temperature(**kwargs) + + +class ClimateDevice(ClimateEntity): + """Representation of a climate entity (for backwards compatibility).""" + + def __init_subclass__(cls, **kwargs): + """Print deprecation warning.""" + super().__init_subclass__(**kwargs) + _LOGGER.warning( + "ClimateDevice is deprecated, modify %s to extend ClimateEntity", + cls.__name__, + ) diff --git a/homeassistant/components/climate/const.py b/homeassistant/components/climate/const.py index 364c452bf4d21..b489071db573c 100644 --- a/homeassistant/components/climate/const.py +++ b/homeassistant/components/climate/const.py @@ -1,60 +1,129 @@ """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' +# All activity disabled / Device is off/standby +HVAC_MODE_OFF = "off" + +# Heating +HVAC_MODE_HEAT = "heat" + +# Cooling +HVAC_MODE_COOL = "cool" + +# The device supports heating/cooling to a range +HVAC_MODE_HEAT_COOL = "heat_cool" + +# The temperature is set based on a schedule, learned behavior, AI or some +# other related mechanism. User is not able to adjust the temperature +HVAC_MODE_AUTO = "auto" + +# Device is in Dry/Humidity mode +HVAC_MODE_DRY = "dry" + +# Only the fan is on, not fan and another mode like cool +HVAC_MODE_FAN_ONLY = "fan_only" + +HVAC_MODES = [ + HVAC_MODE_OFF, + HVAC_MODE_HEAT, + HVAC_MODE_COOL, + HVAC_MODE_HEAT_COOL, + HVAC_MODE_AUTO, + HVAC_MODE_DRY, + HVAC_MODE_FAN_ONLY, +] + +# No preset is active +PRESET_NONE = "none" + +# Device is running an energy-saving mode +PRESET_ECO = "eco" + +# Device is in away mode +PRESET_AWAY = "away" + +# Device turn all valve full up +PRESET_BOOST = "boost" + +# Device is in comfort mode +PRESET_COMFORT = "comfort" + +# Device is in home mode +PRESET_HOME = "home" + +# Device is prepared for sleep +PRESET_SLEEP = "sleep" + +# Device is reacting to activity (e.g. movement sensors) +PRESET_ACTIVITY = "activity" + +# Possible fan state +FAN_ON = "on" +FAN_OFF = "off" +FAN_AUTO = "auto" +FAN_LOW = "low" +FAN_MEDIUM = "medium" +FAN_HIGH = "high" +FAN_MIDDLE = "middle" +FAN_FOCUS = "focus" +FAN_DIFFUSE = "diffuse" + + +# Possible swing state +SWING_OFF = "off" +SWING_BOTH = "both" +SWING_VERTICAL = "vertical" +SWING_HORIZONTAL = "horizontal" + + +# This are support current states of HVAC +CURRENT_HVAC_OFF = "off" +CURRENT_HVAC_HEAT = "heating" +CURRENT_HVAC_COOL = "cooling" +CURRENT_HVAC_DRY = "drying" +CURRENT_HVAC_IDLE = "idle" +CURRENT_HVAC_FAN = "fan" + + +ATTR_AUX_HEAT = "aux_heat" +ATTR_CURRENT_HUMIDITY = "current_humidity" +ATTR_CURRENT_TEMPERATURE = "current_temperature" +ATTR_FAN_MODES = "fan_modes" +ATTR_FAN_MODE = "fan_mode" +ATTR_PRESET_MODE = "preset_mode" +ATTR_PRESET_MODES = "preset_modes" +ATTR_HUMIDITY = "humidity" +ATTR_MAX_HUMIDITY = "max_humidity" +ATTR_MIN_HUMIDITY = "min_humidity" +ATTR_MAX_TEMP = "max_temp" +ATTR_MIN_TEMP = "min_temp" +ATTR_HVAC_ACTION = "hvac_action" +ATTR_HVAC_MODES = "hvac_modes" +ATTR_HVAC_MODE = "hvac_mode" +ATTR_SWING_MODES = "swing_modes" +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_MIN_HUMIDITY = 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' +DOMAIN = "climate" + +SERVICE_SET_AUX_HEAT = "set_aux_heat" +SERVICE_SET_FAN_MODE = "set_fan_mode" +SERVICE_SET_PRESET_MODE = "set_preset_mode" +SERVICE_SET_HUMIDITY = "set_humidity" +SERVICE_SET_HVAC_MODE = "set_hvac_mode" +SERVICE_SET_SWING_MODE = "set_swing_mode" +SERVICE_SET_TEMPERATURE = "set_temperature" 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 +SUPPORT_TARGET_TEMPERATURE_RANGE = 2 +SUPPORT_TARGET_HUMIDITY = 4 +SUPPORT_FAN_MODE = 8 +SUPPORT_PRESET_MODE = 16 +SUPPORT_SWING_MODE = 32 +SUPPORT_AUX_HEAT = 64 diff --git a/homeassistant/components/climate/device_action.py b/homeassistant/components/climate/device_action.py new file mode 100644 index 0000000000000..6f7725ac83577 --- /dev/null +++ b/homeassistant/components/climate/device_action.py @@ -0,0 +1,114 @@ +"""Provides device automations for Climate.""" +from typing import List, Optional + +import voluptuous as vol + +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_DEVICE_ID, + CONF_DOMAIN, + CONF_ENTITY_ID, + CONF_TYPE, +) +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import entity_registry +import homeassistant.helpers.config_validation as cv + +from . import DOMAIN, const + +ACTION_TYPES = {"set_hvac_mode", "set_preset_mode"} + +SET_HVAC_MODE_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( + { + vol.Required(CONF_TYPE): "set_hvac_mode", + vol.Required(CONF_ENTITY_ID): cv.entity_domain(DOMAIN), + vol.Required(const.ATTR_HVAC_MODE): vol.In(const.HVAC_MODES), + } +) + +SET_PRESET_MODE_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( + { + vol.Required(CONF_TYPE): "set_preset_mode", + vol.Required(CONF_ENTITY_ID): cv.entity_domain(DOMAIN), + vol.Required(const.ATTR_PRESET_MODE): str, + } +) + +ACTION_SCHEMA = vol.Any(SET_HVAC_MODE_SCHEMA, SET_PRESET_MODE_SCHEMA) + + +async def async_get_actions(hass: HomeAssistant, device_id: str) -> List[dict]: + """List device actions for Climate devices.""" + registry = await entity_registry.async_get_registry(hass) + actions = [] + + # Get all the integrations entities for this device + for entry in entity_registry.async_entries_for_device(registry, device_id): + if entry.domain != DOMAIN: + continue + + state = hass.states.get(entry.entity_id) + + # We need a state or else we can't populate the HVAC and preset modes. + if state is None: + continue + + actions.append( + { + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "set_hvac_mode", + } + ) + if state.attributes["supported_features"] & const.SUPPORT_PRESET_MODE: + actions.append( + { + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "set_preset_mode", + } + ) + + return actions + + +async def async_call_action_from_config( + hass: HomeAssistant, config: dict, variables: dict, context: Optional[Context] +) -> None: + """Execute a device action.""" + config = ACTION_SCHEMA(config) + + service_data = {ATTR_ENTITY_ID: config[CONF_ENTITY_ID]} + + if config[CONF_TYPE] == "set_hvac_mode": + service = const.SERVICE_SET_HVAC_MODE + service_data[const.ATTR_HVAC_MODE] = config[const.ATTR_HVAC_MODE] + elif config[CONF_TYPE] == "set_preset_mode": + service = const.SERVICE_SET_PRESET_MODE + service_data[const.ATTR_PRESET_MODE] = config[const.ATTR_PRESET_MODE] + + await hass.services.async_call( + DOMAIN, service, service_data, blocking=True, context=context + ) + + +async def async_get_action_capabilities(hass, config): + """List action capabilities.""" + state = hass.states.get(config[CONF_ENTITY_ID]) + action_type = config[CONF_TYPE] + + fields = {} + + if action_type == "set_hvac_mode": + hvac_modes = state.attributes[const.ATTR_HVAC_MODES] if state else [] + fields[vol.Required(const.ATTR_HVAC_MODE)] = vol.In(hvac_modes) + elif action_type == "set_preset_mode": + if state: + preset_modes = state.attributes.get(const.ATTR_PRESET_MODES, []) + else: + preset_modes = [] + fields[vol.Required(const.ATTR_PRESET_MODE)] = vol.In(preset_modes) + + return {"extra_fields": vol.Schema(fields)} diff --git a/homeassistant/components/climate/device_condition.py b/homeassistant/components/climate/device_condition.py new file mode 100644 index 0000000000000..8a5b9ceede8f9 --- /dev/null +++ b/homeassistant/components/climate/device_condition.py @@ -0,0 +1,120 @@ +"""Provide the device automations for Climate.""" +from typing import Dict, List + +import voluptuous as vol + +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_CONDITION, + CONF_DEVICE_ID, + CONF_DOMAIN, + CONF_ENTITY_ID, + CONF_TYPE, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import condition, config_validation as cv, entity_registry +from homeassistant.helpers.config_validation import DEVICE_CONDITION_BASE_SCHEMA +from homeassistant.helpers.typing import ConfigType, TemplateVarsType + +from . import DOMAIN, const + +CONDITION_TYPES = {"is_hvac_mode", "is_preset_mode"} + +HVAC_MODE_CONDITION = DEVICE_CONDITION_BASE_SCHEMA.extend( + { + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_TYPE): "is_hvac_mode", + vol.Required(const.ATTR_HVAC_MODE): vol.In(const.HVAC_MODES), + } +) + +PRESET_MODE_CONDITION = DEVICE_CONDITION_BASE_SCHEMA.extend( + { + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_TYPE): "is_preset_mode", + vol.Required(const.ATTR_PRESET_MODE): str, + } +) + +CONDITION_SCHEMA = vol.Any(HVAC_MODE_CONDITION, PRESET_MODE_CONDITION) + + +async def async_get_conditions( + hass: HomeAssistant, device_id: str +) -> List[Dict[str, str]]: + """List device conditions for Climate devices.""" + registry = await entity_registry.async_get_registry(hass) + conditions = [] + + # Get all the integrations entities for this device + for entry in entity_registry.async_entries_for_device(registry, device_id): + if entry.domain != DOMAIN: + continue + + state = hass.states.get(entry.entity_id) + + conditions.append( + { + CONF_CONDITION: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "is_hvac_mode", + } + ) + + if state and state.attributes["supported_features"] & const.SUPPORT_PRESET_MODE: + conditions.append( + { + CONF_CONDITION: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "is_preset_mode", + } + ) + + return conditions + + +@callback +def async_condition_from_config( + config: ConfigType, config_validation: bool +) -> condition.ConditionCheckerType: + """Create a function to test a device condition.""" + if config_validation: + config = CONDITION_SCHEMA(config) + + if config[CONF_TYPE] == "is_hvac_mode": + attribute = const.ATTR_HVAC_MODE + else: + attribute = const.ATTR_PRESET_MODE + + def test_is_state(hass: HomeAssistant, variables: TemplateVarsType) -> bool: + """Test if an entity is a certain state.""" + state = hass.states.get(config[ATTR_ENTITY_ID]) + return state and state.attributes.get(attribute) == config[attribute] + + return test_is_state + + +async def async_get_condition_capabilities(hass, config): + """List condition capabilities.""" + state = hass.states.get(config[CONF_ENTITY_ID]) + condition_type = config[CONF_TYPE] + + fields = {} + + if condition_type == "is_hvac_mode": + hvac_modes = state.attributes[const.ATTR_HVAC_MODES] if state else [] + fields[vol.Required(const.ATTR_HVAC_MODE)] = vol.In(hvac_modes) + + elif condition_type == "is_preset_mode": + if state: + preset_modes = state.attributes.get(const.ATTR_PRESET_MODES, []) + else: + preset_modes = [] + + fields[vol.Required(const.ATTR_PRESET_MODES)] = vol.In(preset_modes) + + return {"extra_fields": vol.Schema(fields)} diff --git a/homeassistant/components/climate/device_trigger.py b/homeassistant/components/climate/device_trigger.py new file mode 100644 index 0000000000000..187f50bc98407 --- /dev/null +++ b/homeassistant/components/climate/device_trigger.py @@ -0,0 +1,195 @@ +"""Provides device automations for Climate.""" +from typing import List + +import voluptuous as vol + +from homeassistant.components.automation import ( + AutomationActionType, + numeric_state as numeric_state_automation, + state as state_automation, +) +from homeassistant.components.device_automation import TRIGGER_BASE_SCHEMA +from homeassistant.const import ( + CONF_ABOVE, + CONF_BELOW, + CONF_DEVICE_ID, + CONF_DOMAIN, + CONF_ENTITY_ID, + CONF_FOR, + CONF_PLATFORM, + CONF_TYPE, + UNIT_PERCENTAGE, +) +from homeassistant.core import CALLBACK_TYPE, HomeAssistant +from homeassistant.helpers import config_validation as cv, entity_registry +from homeassistant.helpers.typing import ConfigType + +from . import DOMAIN, const + +TRIGGER_TYPES = { + "current_temperature_changed", + "current_humidity_changed", + "hvac_mode_changed", +} + +HVAC_MODE_TRIGGER_SCHEMA = TRIGGER_BASE_SCHEMA.extend( + { + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_TYPE): "hvac_mode_changed", + vol.Required(state_automation.CONF_TO): vol.In(const.HVAC_MODES), + } +) + +CURRENT_TRIGGER_SCHEMA = vol.All( + TRIGGER_BASE_SCHEMA.extend( + { + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_TYPE): vol.In( + ["current_temperature_changed", "current_humidity_changed"] + ), + vol.Optional(CONF_BELOW): vol.Any(vol.Coerce(float)), + vol.Optional(CONF_ABOVE): vol.Any(vol.Coerce(float)), + vol.Optional(CONF_FOR): cv.positive_time_period_dict, + } + ), + cv.has_at_least_one_key(CONF_BELOW, CONF_ABOVE), +) + +TRIGGER_SCHEMA = vol.Any(HVAC_MODE_TRIGGER_SCHEMA, CURRENT_TRIGGER_SCHEMA) + + +async def async_get_triggers(hass: HomeAssistant, device_id: str) -> List[dict]: + """List device triggers for Climate devices.""" + registry = await entity_registry.async_get_registry(hass) + triggers = [] + + # Get all the integrations entities for this device + for entry in entity_registry.async_entries_for_device(registry, device_id): + if entry.domain != DOMAIN: + continue + + state = hass.states.get(entry.entity_id) + + # Add triggers for each entity that belongs to this integration + triggers.append( + { + CONF_PLATFORM: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "hvac_mode_changed", + } + ) + + if state and const.ATTR_CURRENT_TEMPERATURE in state.attributes: + triggers.append( + { + CONF_PLATFORM: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "current_temperature_changed", + } + ) + + if state and const.ATTR_CURRENT_HUMIDITY in state.attributes: + triggers.append( + { + CONF_PLATFORM: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "current_humidity_changed", + } + ) + + return triggers + + +async def async_attach_trigger( + hass: HomeAssistant, + config: ConfigType, + action: AutomationActionType, + automation_info: dict, +) -> CALLBACK_TYPE: + """Attach a trigger.""" + config = TRIGGER_SCHEMA(config) + trigger_type = config[CONF_TYPE] + + if trigger_type == "hvac_mode_changed": + state_config = { + state_automation.CONF_PLATFORM: "state", + state_automation.CONF_ENTITY_ID: config[CONF_ENTITY_ID], + state_automation.CONF_TO: config[state_automation.CONF_TO], + state_automation.CONF_FROM: [ + mode + for mode in const.HVAC_MODES + if mode != config[state_automation.CONF_TO] + ], + } + if CONF_FOR in config: + state_config[CONF_FOR] = config[CONF_FOR] + state_config = state_automation.TRIGGER_SCHEMA(state_config) + return await state_automation.async_attach_trigger( + hass, state_config, action, automation_info, platform_type="device" + ) + + numeric_state_config = { + numeric_state_automation.CONF_PLATFORM: "numeric_state", + numeric_state_automation.CONF_ENTITY_ID: config[CONF_ENTITY_ID], + } + + if trigger_type == "current_temperature_changed": + numeric_state_config[ + numeric_state_automation.CONF_VALUE_TEMPLATE + ] = "{{ state.attributes.current_temperature }}" + else: + numeric_state_config[ + numeric_state_automation.CONF_VALUE_TEMPLATE + ] = "{{ state.attributes.current_humidity }}" + + if CONF_ABOVE in config: + numeric_state_config[CONF_ABOVE] = config[CONF_ABOVE] + if CONF_BELOW in config: + numeric_state_config[CONF_BELOW] = config[CONF_BELOW] + if CONF_FOR in config: + numeric_state_config[CONF_FOR] = config[CONF_FOR] + + numeric_state_config = numeric_state_automation.TRIGGER_SCHEMA(numeric_state_config) + return await numeric_state_automation.async_attach_trigger( + hass, numeric_state_config, action, automation_info, platform_type="device" + ) + + +async def async_get_trigger_capabilities(hass: HomeAssistant, config): + """List trigger capabilities.""" + trigger_type = config[CONF_TYPE] + + if trigger_type == "hvac_action_changed": + return None + + if trigger_type == "hvac_mode_changed": + return { + "extra_fields": vol.Schema( + {vol.Optional(CONF_FOR): cv.positive_time_period_dict} + ) + } + + if trigger_type == "current_temperature_changed": + unit_of_measurement = hass.config.units.temperature_unit + else: + unit_of_measurement = UNIT_PERCENTAGE + + return { + "extra_fields": vol.Schema( + { + vol.Optional( + CONF_ABOVE, description={"suffix": unit_of_measurement} + ): vol.Coerce(float), + vol.Optional( + CONF_BELOW, description={"suffix": unit_of_measurement} + ): vol.Coerce(float), + vol.Optional(CONF_FOR): cv.positive_time_period_dict, + } + ) + } diff --git a/homeassistant/components/climate/manifest.json b/homeassistant/components/climate/manifest.json index ca5312e7670b1..5d950ccbe2da9 100644 --- a/homeassistant/components/climate/manifest.json +++ b/homeassistant/components/climate/manifest.json @@ -1,8 +1,7 @@ { "domain": "climate", "name": "Climate", - "documentation": "https://www.home-assistant.io/components/climate", - "requirements": [], - "dependencies": [], - "codeowners": [] + "documentation": "https://www.home-assistant.io/integrations/climate", + "codeowners": [], + "quality_scale": "internal" } diff --git a/homeassistant/components/climate/reproduce_state.py b/homeassistant/components/climate/reproduce_state.py index 3259e4084cf68..1217d5fde4cd0 100644 --- a/homeassistant/components/climate/reproduce_state.py +++ b/homeassistant/components/climate/reproduce_state.py @@ -1,91 +1,90 @@ """Module that groups code required to handle state restore for component.""" import asyncio -from typing import Iterable, Optional +from typing import Any, Dict, Iterable, Optional -from homeassistant.const import ( - ATTR_TEMPERATURE, SERVICE_TURN_OFF, - SERVICE_TURN_ON, STATE_OFF, STATE_ON) +from homeassistant.const import ATTR_TEMPERATURE 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_HUMIDITY, + ATTR_HVAC_MODE, + ATTR_PRESET_MODE, + ATTR_SWING_MODE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, - ATTR_HOLD_MODE, - ATTR_OPERATION_MODE, - ATTR_SWING_MODE, - ATTR_HUMIDITY, - SERVICE_SET_AWAY_MODE, + DOMAIN, + HVAC_MODES, SERVICE_SET_AUX_HEAT, - SERVICE_SET_TEMPERATURE, - SERVICE_SET_HOLD_MODE, - SERVICE_SET_OPERATION_MODE, - SERVICE_SET_SWING_MODE, SERVICE_SET_HUMIDITY, - DOMAIN, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_PRESET_MODE, + SERVICE_SET_SWING_MODE, + SERVICE_SET_TEMPERATURE, ) -async def _async_reproduce_states(hass: HomeAssistantType, - state: State, - context: Optional[Context] = None) -> None: +async def _async_reproduce_states( + hass: HomeAssistantType, + state: State, + *, + context: Optional[Context] = None, + reproduce_options: Optional[Dict[str, Any]] = None, +) -> None: """Reproduce component states.""" - async def call_service(service: str, keys: Iterable): + + async def call_service(service: str, keys: Iterable, data=None): """Call service with set of attributes given.""" - data = {} - data['entity_id'] = state.entity_id + data = data or {} + 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) + 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 state.state in HVAC_MODES: + await call_service(SERVICE_SET_HVAC_MODE, [], {ATTR_HVAC_MODE: state.state}) 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_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_OPERATION_MODE in state.attributes: - await call_service(SERVICE_SET_OPERATION_MODE, - [ATTR_OPERATION_MODE]) + if ATTR_PRESET_MODE in state.attributes: + await call_service(SERVICE_SET_PRESET_MODE, [ATTR_PRESET_MODE]) if ATTR_SWING_MODE in state.attributes: - await call_service(SERVICE_SET_SWING_MODE, - [ATTR_SWING_MODE]) + await call_service(SERVICE_SET_SWING_MODE, [ATTR_SWING_MODE]) if ATTR_HUMIDITY in state.attributes: - await call_service(SERVICE_SET_HUMIDITY, - [ATTR_HUMIDITY]) + 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: +async def async_reproduce_states( + hass: HomeAssistantType, + states: Iterable[State], + *, + context: Optional[Context] = None, + reproduce_options: Optional[Dict[str, Any]] = None, +) -> None: """Reproduce component states.""" - await asyncio.gather(*[ - _async_reproduce_states(hass, state, context) - for state in states]) + await asyncio.gather( + *( + _async_reproduce_states( + hass, state, context=context, reproduce_options=reproduce_options + ) + for state in states + ) + ) diff --git a/homeassistant/components/climate/services.yaml b/homeassistant/components/climate/services.yaml index c0dd231ef9516..999640812779d 100644 --- a/homeassistant/components/climate/services.yaml +++ b/homeassistant/components/climate/services.yaml @@ -5,79 +5,76 @@ set_aux_heat: fields: entity_id: description: Name(s) of entities to change. - example: 'climate.kitchen' + example: "climate.kitchen" aux_heat: description: New value of axillary heater. example: true -set_away_mode: - description: Turn away mode on/off for climate device. - fields: - entity_id: - description: Name(s) of entities to change. - example: 'climate.kitchen' - away_mode: - description: New value of away mode. - example: true -set_hold_mode: - description: Turn hold mode for climate device. + +set_preset_mode: + description: Set preset 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' + example: "climate.kitchen" + preset_mode: + description: New value of preset mode + example: "away" + set_temperature: description: Set target temperature of climate device. fields: entity_id: description: Name(s) of entities to change. - example: 'climate.kitchen' + example: "climate.kitchen" temperature: description: New target temperature for HVAC. example: 25 target_temp_high: - description: New target high tempereature for HVAC. + description: New target high temperature for HVAC. example: 26 target_temp_low: 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' + hvac_mode: + description: HVAC operation mode to set temperature to. + example: "heat" + set_humidity: description: Set target humidity of climate device. fields: entity_id: description: Name(s) of entities to change. - example: 'climate.kitchen' + example: "climate.kitchen" humidity: description: New target humidity for climate device. example: 60 + set_fan_mode: description: Set fan operation for climate device. fields: entity_id: description: Name(s) of entities to change. - example: 'climate.nest' + example: "climate.nest" fan_mode: description: New value of fan mode. example: On Low -set_operation_mode: - description: Set operation mode for climate device. + +set_hvac_mode: + description: Set HVAC operation mode for climate device. fields: entity_id: description: Name(s) of entities to change. - example: 'climate.nest' - operation_mode: + example: "climate.nest" + hvac_mode: description: New value of operation mode. - example: Heat + 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' + example: "climate.nest" swing_mode: description: New value of swing mode. @@ -86,64 +83,11 @@ turn_on: fields: entity_id: description: Name(s) of entities to change. - example: 'climate.kitchen' + example: "climate.kitchen" 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: 'climate.kitchen' - fan_min_on_time: - description: New value of fan min on time. - example: 5 - -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' + example: "climate.kitchen" diff --git a/homeassistant/components/climate/strings.json b/homeassistant/components/climate/strings.json new file mode 100644 index 0000000000000..1caf184b99803 --- /dev/null +++ b/homeassistant/components/climate/strings.json @@ -0,0 +1,29 @@ +{ + "title": "Climate", + "device_automation": { + "condition_type": { + "is_hvac_mode": "{entity_name} is set to a specific HVAC mode", + "is_preset_mode": "{entity_name} is set to a specific preset mode" + }, + "trigger_type": { + "current_temperature_changed": "{entity_name} measured temperature changed", + "current_humidity_changed": "{entity_name} measured humidity changed", + "hvac_mode_changed": "{entity_name} HVAC mode changed" + }, + "action_type": { + "set_hvac_mode": "Change HVAC mode on {entity_name}", + "set_preset_mode": "Change preset on {entity_name}" + } + }, + "state": { + "_": { + "off": "[%key:common::state::off%]", + "heat": "Heat", + "cool": "Cool", + "heat_cool": "Heat/Cool", + "auto": "Auto", + "dry": "Dry", + "fan_only": "Fan only" + } + } +} diff --git a/homeassistant/components/climate/translations/af.json b/homeassistant/components/climate/translations/af.json new file mode 100644 index 0000000000000..4a6ceb4a267eb --- /dev/null +++ b/homeassistant/components/climate/translations/af.json @@ -0,0 +1,14 @@ +{ + "state": { + "_": { + "auto": "Outo", + "cool": "Koel", + "dry": "Droog", + "fan_only": "Slegs waaier", + "heat": "Hitte", + "heat_cool": "Verhit/Verkoel", + "off": "Af" + } + }, + "title": "Klimaat" +} \ No newline at end of file diff --git a/homeassistant/components/climate/translations/ar.json b/homeassistant/components/climate/translations/ar.json new file mode 100644 index 0000000000000..1363f619d25e8 --- /dev/null +++ b/homeassistant/components/climate/translations/ar.json @@ -0,0 +1,13 @@ +{ + "state": { + "_": { + "auto": "\u062a\u0644\u0642\u0627\u0626\u064a", + "cool": "\u062a\u0628\u0631\u064a\u062f", + "dry": "\u062c\u0627\u0641", + "fan_only": "\u0627\u0644\u0645\u0631\u0648\u062d\u0629 \u0641\u0642\u0637", + "heat": "\u062a\u062f\u0641\u0626\u0629", + "off": "\u0625\u064a\u0642\u0627\u0641" + } + }, + "title": "\u0627\u0644\u0637\u0642\u0633" +} \ No newline at end of file diff --git a/homeassistant/components/climate/translations/bg.json b/homeassistant/components/climate/translations/bg.json new file mode 100644 index 0000000000000..7c7389545ebaa --- /dev/null +++ b/homeassistant/components/climate/translations/bg.json @@ -0,0 +1,29 @@ +{ + "device_automation": { + "action_type": { + "set_hvac_mode": "\u041f\u0440\u043e\u043c\u044f\u043d\u0430 \u043d\u0430 \u0440\u0435\u0436\u0438\u043c \u043d\u0430 \u041e\u0412\u041a \u043d\u0430 {entity_name}", + "set_preset_mode": "\u041f\u0440\u043e\u043c\u0435\u043d\u0438 \u043f\u0440\u0435\u0434\u0432\u0430\u0440\u0438\u0442\u0435\u043b\u043d\u043e \u0437\u0430\u0434\u0430\u0434\u0435\u043d \u0440\u0435\u0436\u0438\u043c \u043d\u0430 {entity_name}" + }, + "condition_type": { + "is_hvac_mode": "{entity_name} \u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d \u043d\u0430 \u0441\u043f\u0435\u0446\u0438\u0444\u0438\u0447\u0435\u043d \u041e\u0412\u041a \u0440\u0435\u0436\u0438\u043c", + "is_preset_mode": "{entity_name} \u0435 \u0432 \u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d \u043f\u0440\u0435\u0434\u0432\u0430\u0440\u0438\u0442\u0435\u043b\u043d\u043e \u0437\u0430\u0434\u0430\u0434\u0435\u043d \u0440\u0435\u0436\u0438\u043c" + }, + "trigger_type": { + "current_humidity_changed": "{entity_name} \u0438\u0437\u043c\u0435\u0440\u0435\u043d\u0430\u0442\u0430 \u0432\u043b\u0430\u0436\u043d\u043e\u0441\u0442 \u0441\u0435 \u043f\u0440\u043e\u043c\u0435\u043d\u0438", + "current_temperature_changed": "{entity_name} \u0438\u0437\u043c\u0435\u0440\u0435\u043d\u0430\u0442\u0430 \u0442\u0435\u043c\u043f\u0435\u0440\u0430\u0442\u0443\u0440\u0430 \u0441\u0435 \u043f\u0440\u043e\u043c\u0435\u043d\u0438", + "hvac_mode_changed": "{entity_name} \u0420\u0435\u0436\u0438\u043c \u043d\u0430 \u041e\u0412\u041a \u0441\u0435 \u043f\u0440\u043e\u043c\u0435\u043d\u0438" + } + }, + "state": { + "_": { + "auto": "\u0410\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0447\u0435\u043d", + "cool": "\u041e\u0445\u043b\u0430\u0436\u0434\u0430\u043d\u0435", + "dry": "\u0421\u0443\u0445", + "fan_only": "\u0421\u0430\u043c\u043e \u0432\u0435\u043d\u0442\u0438\u043b\u0430\u0442\u043e\u0440", + "heat": "\u041e\u0442\u043e\u043f\u043b\u0435\u043d\u0438\u0435", + "heat_cool": "\u041e\u0442\u043e\u043f\u043b\u0435\u043d\u0438\u0435/\u041e\u0445\u043b\u0430\u0436\u0434\u0430\u043d\u0435", + "off": "\u0418\u0437\u043a\u043b\u044e\u0447\u0435\u043d" + } + }, + "title": "\u041a\u043b\u0438\u043c\u0430\u0442" +} \ No newline at end of file diff --git a/homeassistant/components/climate/translations/bs.json b/homeassistant/components/climate/translations/bs.json new file mode 100644 index 0000000000000..a18207041eaf3 --- /dev/null +++ b/homeassistant/components/climate/translations/bs.json @@ -0,0 +1,13 @@ +{ + "state": { + "_": { + "auto": "Auto", + "cool": "Hladno", + "dry": "Suh", + "fan_only": "Samo ventilator", + "heat": "Toplota", + "off": "Isklju\u010den" + } + }, + "title": "Klima" +} \ No newline at end of file diff --git a/homeassistant/components/climate/translations/ca.json b/homeassistant/components/climate/translations/ca.json new file mode 100644 index 0000000000000..e2f3a58e2eb7e --- /dev/null +++ b/homeassistant/components/climate/translations/ca.json @@ -0,0 +1,29 @@ +{ + "device_automation": { + "action_type": { + "set_hvac_mode": "Canvia el mode HVAC de {entity_name}", + "set_preset_mode": "Canvia la configuraci\u00f3 preestablerta de {entity_name}" + }, + "condition_type": { + "is_hvac_mode": "{entity_name} est\u00e0 configurat/ada en un mode HVAC espec\u00edfic", + "is_preset_mode": "{entity_name} est\u00e0 configurat/ada en un mode preestablert espec\u00edfic" + }, + "trigger_type": { + "current_humidity_changed": "Ha canviat la humitat mesurada per {entity_name}", + "current_temperature_changed": "Ha canviat la temperatura mesurada per {entity_name}", + "hvac_mode_changed": "El mode HVAC de {entity_name} ha canviat" + } + }, + "state": { + "_": { + "auto": "Autom\u00e0tic", + "cool": "Refredar", + "dry": "Assecar", + "fan_only": "Nom\u00e9s ventilador", + "heat": "Escalfar", + "heat_cool": "Escalfar/Refredar", + "off": "Apagat" + } + }, + "title": "Climatitzaci\u00f3" +} \ No newline at end of file diff --git a/homeassistant/components/climate/translations/cs.json b/homeassistant/components/climate/translations/cs.json new file mode 100644 index 0000000000000..a61706acea8f4 --- /dev/null +++ b/homeassistant/components/climate/translations/cs.json @@ -0,0 +1,14 @@ +{ + "state": { + "_": { + "auto": "Automatika", + "cool": "Chlazen\u00ed", + "dry": "Vysou\u0161en\u00ed", + "fan_only": "Pouze ventil\u00e1tor", + "heat": "Topen\u00ed", + "heat_cool": "Vyt\u00e1p\u011bn\u00ed/Chlazen\u00ed", + "off": "Neaktivn\u00ed" + } + }, + "title": "Klimatizace" +} \ No newline at end of file diff --git a/homeassistant/components/climate/translations/cy.json b/homeassistant/components/climate/translations/cy.json new file mode 100644 index 0000000000000..c2d1ebdef0513 --- /dev/null +++ b/homeassistant/components/climate/translations/cy.json @@ -0,0 +1,13 @@ +{ + "state": { + "_": { + "auto": "Awto", + "cool": "Sefydlog", + "dry": "Sych", + "fan_only": "Fan yn unig", + "heat": "Gwres", + "off": "i ffwrdd" + } + }, + "title": "Hinsawdd" +} \ No newline at end of file diff --git a/homeassistant/components/climate/translations/da.json b/homeassistant/components/climate/translations/da.json new file mode 100644 index 0000000000000..18b2bf16d4935 --- /dev/null +++ b/homeassistant/components/climate/translations/da.json @@ -0,0 +1,29 @@ +{ + "device_automation": { + "action_type": { + "set_hvac_mode": "Skift af klimaanl\u00e6gstilstand p\u00e5 {entity_name}", + "set_preset_mode": "Skift af forudindstilling p\u00e5 {entity_name}" + }, + "condition_type": { + "is_hvac_mode": "{entity_name} er indstillet til en bestemt klimaanl\u00e6gstilstand", + "is_preset_mode": "{entity_name} er indstillet til en bestemt forudindstillet tilstand" + }, + "trigger_type": { + "current_humidity_changed": "{entity_name} m\u00e5lte luftfugtighed \u00e6ndret", + "current_temperature_changed": "{entity_name} m\u00e5lte temperatur \u00e6ndret", + "hvac_mode_changed": "{entity_name} klimaanl\u00e6gstilstand \u00e6ndret" + } + }, + "state": { + "_": { + "auto": "Auto", + "cool": "K\u00f8l", + "dry": "T\u00f8r", + "fan_only": "Kun bl\u00e6ser", + "heat": "Varme", + "heat_cool": "Opvarm/k\u00f8l", + "off": "Fra" + } + }, + "title": "Klima" +} \ No newline at end of file diff --git a/homeassistant/components/climate/translations/de.json b/homeassistant/components/climate/translations/de.json new file mode 100644 index 0000000000000..b720df9f0076b --- /dev/null +++ b/homeassistant/components/climate/translations/de.json @@ -0,0 +1,29 @@ +{ + "device_automation": { + "action_type": { + "set_hvac_mode": "HVAC-Modus auf {entity_name} \u00e4ndern", + "set_preset_mode": "Voreinstellung von {entity_name} \u00e4ndern" + }, + "condition_type": { + "is_hvac_mode": "{entity_name} ist auf einen bestimmten HVAC-Modus festgelegt", + "is_preset_mode": "{entity_name} ist auf einen bestimmten voreingestellten Modus eingestellt" + }, + "trigger_type": { + "current_humidity_changed": "Gemessene Luftfeuchtigkeit von {entity_name} ge\u00e4ndert", + "current_temperature_changed": "Gemessene Temperatur von {entity_name} ge\u00e4ndert", + "hvac_mode_changed": "{entity_name} HVAC-Modus ge\u00e4ndert" + } + }, + "state": { + "_": { + "auto": "Automatisch", + "cool": "K\u00fchlen", + "dry": "Entfeuchten", + "fan_only": "Nur Ventilator", + "heat": "Heizen", + "heat_cool": "Heizen/K\u00fchlen", + "off": "Aus" + } + }, + "title": "Klima" +} \ No newline at end of file diff --git a/homeassistant/components/climate/translations/el.json b/homeassistant/components/climate/translations/el.json new file mode 100644 index 0000000000000..44c56a0a2716a --- /dev/null +++ b/homeassistant/components/climate/translations/el.json @@ -0,0 +1,14 @@ +{ + "state": { + "_": { + "auto": "\u0391\u03c5\u03c4\u03cc\u03bc\u03b1\u03c4\u03bf", + "cool": "\u0394\u03c1\u03bf\u03c3\u03b5\u03c1\u03cc", + "dry": "\u039e\u03b7\u03c1\u03cc", + "fan_only": "\u0391\u03bd\u03b5\u03bc\u03b9\u03c3\u03c4\u03ae\u03c1\u03b1\u03c2 \u03bc\u03cc\u03bd\u03bf", + "heat": "\u0398\u03b5\u03c1\u03bc\u03cc", + "heat_cool": "\u0398\u03ad\u03c1\u03bc\u03b1\u03bd\u03c3\u03b7 / \u03a8\u03cd\u03be\u03b7", + "off": "\u0391\u03bd\u03b5\u03bd\u03b5\u03c1\u03b3\u03cc" + } + }, + "title": "\u03a4\u03bf \u03ba\u03bb\u03af\u03bc\u03b1" +} \ No newline at end of file diff --git a/homeassistant/components/climate/translations/en.json b/homeassistant/components/climate/translations/en.json new file mode 100644 index 0000000000000..92ff71be75603 --- /dev/null +++ b/homeassistant/components/climate/translations/en.json @@ -0,0 +1,29 @@ +{ + "device_automation": { + "action_type": { + "set_hvac_mode": "Change HVAC mode on {entity_name}", + "set_preset_mode": "Change preset on {entity_name}" + }, + "condition_type": { + "is_hvac_mode": "{entity_name} is set to a specific HVAC mode", + "is_preset_mode": "{entity_name} is set to a specific preset mode" + }, + "trigger_type": { + "current_humidity_changed": "{entity_name} measured humidity changed", + "current_temperature_changed": "{entity_name} measured temperature changed", + "hvac_mode_changed": "{entity_name} HVAC mode changed" + } + }, + "state": { + "_": { + "auto": "Auto", + "cool": "Cool", + "dry": "Dry", + "fan_only": "Fan only", + "heat": "Heat", + "heat_cool": "Heat/Cool", + "off": "Off" + } + }, + "title": "Climate" +} \ No newline at end of file diff --git a/homeassistant/components/climate/translations/es-419.json b/homeassistant/components/climate/translations/es-419.json new file mode 100644 index 0000000000000..d61483edda273 --- /dev/null +++ b/homeassistant/components/climate/translations/es-419.json @@ -0,0 +1,22 @@ +{ + "device_automation": { + "action_type": { + "set_hvac_mode": "Cambiar el modo HVAC en {entity_name}" + }, + "condition_type": { + "is_hvac_mode": "{entity_name} est\u00e1 configurado en un modo HVAC espec\u00edfico" + } + }, + "state": { + "_": { + "auto": "Automatico", + "cool": "Enfriar", + "dry": "Seco", + "fan_only": "S\u00f3lo ventilador", + "heat": "Calentar", + "heat_cool": "Calentar/Enfriar", + "off": "Desactivar" + } + }, + "title": "Clima" +} \ No newline at end of file diff --git a/homeassistant/components/climate/translations/es.json b/homeassistant/components/climate/translations/es.json new file mode 100644 index 0000000000000..9ed5ff150c020 --- /dev/null +++ b/homeassistant/components/climate/translations/es.json @@ -0,0 +1,29 @@ +{ + "device_automation": { + "action_type": { + "set_hvac_mode": "Cambiar el modo HVAC de {entity_name}.", + "set_preset_mode": "Cambiar la configuraci\u00f3n prefijada de {entity_name}" + }, + "condition_type": { + "is_hvac_mode": "{entity_name} est\u00e1 configurado en un modo HVAC espec\u00edfico", + "is_preset_mode": "{entity_name} se establece en un modo predeterminado espec\u00edfico" + }, + "trigger_type": { + "current_humidity_changed": "{entity_name} humedad medida cambi\u00f3", + "current_temperature_changed": "{entity_name} temperatura medida cambi\u00f3", + "hvac_mode_changed": "{entity_name} Modo HVAC cambiado" + } + }, + "state": { + "_": { + "auto": "Autom\u00e1tico", + "cool": "Fr\u00edo", + "dry": "Seco", + "fan_only": "S\u00f3lo ventilador", + "heat": "Calor", + "heat_cool": "Calor/Fr\u00edo", + "off": "Apagado" + } + }, + "title": "Climatizaci\u00f3n" +} \ No newline at end of file diff --git a/homeassistant/components/climate/translations/et.json b/homeassistant/components/climate/translations/et.json new file mode 100644 index 0000000000000..1c4a6a5ff1121 --- /dev/null +++ b/homeassistant/components/climate/translations/et.json @@ -0,0 +1,14 @@ +{ + "state": { + "_": { + "auto": "Automaatne", + "cool": "Jahuta", + "dry": "Kuiv", + "fan_only": "Ainult ventilaator", + "heat": "Soojenda", + "heat_cool": "K\u00fcta/jahuta", + "off": "V\u00e4ljas" + } + }, + "title": "Kliima" +} \ No newline at end of file diff --git a/homeassistant/components/climate/translations/eu.json b/homeassistant/components/climate/translations/eu.json new file mode 100644 index 0000000000000..1dc30b687ac0a --- /dev/null +++ b/homeassistant/components/climate/translations/eu.json @@ -0,0 +1,13 @@ +{ + "state": { + "_": { + "auto": "Automatikoa", + "cool": "Hotza", + "dry": "Lehorra", + "fan_only": "Haizagailua bakarrik", + "heat": "Beroa", + "off": "Itzalita" + } + }, + "title": "Klimatizazioa" +} \ No newline at end of file diff --git a/homeassistant/components/climate/translations/fa.json b/homeassistant/components/climate/translations/fa.json new file mode 100644 index 0000000000000..84793ac06c9fa --- /dev/null +++ b/homeassistant/components/climate/translations/fa.json @@ -0,0 +1,13 @@ +{ + "state": { + "_": { + "auto": "\u062e\u0648\u062f\u06a9\u0627\u0631", + "cool": "\u062e\u0646\u06a9", + "dry": "\u062e\u0634\u06a9", + "fan_only": "\u0641\u0642\u0637 \u067e\u0646\u06a9\u0647", + "heat": "\u062d\u0631\u0627\u0631\u062a", + "off": "\u062e\u0627\u0645\u0648\u0634" + } + }, + "title": "\u0622\u0628 \u0648 \u0647\u0648\u0627" +} \ No newline at end of file diff --git a/homeassistant/components/climate/translations/fi.json b/homeassistant/components/climate/translations/fi.json new file mode 100644 index 0000000000000..7a280d441207e --- /dev/null +++ b/homeassistant/components/climate/translations/fi.json @@ -0,0 +1,14 @@ +{ + "state": { + "_": { + "auto": "Automaatilla", + "cool": "J\u00e4\u00e4hdytys", + "dry": "Kuivaus", + "fan_only": "Tuuletus", + "heat": "L\u00e4mmitys", + "heat_cool": "L\u00e4mmitys/j\u00e4\u00e4hdytys", + "off": "Pois" + } + }, + "title": "Ilmasto" +} \ No newline at end of file diff --git a/homeassistant/components/climate/translations/fr.json b/homeassistant/components/climate/translations/fr.json new file mode 100644 index 0000000000000..913c2579478c0 --- /dev/null +++ b/homeassistant/components/climate/translations/fr.json @@ -0,0 +1,29 @@ +{ + "device_automation": { + "action_type": { + "set_hvac_mode": "Changer le mode HVAC sur {entity_name}.", + "set_preset_mode": "Changer les pr\u00e9r\u00e9glages de {entity_name}" + }, + "condition_type": { + "is_hvac_mode": "{entity_name} est d\u00e9fini sur un mode HVAC sp\u00e9cifique", + "is_preset_mode": "{entity_name} est d\u00e9fini sur un mode pr\u00e9d\u00e9fini sp\u00e9cifique" + }, + "trigger_type": { + "current_humidity_changed": "Changement d'humidit\u00e9 mesur\u00e9e pour {entity_name}", + "current_temperature_changed": "Changement de temp\u00e9rature mesur\u00e9e pour {entity_name}", + "hvac_mode_changed": "Mode HVAC chang\u00e9 pour {entity_name}" + } + }, + "state": { + "_": { + "auto": "Auto", + "cool": "Frais", + "dry": "Sec", + "fan_only": "Ventilateur seul", + "heat": "Chauffe", + "heat_cool": "Chaud/Froid", + "off": "Inactif" + } + }, + "title": "Thermostat" +} \ No newline at end of file diff --git a/homeassistant/components/climate/translations/gsw.json b/homeassistant/components/climate/translations/gsw.json new file mode 100644 index 0000000000000..9c3f9a34fb736 --- /dev/null +++ b/homeassistant/components/climate/translations/gsw.json @@ -0,0 +1,13 @@ +{ + "state": { + "_": { + "auto": "Automatik", + "cool": "Ch\u00fc\u00e4l\u00e4", + "dry": "Troch\u00e4", + "fan_only": "Nur L\u00fcfter", + "heat": "Heiz\u00e4", + "off": "Us" + } + }, + "title": "Klima" +} \ No newline at end of file diff --git a/homeassistant/components/climate/translations/he.json b/homeassistant/components/climate/translations/he.json new file mode 100644 index 0000000000000..fe5380a0528b4 --- /dev/null +++ b/homeassistant/components/climate/translations/he.json @@ -0,0 +1,14 @@ +{ + "state": { + "_": { + "auto": "\u05d0\u05d5\u05d8\u05d5\u05de\u05d8\u05d9", + "cool": "\u05e7\u05e8\u05d5\u05e8", + "dry": "\u05d9\u05d1\u05e9", + "fan_only": "\u05de\u05d0\u05d5\u05d5\u05e8\u05e8 \u05d1\u05dc\u05d1\u05d3", + "heat": "\u05d7\u05d9\u05de\u05d5\u05dd", + "heat_cool": "\u05d7\u05d9\u05de\u05d5\u05dd/\u05e7\u05d9\u05e8\u05d5\u05e8", + "off": "\u05db\u05d1\u05d5\u05d9" + } + }, + "title": "\u05d0\u05b7\u05e7\u05dc\u05b4\u05d9\u05dd" +} \ No newline at end of file diff --git a/homeassistant/components/climate/translations/hi.json b/homeassistant/components/climate/translations/hi.json new file mode 100644 index 0000000000000..1c595e33c76db --- /dev/null +++ b/homeassistant/components/climate/translations/hi.json @@ -0,0 +1,11 @@ +{ + "state": { + "_": { + "cool": "\u0920\u0902\u0921\u093e", + "dry": "\u0938\u0942\u0916\u093e", + "heat": "\u0917\u0930\u094d\u092e\u0940", + "off": "\u092c\u0902\u0926" + } + }, + "title": "\u091c\u0932\u0935\u093e\u092f\u0941" +} \ No newline at end of file diff --git a/homeassistant/components/climate/translations/hr.json b/homeassistant/components/climate/translations/hr.json new file mode 100644 index 0000000000000..a960810c02062 --- /dev/null +++ b/homeassistant/components/climate/translations/hr.json @@ -0,0 +1,14 @@ +{ + "state": { + "_": { + "auto": "Auto", + "cool": "Hla\u0111enje", + "dry": "Suho", + "fan_only": "Samo ventilator", + "heat": "Grijanje", + "heat_cool": "Grijanje/Hla\u0111enje", + "off": "Isklju\u010den" + } + }, + "title": "Klima" +} \ No newline at end of file diff --git a/homeassistant/components/climate/translations/hu.json b/homeassistant/components/climate/translations/hu.json new file mode 100644 index 0000000000000..400c1af877d42 --- /dev/null +++ b/homeassistant/components/climate/translations/hu.json @@ -0,0 +1,29 @@ +{ + "device_automation": { + "action_type": { + "set_hvac_mode": "F\u0171t\u00e9s- \u00e9s l\u00e9gtechnikai (HVAC) \u00fczemm\u00f3d m\u00f3dos\u00edt\u00e1sa a k\u00f6vetkez\u0151n: {entity_name}", + "set_preset_mode": "A(z) {entity_name} be\u00e1ll\u00edt\u00e1s\u00e1nak v\u00e1lt\u00e1sa" + }, + "condition_type": { + "is_hvac_mode": "{entity_name} speci\u00e1lis f\u0171t\u00e9s, szell\u0151z\u00e9s \u00e9s l\u00e9gkondicion\u00e1l\u00e1s (HVAC) \u00fczemm\u00f3dra van be\u00e1ll\u00edtva", + "is_preset_mode": "A(z) {entity_name} el\u0151re be\u00e1ll\u00edtott m\u00f3dja van kiv\u00e1lasztva" + }, + "trigger_type": { + "current_humidity_changed": "{entity_name} m\u00e9rt p\u00e1ratartalma megv\u00e1ltozott", + "current_temperature_changed": "{entity_name} m\u00e9rt h\u0151m\u00e9rs\u00e9klete megv\u00e1ltozott", + "hvac_mode_changed": "{entity_name} f\u0171t\u00e9s, szell\u0151z\u00e9s \u00e9s l\u00e9gkondicion\u00e1l\u00e1s (HVAC) \u00fczemm\u00f3d megv\u00e1ltozott" + } + }, + "state": { + "_": { + "auto": "Automatikus", + "cool": "H\u0171t\u00e9s", + "dry": "Sz\u00e1raz", + "fan_only": "Csak ventil\u00e1tor", + "heat": "F\u0171t\u00e9s", + "heat_cool": "F\u0171t\u00e9s/H\u0171t\u00e9s", + "off": "Ki" + } + }, + "title": "H\u0171t\u00e9s/f\u0171t\u00e9s" +} \ No newline at end of file diff --git a/homeassistant/components/climate/translations/hy.json b/homeassistant/components/climate/translations/hy.json new file mode 100644 index 0000000000000..d7a090fe5f393 --- /dev/null +++ b/homeassistant/components/climate/translations/hy.json @@ -0,0 +1,14 @@ +{ + "state": { + "_": { + "auto": "\u0531\u057e\u057f\u0578\u0574\u0561\u057f", + "cool": "\u0540\u0578\u057e\u0561\u0581\u0578\u0582\u0574", + "dry": "\u0549\u0578\u0580", + "fan_only": "\u0555\u0564\u0561\u0583\u0578\u056d\u056b\u0579", + "heat": "\u054b\u0565\u0580\u0574\u0578\u0582\u0569\u0575\u0578\u0582\u0576", + "heat_cool": "\u054b\u0565\u057c\u0578\u0582\u0581\u0578\u0582\u0574/\u0540\u0578\u057e\u0561\u0581\u0578\u0582\u0574", + "off": "\u0531\u0576\u057b\u0561\u057f\u057e\u0561\u056e" + } + }, + "title": "\u053f\u056c\u056b\u0574\u0561" +} \ No newline at end of file diff --git a/homeassistant/components/climate/translations/id.json b/homeassistant/components/climate/translations/id.json new file mode 100644 index 0000000000000..4f1ec02379b4f --- /dev/null +++ b/homeassistant/components/climate/translations/id.json @@ -0,0 +1,14 @@ +{ + "state": { + "_": { + "auto": "Auto", + "cool": "Sejuk", + "dry": "Kering", + "fan_only": "Hanya kipas", + "heat": "Panas", + "heat_cool": "Panas/Dingin", + "off": "Off" + } + }, + "title": "Cuaca" +} \ No newline at end of file diff --git a/homeassistant/components/climate/translations/is.json b/homeassistant/components/climate/translations/is.json new file mode 100644 index 0000000000000..dd2e8043cd888 --- /dev/null +++ b/homeassistant/components/climate/translations/is.json @@ -0,0 +1,14 @@ +{ + "state": { + "_": { + "auto": "Sj\u00e1lfvirkt", + "cool": "K\u00e6ling", + "dry": "\u00deurrt", + "fan_only": "Vifta eing\u00f6ngu", + "heat": "Hitun", + "heat_cool": "Hita/K\u00e6la", + "off": "Sl\u00f6kkt" + } + }, + "title": "Loftslag" +} \ No newline at end of file diff --git a/homeassistant/components/climate/translations/it.json b/homeassistant/components/climate/translations/it.json new file mode 100644 index 0000000000000..4f427209325d0 --- /dev/null +++ b/homeassistant/components/climate/translations/it.json @@ -0,0 +1,29 @@ +{ + "device_automation": { + "action_type": { + "set_hvac_mode": "Cambia modalit\u00e0 HVAC su {entity_name}", + "set_preset_mode": "Modifica preimpostazione su {entity_name}" + }, + "condition_type": { + "is_hvac_mode": "{entity_name} \u00e8 impostato su una modalit\u00e0 HVAC specifica", + "is_preset_mode": "{entity_name} \u00e8 impostato su una modalit\u00e0 preimpostata specifica" + }, + "trigger_type": { + "current_humidity_changed": "{entity_name} umidit\u00e0 misurata modificata", + "current_temperature_changed": "{entity_name} temperatura misurata cambiata", + "hvac_mode_changed": "{entity_name} modalit\u00e0 HVAC modificata" + } + }, + "state": { + "_": { + "auto": "Auto", + "cool": "Freddo", + "dry": "Secco", + "fan_only": "Solo ventilatore", + "heat": "Caldo", + "heat_cool": "Caldo/Freddo", + "off": "Spento" + } + }, + "title": "Termostato" +} \ No newline at end of file diff --git a/homeassistant/components/climate/translations/ja.json b/homeassistant/components/climate/translations/ja.json new file mode 100644 index 0000000000000..2d660b8dd5448 --- /dev/null +++ b/homeassistant/components/climate/translations/ja.json @@ -0,0 +1,12 @@ +{ + "state": { + "_": { + "auto": "\u30aa\u30fc\u30c8", + "cool": "\u51b7\u623f", + "dry": "\u30c9\u30e9\u30a4", + "fan_only": "\u30d5\u30a1\u30f3\u306e\u307f", + "heat": "\u6696\u623f", + "off": "\u30aa\u30d5" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/climate/translations/ko.json b/homeassistant/components/climate/translations/ko.json new file mode 100644 index 0000000000000..c707c9c1eba34 --- /dev/null +++ b/homeassistant/components/climate/translations/ko.json @@ -0,0 +1,29 @@ +{ + "device_automation": { + "action_type": { + "set_hvac_mode": "{entity_name} \uc758 HVAC \ubaa8\ub4dc \ubcc0\uacbd", + "set_preset_mode": "{entity_name} \uc758 \uc0ac\uc804 \uc124\uc815 \ubcc0\uacbd" + }, + "condition_type": { + "is_hvac_mode": "{entity_name} \uc774(\uac00) \ud2b9\uc815 HVAC \ubaa8\ub4dc\ub85c \uc124\uc815\ub418\uc5b4\uc788\uc73c\uba74", + "is_preset_mode": "{entity_name} \uc774(\uac00) \ud2b9\uc815 \uc0ac\uc804 \uc124\uc815 \ubaa8\ub4dc\ub85c \uc124\uc815\ub418\uc5b4\uc788\uc73c\uba74" + }, + "trigger_type": { + "current_humidity_changed": "{entity_name} \uc774(\uac00) \uc2b5\ub3c4 \ubcc0\ud654\ub97c \uac10\uc9c0\ud560 \ub54c", + "current_temperature_changed": "{entity_name} \uc774(\uac00) \uc628\ub3c4 \ubcc0\ud654\ub97c \uac10\uc9c0\ud560 \ub54c", + "hvac_mode_changed": "{entity_name} HVAC \ubaa8\ub4dc\uac00 \ubcc0\uacbd\ub420 \ub54c" + } + }, + "state": { + "_": { + "auto": "\uc790\ub3d9", + "cool": "\ub0c9\ubc29", + "dry": "\uc81c\uc2b5", + "fan_only": "\uc1a1\ud48d", + "heat": "\ub09c\ubc29", + "heat_cool": "\ub0c9\ub09c\ubc29", + "off": "\uaebc\uc9d0" + } + }, + "title": "\uacf5\uc870\uae30\uae30" +} \ No newline at end of file diff --git a/homeassistant/components/climate/translations/lb.json b/homeassistant/components/climate/translations/lb.json new file mode 100644 index 0000000000000..6d97f7759bef0 --- /dev/null +++ b/homeassistant/components/climate/translations/lb.json @@ -0,0 +1,29 @@ +{ + "device_automation": { + "action_type": { + "set_hvac_mode": "HVAC Modus \u00e4nnere fir {entity_name}", + "set_preset_mode": "Preset \u00e4nnere fir {entity_name}" + }, + "condition_type": { + "is_hvac_mode": "{entity_name} ass op e spezifesche HVAC Modus gesat", + "is_preset_mode": "{entity_name} ass op e spezifesche preset Modus gesat" + }, + "trigger_type": { + "current_humidity_changed": "{entity_name} gemoosse Fiichtegkeet ge\u00e4nnert", + "current_temperature_changed": "{entity_name} gemoossen Temperatur ge\u00e4nnert", + "hvac_mode_changed": "{entity_name} HVAC Modus ge\u00e4nnert" + } + }, + "state": { + "_": { + "auto": "Auto", + "cool": "Kill", + "dry": "Dr\u00e9chen", + "fan_only": "N\u00ebmme Ventilator", + "heat": "Heizen", + "heat_cool": "H\u00ebtzen/Ofkillen", + "off": "Aus" + } + }, + "title": "Klima" +} \ No newline at end of file diff --git a/homeassistant/components/climate/translations/lt.json b/homeassistant/components/climate/translations/lt.json new file mode 100644 index 0000000000000..1f60d1cd5c290 --- /dev/null +++ b/homeassistant/components/climate/translations/lt.json @@ -0,0 +1,7 @@ +{ + "state": { + "_": { + "off": "I\u0161jungta" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/climate/translations/lv.json b/homeassistant/components/climate/translations/lv.json new file mode 100644 index 0000000000000..f789256ed33aa --- /dev/null +++ b/homeassistant/components/climate/translations/lv.json @@ -0,0 +1,14 @@ +{ + "state": { + "_": { + "auto": "Auto", + "cool": "Dzes\u0113\u0161ana", + "dry": "Sauss", + "fan_only": "Tikai ventilators", + "heat": "Sild\u012b\u0161ana", + "heat_cool": "Sild\u012bt / Atdzes\u0113t", + "off": "Izsl\u0113gts" + } + }, + "title": "Klimats" +} \ No newline at end of file diff --git a/homeassistant/components/climate/translations/nb.json b/homeassistant/components/climate/translations/nb.json new file mode 100644 index 0000000000000..aa28848f92101 --- /dev/null +++ b/homeassistant/components/climate/translations/nb.json @@ -0,0 +1,14 @@ +{ + "state": { + "_": { + "auto": "Auto", + "cool": "Kj\u00f8ling", + "dry": "T\u00f8rr", + "fan_only": "Kun vifte", + "heat": "Varme", + "heat_cool": "Varme/kj\u00f8ling", + "off": "Av" + } + }, + "title": "Klima" +} \ No newline at end of file diff --git a/homeassistant/components/climate/translations/nl.json b/homeassistant/components/climate/translations/nl.json new file mode 100644 index 0000000000000..0237d2bbd9ac4 --- /dev/null +++ b/homeassistant/components/climate/translations/nl.json @@ -0,0 +1,29 @@ +{ + "device_automation": { + "action_type": { + "set_hvac_mode": "Wijzig de HVAC-modus op {entity_name}", + "set_preset_mode": "Wijzig voorinstelling op {entity_name}" + }, + "condition_type": { + "is_hvac_mode": "{entity_name} is ingesteld op een specifieke HVAC-modus", + "is_preset_mode": "{entity_name} is ingesteld op een specifieke vooraf ingestelde modus" + }, + "trigger_type": { + "current_humidity_changed": "{entity_name} gemeten vochtigheid veranderd", + "current_temperature_changed": "{entity_name} gemeten temperatuur veranderd", + "hvac_mode_changed": "{entity_name} HVAC-modus gewijzigd" + } + }, + "state": { + "_": { + "auto": "Auto", + "cool": "Koelen", + "dry": "Droog", + "fan_only": "Alleen ventilatie", + "heat": "Verwarmen", + "heat_cool": "Verwarmen/Koelen", + "off": "Uit" + } + }, + "title": "Klimaat" +} \ No newline at end of file diff --git a/homeassistant/components/climate/translations/nn.json b/homeassistant/components/climate/translations/nn.json new file mode 100644 index 0000000000000..12a5e879b783c --- /dev/null +++ b/homeassistant/components/climate/translations/nn.json @@ -0,0 +1,14 @@ +{ + "state": { + "_": { + "auto": "Auto", + "cool": "Kj\u00f8le", + "dry": "T\u00f8rr", + "fan_only": "Berre vifte", + "heat": "Varme", + "heat_cool": "Oppvarming/Nedkj\u00f8ling", + "off": "Av" + } + }, + "title": "Klima" +} \ No newline at end of file diff --git a/homeassistant/components/climate/translations/no.json b/homeassistant/components/climate/translations/no.json new file mode 100644 index 0000000000000..4ac58d07bbbf7 --- /dev/null +++ b/homeassistant/components/climate/translations/no.json @@ -0,0 +1,29 @@ +{ + "device_automation": { + "action_type": { + "set_hvac_mode": "Endre klima-modus p\u00e5 {entity_name}", + "set_preset_mode": "Endre modus p\u00e5 {entity_name}" + }, + "condition_type": { + "is_hvac_mode": "{entity_name} er satt til en spesifikk klima-modus", + "is_preset_mode": "{entity_name} er satt til en spesifikk forh\u00e5ndsinnstilt modus" + }, + "trigger_type": { + "current_humidity_changed": "{entity_name} m\u00e5lt fuktighet er endret", + "current_temperature_changed": "{entity_name} m\u00e5lt temperatur er endret", + "hvac_mode_changed": "{entity_name} klima-modus er endret" + } + }, + "state": { + "_": { + "auto": "", + "cool": "Kj\u00f8le", + "dry": "T\u00f8rr", + "fan_only": "Kun vifte", + "heat": "Varme", + "heat_cool": "Varme/kj\u00f8lig", + "off": "Av" + } + }, + "title": "Klima" +} \ No newline at end of file diff --git a/homeassistant/components/climate/translations/pl.json b/homeassistant/components/climate/translations/pl.json new file mode 100644 index 0000000000000..50f882dcd8074 --- /dev/null +++ b/homeassistant/components/climate/translations/pl.json @@ -0,0 +1,29 @@ +{ + "device_automation": { + "action_type": { + "set_hvac_mode": "zmie\u0144 tryb HVAC na {entity_name}", + "set_preset_mode": "zmie\u0144 ustawienia dla {entity_name}" + }, + "condition_type": { + "is_hvac_mode": "na {entity_name} jest ustawiony okre\u015blony tryb HVAC", + "is_preset_mode": "na {entity_name} jest okre\u015blone ustawienie" + }, + "trigger_type": { + "current_humidity_changed": "zmieni si\u0119 zmierzona wilgotno\u015b\u0107 {entity_name}", + "current_temperature_changed": "zmieni si\u0119 zmierzona temperatura {entity_name}", + "hvac_mode_changed": "zmieni si\u0119 tryb HVAC {entity_name}" + } + }, + "state": { + "_": { + "auto": "automatyczny", + "cool": "ch\u0142odzenie", + "dry": "osuszanie", + "fan_only": "tylko wentylator", + "heat": "grzanie", + "heat_cool": "grzanie/ch\u0142odzenie", + "off": "wy\u0142\u0105czony" + } + }, + "title": "Klimat" +} \ No newline at end of file diff --git a/homeassistant/components/climate/translations/pt-BR.json b/homeassistant/components/climate/translations/pt-BR.json new file mode 100644 index 0000000000000..e920caf2a870f --- /dev/null +++ b/homeassistant/components/climate/translations/pt-BR.json @@ -0,0 +1,14 @@ +{ + "state": { + "_": { + "auto": "Autom\u00e1tico", + "cool": "Frio", + "dry": "Seco", + "fan_only": "Apenas ventilador", + "heat": "Quente", + "heat_cool": "Quente/Frio", + "off": "Desligado" + } + }, + "title": "Clima" +} \ No newline at end of file diff --git a/homeassistant/components/climate/translations/pt.json b/homeassistant/components/climate/translations/pt.json new file mode 100644 index 0000000000000..5acd785c64412 --- /dev/null +++ b/homeassistant/components/climate/translations/pt.json @@ -0,0 +1,14 @@ +{ + "state": { + "_": { + "auto": "Auto", + "cool": "Frio", + "dry": "Desumidificar", + "fan_only": "Apenas ventilar", + "heat": "Quente", + "heat_cool": "Calor / Frio", + "off": "Desligado" + } + }, + "title": "Climatiza\u00e7\u00e3o" +} \ No newline at end of file diff --git a/homeassistant/components/climate/translations/ro.json b/homeassistant/components/climate/translations/ro.json new file mode 100644 index 0000000000000..bb39b84b03ddd --- /dev/null +++ b/homeassistant/components/climate/translations/ro.json @@ -0,0 +1,14 @@ +{ + "state": { + "_": { + "auto": "Auto", + "cool": "Rece", + "dry": "Uscat", + "fan_only": "Numai ventilator", + "heat": "C\u0103ldur\u0103", + "heat_cool": "\u00cenc\u0103lzire / R\u0103cire", + "off": "Oprit" + } + }, + "title": "Clima" +} \ No newline at end of file diff --git a/homeassistant/components/climate/translations/ru.json b/homeassistant/components/climate/translations/ru.json new file mode 100644 index 0000000000000..507686629e933 --- /dev/null +++ b/homeassistant/components/climate/translations/ru.json @@ -0,0 +1,29 @@ +{ + "device_automation": { + "action_type": { + "set_hvac_mode": "\u0421\u043c\u0435\u043d\u0438\u0442\u044c \u0440\u0435\u0436\u0438\u043c \u0440\u0430\u0431\u043e\u0442\u044b \u043e\u0431\u044a\u0435\u043a\u0442\u0430 \"{entity_name}\"", + "set_preset_mode": "\u0421\u043c\u0435\u043d\u0438\u0442\u044c \u043d\u0430\u0431\u043e\u0440 \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043a \u043e\u0431\u044a\u0435\u043a\u0442\u0430 \"{entity_name}\"" + }, + "condition_type": { + "is_hvac_mode": "{entity_name} \u043d\u0430\u0445\u043e\u0434\u0438\u0442\u0441\u044f \u0432 \u0437\u0430\u0434\u0430\u043d\u043d\u043e\u043c \u0440\u0435\u0436\u0438\u043c\u0435 \u0440\u0430\u0431\u043e\u0442\u044b", + "is_preset_mode": "{entity_name} \u0432 \u0440\u0435\u0436\u0438\u043c\u0435 \u043f\u0440\u0435\u0434\u0443\u0441\u0442\u0430\u043d\u043e\u0432\u043b\u0435\u043d\u043d\u043e\u0433\u043e \u043d\u0430\u0431\u043e\u0440\u0430 \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043a" + }, + "trigger_type": { + "current_humidity_changed": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0438\u0437\u043c\u0435\u0440\u0435\u043d\u043d\u043e\u0439 \u0432\u043b\u0430\u0436\u043d\u043e\u0441\u0442\u0438", + "current_temperature_changed": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0438\u0437\u043c\u0435\u0440\u0435\u043d\u043d\u043e\u0439 \u0442\u0435\u043c\u043f\u0435\u0440\u0430\u0442\u0443\u0440\u044b", + "hvac_mode_changed": "{entity_name} \u043c\u0435\u043d\u044f\u0435\u0442 \u0440\u0435\u0436\u0438\u043c \u0440\u0430\u0431\u043e\u0442\u044b" + } + }, + "state": { + "_": { + "auto": "\u0410\u0432\u0442\u043e", + "cool": "\u041e\u0445\u043b\u0430\u0436\u0434\u0435\u043d\u0438\u0435", + "dry": "\u041e\u0441\u0443\u0448\u0435\u043d\u0438\u0435", + "fan_only": "\u0412\u0435\u043d\u0442\u0438\u043b\u044f\u0446\u0438\u044f", + "heat": "\u041e\u0431\u043e\u0433\u0440\u0435\u0432", + "heat_cool": "\u041e\u0431\u043e\u0433\u0440\u0435\u0432 / \u041e\u0445\u043b\u0430\u0436\u0434\u0435\u043d\u0438\u0435", + "off": "\u0412\u044b\u043a\u043b\u044e\u0447\u0435\u043d\u043e" + } + }, + "title": "\u041a\u043b\u0438\u043c\u0430\u0442" +} \ No newline at end of file diff --git a/homeassistant/components/climate/translations/sk.json b/homeassistant/components/climate/translations/sk.json new file mode 100644 index 0000000000000..15536f9b879c7 --- /dev/null +++ b/homeassistant/components/climate/translations/sk.json @@ -0,0 +1,14 @@ +{ + "state": { + "_": { + "auto": "Automatika", + "cool": "Chladenie", + "dry": "Su\u0161enie", + "fan_only": "Iba ventil\u00e1tor", + "heat": "K\u00farenie", + "heat_cool": "Vykurovanie / Chladenie", + "off": "Vypnut\u00e9" + } + }, + "title": "Klimatiz\u00e1cia" +} \ No newline at end of file diff --git a/homeassistant/components/climate/translations/sl.json b/homeassistant/components/climate/translations/sl.json new file mode 100644 index 0000000000000..037f807b42d18 --- /dev/null +++ b/homeassistant/components/climate/translations/sl.json @@ -0,0 +1,29 @@ +{ + "device_automation": { + "action_type": { + "set_hvac_mode": "Spremeni na\u010din HVAC na {entity_name}", + "set_preset_mode": "Spremenite prednastavitev na {entity_name}" + }, + "condition_type": { + "is_hvac_mode": "{entity_name} je nastavljen na dolo\u010den na\u010din HVAC", + "is_preset_mode": "{entity_name} je nastavljen na dolo\u010den prednastavljeni na\u010din" + }, + "trigger_type": { + "current_humidity_changed": "{entity_name} spremenjena izmerjena vla\u017enost", + "current_temperature_changed": "{entity_name} izmerjena temperaturna sprememba", + "hvac_mode_changed": "{entity_name} HVAC na\u010din spremenjen" + } + }, + "state": { + "_": { + "auto": "Samodejno", + "cool": "Mrzlo", + "dry": "Suho", + "fan_only": "Samo ventilator", + "heat": "Toplo", + "heat_cool": "Gretje/Hlajenje", + "off": "Izklju\u010den" + } + }, + "title": "Klimat" +} \ No newline at end of file diff --git a/homeassistant/components/climate/translations/sv.json b/homeassistant/components/climate/translations/sv.json new file mode 100644 index 0000000000000..a5dcd72d66b36 --- /dev/null +++ b/homeassistant/components/climate/translations/sv.json @@ -0,0 +1,29 @@ +{ + "device_automation": { + "action_type": { + "set_hvac_mode": "\u00c4ndra HVAC-l\u00e4ge p\u00e5 {entity_name}", + "set_preset_mode": "\u00c4ndra f\u00f6rinst\u00e4llning p\u00e5 {entity_name}" + }, + "condition_type": { + "is_hvac_mode": "{entity_name} \u00e4r inst\u00e4lld p\u00e5 ett specifikt HVAC-l\u00e4ge", + "is_preset_mode": "{entity_name} \u00e4r inst\u00e4lld p\u00e5 ett specifikt f\u00f6rinst\u00e4llt l\u00e4ge" + }, + "trigger_type": { + "current_humidity_changed": "{entity_name} uppm\u00e4tt fuktighet har \u00e4ndrats", + "current_temperature_changed": "{entity_name} uppm\u00e4tt temperatur har \u00e4ndrats", + "hvac_mode_changed": "{entity_name} HVAC-l\u00e4ge har \u00e4ndrats" + } + }, + "state": { + "_": { + "auto": "Automatisk", + "cool": "Kyla", + "dry": "Avfuktning", + "fan_only": "Endast fl\u00e4kt", + "heat": "V\u00e4rme", + "heat_cool": "V\u00e4rme/Kyla", + "off": "Av" + } + }, + "title": "Klimat" +} \ No newline at end of file diff --git a/homeassistant/components/climate/translations/ta.json b/homeassistant/components/climate/translations/ta.json new file mode 100644 index 0000000000000..17d24535cb80a --- /dev/null +++ b/homeassistant/components/climate/translations/ta.json @@ -0,0 +1,12 @@ +{ + "state": { + "_": { + "auto": "\u0ba4\u0bbe\u0ba9\u0bbe\u0b95 \u0b87\u0baf\u0b99\u0bcd\u0b95\u0bc1\u0ba4\u0bb2\u0bcd", + "cool": "\u0b95\u0bc1\u0bb3\u0bbf\u0bb0\u0bcd", + "dry": "\u0b89\u0bb2\u0bb0\u0bcd\u0ba8\u0bcd\u0ba4", + "fan_only": "\u0bb5\u0bbf\u0b9a\u0bbf\u0bb1\u0bbf \u0bae\u0b9f\u0bcd\u0b9f\u0bc1\u0bae\u0bcd", + "heat": "\u0bb5\u0bc6\u0baa\u0bcd\u0baa\u0bae\u0bcd", + "off": "\u0b86\u0b83\u0baa\u0bcd" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/climate/translations/te.json b/homeassistant/components/climate/translations/te.json new file mode 100644 index 0000000000000..ba01524303eaf --- /dev/null +++ b/homeassistant/components/climate/translations/te.json @@ -0,0 +1,13 @@ +{ + "state": { + "_": { + "auto": "\u0c26\u0c3e\u0c28\u0c02\u0c24\u0c1f \u0c05\u0c26\u0c47", + "cool": "\u0c1a\u0c32\u0c4d\u0c32\u0c17\u0c3e", + "dry": "\u0c2a\u0c4a\u0c21\u0c3f", + "fan_only": "\u0c2b\u0c4d\u0c2f\u0c3e\u0c28\u0c4d \u0c2e\u0c3e\u0c24\u0c4d\u0c30\u0c2e\u0c47", + "heat": "\u0c35\u0c46\u0c1a\u0c4d\u0c1a\u0c17\u0c3e", + "off": "\u0c06\u0c2b\u0c4d" + } + }, + "title": "\u0c35\u0c3e\u0c24\u0c3e\u0c35\u0c30\u0c23\u0c02" +} \ No newline at end of file diff --git a/homeassistant/components/climate/translations/th.json b/homeassistant/components/climate/translations/th.json new file mode 100644 index 0000000000000..73c9f9e1d57b4 --- /dev/null +++ b/homeassistant/components/climate/translations/th.json @@ -0,0 +1,14 @@ +{ + "state": { + "_": { + "auto": "\u0e2d\u0e31\u0e15\u0e42\u0e19\u0e21\u0e31\u0e15\u0e34", + "cool": "\u0e40\u0e22\u0e47\u0e19", + "dry": "\u0e41\u0e2b\u0e49\u0e07", + "fan_only": "\u0e40\u0e09\u0e1e\u0e32\u0e30\u0e1e\u0e31\u0e14\u0e25\u0e21", + "heat": "\u0e23\u0e49\u0e2d\u0e19", + "heat_cool": "\u0e23\u0e49\u0e2d\u0e19/\u0e40\u0e22\u0e47\u0e19", + "off": "\u0e1b\u0e34\u0e14" + } + }, + "title": "\u0e2a\u0e20\u0e32\u0e1e\u0e2d\u0e32\u0e01\u0e32\u0e28" +} \ No newline at end of file diff --git a/homeassistant/components/climate/translations/tr.json b/homeassistant/components/climate/translations/tr.json new file mode 100644 index 0000000000000..bfac4a6e7c645 --- /dev/null +++ b/homeassistant/components/climate/translations/tr.json @@ -0,0 +1,14 @@ +{ + "state": { + "_": { + "auto": "Otomatik", + "cool": "Serin", + "dry": "Kuru", + "fan_only": "Sadece fan", + "heat": "S\u0131cak", + "heat_cool": "Is\u0131tma / So\u011futma", + "off": "Kapal\u0131" + } + }, + "title": "\u0130klim" +} \ No newline at end of file diff --git a/homeassistant/components/climate/translations/uk.json b/homeassistant/components/climate/translations/uk.json new file mode 100644 index 0000000000000..227e0e1f4ef98 --- /dev/null +++ b/homeassistant/components/climate/translations/uk.json @@ -0,0 +1,14 @@ +{ + "state": { + "_": { + "auto": "\u0410\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0447\u043d\u0438\u0439", + "cool": "\u041e\u0445\u043e\u043b\u043e\u0434\u0436\u0435\u043d\u043d\u044f", + "dry": "\u041e\u0441\u0443\u0448\u0435\u043d\u043d\u044f", + "fan_only": "\u041b\u0438\u0448\u0435 \u0432\u0435\u043d\u0442\u0438\u043b\u044f\u0442\u043e\u0440", + "heat": "\u041e\u0431\u0456\u0433\u0440\u0456\u0432\u0430\u043d\u043d\u044f", + "heat_cool": "\u041e\u043f\u0430\u043b\u0435\u043d\u043d\u044f/\u041e\u0445\u043e\u043b\u043e\u0434\u0436\u0435\u043d\u043d\u044f", + "off": "\u0412\u0438\u043c\u043a\u043d\u0435\u043d\u043e" + } + }, + "title": "\u041a\u043b\u0456\u043c\u0430\u0442" +} \ No newline at end of file diff --git a/homeassistant/components/climate/translations/vi.json b/homeassistant/components/climate/translations/vi.json new file mode 100644 index 0000000000000..f5b9bd1e7233e --- /dev/null +++ b/homeassistant/components/climate/translations/vi.json @@ -0,0 +1,14 @@ +{ + "state": { + "_": { + "auto": "T\u01b0\u0323 \u0111\u00f4\u0323ng", + "cool": "M\u00e1t m\u1ebb", + "dry": "Kh\u00f4", + "fan_only": "Ch\u1ec9 c\u00f3 qu\u1ea1t", + "heat": "Nhi\u1ec7t", + "heat_cool": "N\u00f3ng/L\u1ea1nh", + "off": "T\u1eaft" + } + }, + "title": "Kh\u00ed h\u1eadu" +} \ No newline at end of file diff --git a/homeassistant/components/climate/translations/zh-Hans.json b/homeassistant/components/climate/translations/zh-Hans.json new file mode 100644 index 0000000000000..9927cd679ae25 --- /dev/null +++ b/homeassistant/components/climate/translations/zh-Hans.json @@ -0,0 +1,29 @@ +{ + "device_automation": { + "action_type": { + "set_hvac_mode": "\u66f4\u6539 {entity_name} \u7a7a\u8c03\u6a21\u5f0f", + "set_preset_mode": "\u66f4\u6539 {entity_name} \u9884\u8bbe\u6a21\u5f0f" + }, + "condition_type": { + "is_hvac_mode": "{entity_name} \u88ab\u8bbe\u4e3a\u6307\u5b9a\u7684\u7a7a\u8c03\u6a21\u5f0f", + "is_preset_mode": "{entity_name} \u88ab\u8bbe\u4e3a\u6307\u5b9a\u7684\u9884\u8bbe\u6a21\u5f0f" + }, + "trigger_type": { + "current_humidity_changed": "{entity_name} \u6d4b\u91cf\u7684\u5ba4\u5185\u6e7f\u5ea6\u53d8\u5316", + "current_temperature_changed": "{entity_name} \u6d4b\u91cf\u7684\u5ba4\u5185\u6e29\u5ea6\u53d8\u5316", + "hvac_mode_changed": "{entity_name} \u7684\u8fd0\u884c\u6a21\u5f0f\u53d8\u5316" + } + }, + "state": { + "_": { + "auto": "\u81ea\u52a8", + "cool": "\u5236\u51b7", + "dry": "\u9664\u6e7f", + "fan_only": "\u4ec5\u9001\u98ce", + "heat": "\u5236\u70ed", + "heat_cool": "\u5236\u70ed/\u5236\u51b7", + "off": "\u5173" + } + }, + "title": "\u7a7a\u8c03" +} \ No newline at end of file diff --git a/homeassistant/components/climate/translations/zh-Hant.json b/homeassistant/components/climate/translations/zh-Hant.json new file mode 100644 index 0000000000000..e8f43f589ee3d --- /dev/null +++ b/homeassistant/components/climate/translations/zh-Hant.json @@ -0,0 +1,29 @@ +{ + "device_automation": { + "action_type": { + "set_hvac_mode": "\u8b8a\u66f4{entity_name} HVAC \u6a21\u5f0f", + "set_preset_mode": "\u8b8a\u66f4{entity_name}\u8a2d\u5b9a\u6a21\u5f0f" + }, + "condition_type": { + "is_hvac_mode": "{entity_name}\u8a2d\u5b9a\u70ba\u6307\u5b9a HVAC \u6a21\u5f0f", + "is_preset_mode": "{entity_name}\u8a2d\u5b9a\u70ba\u6307\u5b9a\u8a2d\u5b9a\u6a21\u5f0f" + }, + "trigger_type": { + "current_humidity_changed": "{entity_name}\u91cf\u6e2c\u6fd5\u5ea6\u5df2\u8b8a\u66f4", + "current_temperature_changed": "{entity_name}\u91cf\u6e2c\u6eab\u5ea6\u5df2\u8b8a\u66f4", + "hvac_mode_changed": "{entity_name} HVAC \u6a21\u5f0f\u5df2\u8b8a\u66f4" + } + }, + "state": { + "_": { + "auto": "\u81ea\u52d5", + "cool": "\u51b7\u6c23", + "dry": "\u9664\u6fd5\u6a21\u5f0f", + "fan_only": "\u50c5\u9001\u98a8", + "heat": "\u6696\u6c23", + "heat_cool": "\u6696\u6c23/\u51b7\u6c23", + "off": "\u95dc\u9589" + } + }, + "title": "\u6eab\u63a7" +} \ No newline at end of file diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index d4d443a692d51..ad97284d85726 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -1,80 +1,111 @@ """Component to integrate the Home Assistant cloud.""" import logging +from hass_nabucasa import Cloud 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.alexa import const as alexa_const 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) + CONF_MODE, + CONF_NAME, + CONF_REGION, + 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 . import account_link, http_api +from .client import CloudClient 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) + CONF_ACCOUNT_LINK_URL, + CONF_ACME_DIRECTORY_SERVER, + CONF_ALEXA, + CONF_ALEXA_ACCESS_TOKEN_URL, + CONF_ALIASES, + CONF_CLOUDHOOK_CREATE_URL, + CONF_COGNITO_CLIENT_ID, + CONF_ENTITY_CONFIG, + CONF_FILTER, + CONF_GOOGLE_ACTIONS, + CONF_GOOGLE_ACTIONS_REPORT_STATE_URL, + CONF_RELAYER, + CONF_REMOTE_API_URL, + CONF_SUBSCRIPTION_INFO_URL, + CONF_USER_POOL_ID, + CONF_VOICE_API_URL, + 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' +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, -}) +ALEXA_ENTITY_SCHEMA = vol.Schema( + { + vol.Optional(alexa_const.CONF_DESCRIPTION): cv.string, + vol.Optional(alexa_const.CONF_DISPLAY_CATEGORIES): cv.string, + vol.Optional(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, -}) +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, -}) +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} -}) +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}, -}) +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) +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Optional(CONF_MODE, default=DEFAULT_MODE): vol.In( + [MODE_DEV, MODE_PROD] + ), + 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_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, + vol.Optional(CONF_ALEXA_ACCESS_TOKEN_URL): vol.Url(), + vol.Optional(CONF_GOOGLE_ACTIONS_REPORT_STATE_URL): vol.Url(), + vol.Optional(CONF_ACCOUNT_LINK_URL): vol.Url(), + vol.Optional(CONF_VOICE_API_URL): vol.Url(), + } + ) + }, + extra=vol.ALLOW_EXTRA, +) class CloudNotAvailable(HomeAssistantError): @@ -92,8 +123,7 @@ def async_is_logged_in(hass) -> bool: @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 + return async_is_logged_in(hass) and not hass.data[DOMAIN].subscription_expired @bind_hass @@ -103,7 +133,7 @@ async def async_create_cloudhook(hass, webhook_id: str) -> str: raise CloudNotAvailable hook = await hass.data[DOMAIN].cloudhooks.async_create(webhook_id, True) - return hook['cloudhook_url'] + return hook["cloudhook_url"] @bind_hass @@ -122,10 +152,13 @@ def async_remote_ui_url(hass) -> str: if not async_is_logged_in(hass): raise CloudNotAvailable + if not hass.data[DOMAIN].client.prefs.remote_enabled: + raise CloudNotAvailable + if not hass.data[DOMAIN].remote.instance_domain: raise CloudNotAvailable - return "https://" + hass.data[DOMAIN].remote.instance_domain + return f"https://{hass.data[DOMAIN].remote.instance_domain}" def is_cloudhook_request(request): @@ -138,9 +171,6 @@ def is_cloudhook_request(request): 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]) @@ -155,23 +185,11 @@ async def async_setup(hass, config): 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() @@ -188,16 +206,34 @@ async def _service_handler(service): await prefs.async_update(remote_enabled=False) hass.helpers.service.async_register_admin_service( - DOMAIN, SERVICE_REMOTE_CONNECT, _service_handler) + DOMAIN, SERVICE_REMOTE_CONNECT, _service_handler + ) hass.helpers.service.async_register_admin_service( - DOMAIN, SERVICE_REMOTE_DISCONNECT, _service_handler) + DOMAIN, SERVICE_REMOTE_DISCONNECT, _service_handler + ) + + loaded = False async def _on_connect(): """Discover RemoteUI binary sensor.""" - hass.async_create_task(hass.helpers.discovery.async_load_platform( - 'binary_sensor', DOMAIN, {}, config)) + nonlocal loaded + + # Prevent multiple discovery + if loaded: + return + loaded = True + + await hass.helpers.discovery.async_load_platform( + "binary_sensor", DOMAIN, {}, config + ) + await hass.helpers.discovery.async_load_platform("stt", DOMAIN, {}, config) + await hass.helpers.discovery.async_load_platform("tts", DOMAIN, {}, config) cloud.iot.register_on_connect(_on_connect) + await cloud.start() await http_api.async_setup(hass) + + account_link.async_setup(hass) + return True diff --git a/homeassistant/components/cloud/account_link.py b/homeassistant/components/cloud/account_link.py new file mode 100644 index 0000000000000..1d0de26918d8f --- /dev/null +++ b/homeassistant/components/cloud/account_link.py @@ -0,0 +1,144 @@ +"""Account linking via the cloud.""" +import asyncio +import logging +from typing import Any + +from hass_nabucasa import account_link + +from homeassistant.const import MAJOR_VERSION, MINOR_VERSION, PATCH_VERSION +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_entry_oauth2_flow, event + +from .const import DOMAIN + +DATA_SERVICES = "cloud_account_link_services" +CACHE_TIMEOUT = 3600 +_LOGGER = logging.getLogger(__name__) + + +@callback +def async_setup(hass: HomeAssistant): + """Set up cloud account link.""" + config_entry_oauth2_flow.async_add_implementation_provider( + hass, DOMAIN, async_provide_implementation + ) + + +async def async_provide_implementation(hass: HomeAssistant, domain: str): + """Provide an implementation for a domain.""" + services = await _get_services(hass) + + for service in services: + if service["service"] == domain and _is_older(service["min_version"]): + return CloudOAuth2Implementation(hass, domain) + + return + + +@callback +def _is_older(version: str) -> bool: + """Test if a version is older than the current HA version.""" + version_parts = version.split(".") + + if len(version_parts) != 3: + return False + + try: + version_parts = [int(val) for val in version_parts] + except ValueError: + return False + + patch_number_str = "" + + for char in PATCH_VERSION: + if char.isnumeric(): + patch_number_str += char + else: + break + + try: + patch_number = int(patch_number_str) + except ValueError: + patch_number = 0 + + cur_version_parts = [MAJOR_VERSION, MINOR_VERSION, patch_number] + + return version_parts <= cur_version_parts + + +async def _get_services(hass): + """Get the available services.""" + services = hass.data.get(DATA_SERVICES) + + if services is not None: + return services + + services = await account_link.async_fetch_available_services(hass.data[DOMAIN]) + + hass.data[DATA_SERVICES] = services + + @callback + def clear_services(_now): + """Clear services cache.""" + hass.data.pop(DATA_SERVICES, None) + + event.async_call_later(hass, CACHE_TIMEOUT, clear_services) + + return services + + +class CloudOAuth2Implementation(config_entry_oauth2_flow.AbstractOAuth2Implementation): + """Cloud implementation of the OAuth2 flow.""" + + def __init__(self, hass: HomeAssistant, service: str): + """Initialize cloud OAuth2 implementation.""" + self.hass = hass + self.service = service + + @property + def name(self) -> str: + """Name of the implementation.""" + return "Home Assistant Cloud" + + @property + def domain(self) -> str: + """Domain that is providing the implementation.""" + return DOMAIN + + async def async_generate_authorize_url(self, flow_id: str) -> str: + """Generate a url for the user to authorize.""" + helper = account_link.AuthorizeAccountHelper( + self.hass.data[DOMAIN], self.service + ) + authorize_url = await helper.async_get_authorize_url() + + async def await_tokens(): + """Wait for tokens and pass them on when received.""" + try: + tokens = await helper.async_get_tokens() + + except asyncio.TimeoutError: + _LOGGER.info("Timeout fetching tokens for flow %s", flow_id) + except account_link.AccountLinkException as err: + _LOGGER.info( + "Failed to fetch tokens for flow %s: %s", flow_id, err.code + ) + else: + await self.hass.config_entries.flow.async_configure( + flow_id=flow_id, user_input=tokens + ) + + self.hass.async_create_task(await_tokens()) + + return authorize_url + + async def async_resolve_external_data(self, external_data: Any) -> dict: + """Resolve external data to tokens.""" + # We already passed in tokens + return external_data + + async def _async_refresh_token(self, token: dict) -> dict: + """Refresh a token.""" + return await account_link.async_fetch_access_token( + self.hass.data[DOMAIN], self.service, token["refresh_token"] + ) diff --git a/homeassistant/components/cloud/alexa_config.py b/homeassistant/components/cloud/alexa_config.py new file mode 100644 index 0000000000000..a45469c8f976e --- /dev/null +++ b/homeassistant/components/cloud/alexa_config.py @@ -0,0 +1,297 @@ +"""Alexa configuration for Home Assistant Cloud.""" +import asyncio +from datetime import timedelta +import logging + +import aiohttp +import async_timeout +from hass_nabucasa import cloud_api + +from homeassistant.components.alexa import ( + config as alexa_config, + entities as alexa_entities, + errors as alexa_errors, + state_report as alexa_state_report, +) +from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES, HTTP_BAD_REQUEST +from homeassistant.core import callback +from homeassistant.helpers import entity_registry +from homeassistant.helpers.event import async_call_later +from homeassistant.util.dt import utcnow + +from .const import ( + CONF_ENTITY_CONFIG, + CONF_FILTER, + DEFAULT_SHOULD_EXPOSE, + PREF_SHOULD_EXPOSE, + RequireRelink, +) + +_LOGGER = logging.getLogger(__name__) + +# Time to wait when entity preferences have changed before syncing it to +# the cloud. +SYNC_DELAY = 1 + + +class AlexaConfig(alexa_config.AbstractConfig): + """Alexa Configuration.""" + + def __init__(self, hass, config, prefs, cloud): + """Initialize the Alexa config.""" + super().__init__(hass) + self._config = config + self._prefs = prefs + self._cloud = cloud + self._token = None + self._token_valid = None + self._cur_entity_prefs = prefs.alexa_entity_configs + self._alexa_sync_unsub = None + self._endpoint = None + + prefs.async_listen_updates(self._async_prefs_updated) + hass.bus.async_listen( + entity_registry.EVENT_ENTITY_REGISTRY_UPDATED, + self._handle_entity_registry_updated, + ) + + @property + def enabled(self): + """Return if Alexa is enabled.""" + return self._prefs.alexa_enabled + + @property + def supports_auth(self): + """Return if config supports auth.""" + return True + + @property + def should_report_state(self): + """Return if states should be proactively reported.""" + return self._prefs.alexa_report_state + + @property + def endpoint(self): + """Endpoint for report state.""" + if self._endpoint is None: + raise ValueError("No endpoint available. Fetch access token first") + + return self._endpoint + + @property + def locale(self): + """Return config locale.""" + # Not clear how to determine locale atm. + return "en-US" + + @property + def entity_config(self): + """Return entity config.""" + return self._config.get(CONF_ENTITY_CONFIG) or {} + + def should_expose(self, entity_id): + """If an entity should be exposed.""" + if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: + return False + + if not self._config[CONF_FILTER].empty_filter: + return self._config[CONF_FILTER](entity_id) + + entity_configs = self._prefs.alexa_entity_configs + entity_config = entity_configs.get(entity_id, {}) + return entity_config.get(PREF_SHOULD_EXPOSE, DEFAULT_SHOULD_EXPOSE) + + @callback + def async_invalidate_access_token(self): + """Invalidate access token.""" + self._token_valid = None + + async def async_get_access_token(self): + """Get an access token.""" + if self._token_valid is not None and self._token_valid > utcnow(): + return self._token + + resp = await cloud_api.async_alexa_access_token(self._cloud) + body = await resp.json() + + if resp.status == HTTP_BAD_REQUEST: + if body["reason"] in ("RefreshTokenNotFound", "UnknownRegion"): + if self.should_report_state: + await self._prefs.async_update(alexa_report_state=False) + self.hass.components.persistent_notification.async_create( + f"There was an error reporting state to Alexa ({body['reason']}). " + "Please re-link your Alexa skill via the Alexa app to " + "continue using it.", + "Alexa state reporting disabled", + "cloud_alexa_report", + ) + raise RequireRelink + + raise alexa_errors.NoTokenAvailable + + self._token = body["access_token"] + self._endpoint = body["event_endpoint"] + self._token_valid = utcnow() + timedelta(seconds=body["expires_in"]) + return self._token + + async def _async_prefs_updated(self, prefs): + """Handle updated preferences.""" + if self.should_report_state != self.is_reporting_states: + if self.should_report_state: + await self.async_enable_proactive_mode() + else: + await self.async_disable_proactive_mode() + + # State reporting is reported as a property on entities. + # So when we change it, we need to sync all entities. + await self.async_sync_entities() + return + + # If entity prefs are the same or we have filter in config.yaml, + # don't sync. + if ( + self._cur_entity_prefs is prefs.alexa_entity_configs + or not self._config[CONF_FILTER].empty_filter + ): + return + + if self._alexa_sync_unsub: + self._alexa_sync_unsub() + + self._alexa_sync_unsub = async_call_later( + self.hass, SYNC_DELAY, self._sync_prefs + ) + + async def _sync_prefs(self, _now): + """Sync the updated preferences to Alexa.""" + self._alexa_sync_unsub = None + old_prefs = self._cur_entity_prefs + new_prefs = self._prefs.alexa_entity_configs + + seen = set() + to_update = [] + to_remove = [] + + for entity_id, info in old_prefs.items(): + seen.add(entity_id) + old_expose = info.get(PREF_SHOULD_EXPOSE) + + if entity_id in new_prefs: + new_expose = new_prefs[entity_id].get(PREF_SHOULD_EXPOSE) + else: + new_expose = None + + if old_expose == new_expose: + continue + + if new_expose: + to_update.append(entity_id) + else: + to_remove.append(entity_id) + + # Now all the ones that are in new prefs but never were in old prefs + for entity_id, info in new_prefs.items(): + if entity_id in seen: + continue + + new_expose = info.get(PREF_SHOULD_EXPOSE) + + if new_expose is None: + continue + + # Only test if we should expose. It can never be a remove action, + # as it didn't exist in old prefs object. + if new_expose: + to_update.append(entity_id) + + # We only set the prefs when update is successful, that way we will + # retry when next change comes in. + if await self._sync_helper(to_update, to_remove): + self._cur_entity_prefs = new_prefs + + async def async_sync_entities(self): + """Sync all entities to Alexa.""" + # Remove any pending sync + if self._alexa_sync_unsub: + self._alexa_sync_unsub() + self._alexa_sync_unsub = None + + to_update = [] + to_remove = [] + + for entity in alexa_entities.async_get_entities(self.hass, self): + if self.should_expose(entity.entity_id): + to_update.append(entity.entity_id) + else: + to_remove.append(entity.entity_id) + + return await self._sync_helper(to_update, to_remove) + + async def _sync_helper(self, to_update, to_remove) -> bool: + """Sync entities to Alexa. + + Return boolean if it was successful. + """ + if not to_update and not to_remove: + return True + + # Make sure it's valid. + await self.async_get_access_token() + + tasks = [] + + if to_update: + tasks.append( + alexa_state_report.async_send_add_or_update_message( + self.hass, self, to_update + ) + ) + + if to_remove: + tasks.append( + alexa_state_report.async_send_delete_message(self.hass, self, to_remove) + ) + + try: + with async_timeout.timeout(10): + await asyncio.wait(tasks, return_when=asyncio.ALL_COMPLETED) + + return True + + except asyncio.TimeoutError: + _LOGGER.warning("Timeout trying to sync entitites to Alexa") + return False + + except aiohttp.ClientError as err: + _LOGGER.warning("Error trying to sync entities to Alexa: %s", err) + return False + + async def _handle_entity_registry_updated(self, event): + """Handle when entity registry updated.""" + if not self.enabled or not self._cloud.is_logged_in: + return + + entity_id = event.data["entity_id"] + + if not self.should_expose(entity_id): + return + + action = event.data["action"] + to_update = [] + to_remove = [] + + if action == "create": + to_update.append(entity_id) + elif action == "remove": + to_remove.append(entity_id) + elif action == "update" and bool( + set(event.data["changes"]) & entity_registry.ENTITY_DESCRIBING_ATTRIBUTES + ): + to_update.append(entity_id) + if "old_entity_id" in event.data: + to_remove.append(event.data["old_entity_id"]) + + try: + await self._sync_helper(to_update, to_remove) + except alexa_errors.NoTokenAvailable: + pass diff --git a/homeassistant/components/cloud/binary_sensor.py b/homeassistant/components/cloud/binary_sensor.py index 3e4aaf9cc848a..baa63679d42c1 100644 --- a/homeassistant/components/cloud/binary_sensor.py +++ b/homeassistant/components/cloud/binary_sensor.py @@ -1,17 +1,15 @@ """Support for Home Assistant Cloud binary sensors.""" import asyncio -from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.components.binary_sensor import BinarySensorEntity 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): +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 @@ -20,7 +18,7 @@ async def async_setup_platform( async_add_entities([CloudRemoteBinary(cloud)]) -class CloudRemoteBinary(BinarySensorDevice): +class CloudRemoteBinary(BinarySensorEntity): """Representation of an Cloud Remote UI Connection binary sensor.""" def __init__(self, cloud): @@ -46,7 +44,7 @@ def is_on(self) -> bool: @property def device_class(self) -> str: """Return the class of this device, from component DEVICE_CLASSES.""" - return 'connectivity' + return "connectivity" @property def available(self) -> bool: @@ -60,13 +58,15 @@ def should_poll(self) -> bool: 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.async_write_ha_state() self._unsub_dispatcher = async_dispatcher_connect( - self.hass, DISPATCHER_REMOTE_UPDATE, async_state_update) + self.hass, DISPATCHER_REMOTE_UPDATE, async_state_update + ) async def async_will_remove_from_hass(self): """Register update dispatcher.""" diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py index f47eae74986af..a17f536db7248 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -1,39 +1,47 @@ """Interface implementation for cloud client.""" import asyncio +import logging 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.components.alexa import ( + errors as alexa_errors, + smart_home as alexa_sh, +) +from homeassistant.components.google_assistant import const as gc, smart_home as ga +from homeassistant.const import HTTP_OK +from homeassistant.core import Context, callback from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.typing import HomeAssistantType from homeassistant.util.aiohttp import MockRequest -from . import utils -from .const import ( - CONF_ENTITY_CONFIG, CONF_FILTER, DOMAIN, DISPATCHER_REMOTE_UPDATE) +from . import alexa_config, google_config, utils +from .const import DISPATCHER_REMOTE_UPDATE from .prefs import CloudPreferences +_LOGGER = logging.getLogger(__name__) + 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]): + def __init__( + self, + hass: HomeAssistantType, + prefs: CloudPreferences, + websession: aiohttp.ClientSession, + alexa_user_config: Dict[str, Any], + google_user_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.google_user_config = google_user_config + self.alexa_user_config = alexa_user_config self._alexa_config = None self._google_config = None @@ -73,56 +81,52 @@ def remote_autostart(self) -> bool: return self._prefs.remote_enabled @property - def alexa_config(self) -> alexa_sh.Config: + def alexa_config(self) -> alexa_config.AlexaConfig: """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), + if self._alexa_config is None: + assert self.cloud is not None + self._alexa_config = alexa_config.AlexaConfig( + self._hass, self.alexa_user_config, self._prefs, self.cloud ) return self._alexa_config - @property - def google_config(self) -> ga_h.Config: + async def get_google_config(self) -> google_config.CloudGoogleConfig: """Return Google config.""" if not self._google_config: - google_conf = self._google_user_config + assert self.cloud is not None - def should_expose(entity): - """If an entity should be exposed.""" - if entity.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: - return False + cloud_user = await self._prefs.get_cloud_user() - return google_conf['filter'](entity.entity_id) + self._google_config = google_config.CloudGoogleConfig( + self._hass, self.google_user_config, cloud_user, self._prefs, self.cloud + ) + await self._google_config.async_initialize() - username = self._hass.data[DOMAIN].claims["cognito:username"] + return self._google_config - self._google_config = ga_h.Config( - should_expose=should_expose, - secure_devices_pin=self._prefs.google_secure_devices_pin, - entity_config=google_conf.get(CONF_ENTITY_CONFIG), - agent_user_id=username, - ) + async def logged_in(self) -> None: + """When user logs in.""" + await self.prefs.async_set_username(self.cloud.username) - # Set it to the latest. - self._google_config.secure_devices_pin = \ - self._prefs.google_secure_devices_pin + if self.alexa_config.enabled and self.alexa_config.should_report_state: + try: + await self.alexa_config.async_enable_proactive_mode() + except alexa_errors.NoTokenAvailable: + pass - return self._google_config + if self._prefs.google_enabled: + gconf = await self.get_google_config() - @property - def google_user_config(self) -> Dict[str, Any]: - """Return google action user config.""" - return self._google_user_config + gconf.async_enable_local_sdk() + + if gconf.should_report_state: + gconf.async_enable_report_state() async def cleanups(self) -> None: """Cleanup some stuff after logout.""" - self._alexa_config = None + await self.prefs.async_set_username(None) + self._google_config = None @callback @@ -138,62 +142,61 @@ def dispatcher_message(self, identifier: str, data: Any = None) -> None: 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]: + async def async_alexa_message(self, payload: Dict[Any, Any]) -> Dict[Any, Any]: """Process cloud alexa message to client.""" + cloud_user = await self._prefs.get_cloud_user() return await alexa_sh.async_handle_message( - self._hass, self.alexa_config, payload, - enabled=self._prefs.alexa_enabled + self._hass, + self.alexa_config, + payload, + context=Context(user_id=cloud_user), + enabled=self._prefs.alexa_enabled, ) - async def async_google_message( - self, payload: Dict[Any, Any]) -> Dict[Any, Any]: + 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) + gconf = await self.get_google_config() + return await ga.async_handle_message( - self._hass, self.google_config, self.prefs.cloud_user, payload + self._hass, gconf, gconf.cloud_user, payload, gc.SOURCE_CLOUD ) - async def async_webhook_message( - self, payload: Dict[Any, Any]) -> Dict[Any, Any]: + async def async_webhook_message(self, payload: Dict[Any, Any]) -> Dict[Any, Any]: """Process cloud webhook message to client.""" - cloudhook_id = payload['cloudhook_id'] + cloudhook_id = payload["cloudhook_id"] found = None for cloudhook in self._prefs.cloudhooks.values(): - if cloudhook['cloudhook_id'] == cloudhook_id: + if cloudhook["cloudhook_id"] == cloudhook_id: found = cloudhook break if found is None: - return { - 'status': 200 - } + return {"status": HTTP_OK} request = MockRequest( - content=payload['body'].encode('utf-8'), - headers=payload['headers'], - method=payload['method'], - query_string=payload['query'], + 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) + found["webhook_id"], request + ) response_dict = utils.aiohttp_serialize_response(response) - body = response_dict.get('body') + body = response_dict.get("body") return { - 'body': body, - 'status': response_dict['status'], - 'headers': { - 'Content-Type': response.content_type - } + "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: + 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 index 5002286edb937..3d930f0c2e5b3 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -1,33 +1,58 @@ """Constants for the cloud component.""" -DOMAIN = 'cloud' +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' - -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' +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_GOOGLE_REPORT_STATE = "google_report_state" +PREF_ALEXA_ENTITY_CONFIGS = "alexa_entity_configs" +PREF_ALEXA_REPORT_STATE = "alexa_report_state" +PREF_OVERRIDE_NAME = "override_name" +PREF_DISABLE_2FA = "disable_2fa" +PREF_ALIASES = "aliases" +PREF_SHOULD_EXPOSE = "should_expose" +PREF_GOOGLE_LOCAL_WEBHOOK_ID = "google_local_webhook_id" +PREF_USERNAME = "username" +DEFAULT_SHOULD_EXPOSE = True +DEFAULT_DISABLE_2FA = False +DEFAULT_ALEXA_REPORT_STATE = False +DEFAULT_GOOGLE_REPORT_STATE = 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_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" +CONF_ALEXA_ACCESS_TOKEN_URL = "alexa_access_token_url" +CONF_GOOGLE_ACTIONS_REPORT_STATE_URL = "google_actions_report_state_url" +CONF_ACCOUNT_LINK_URL = "account_link_url" +CONF_VOICE_API_URL = "voice_api_url" MODE_DEV = "development" MODE_PROD = "production" -DISPATCHER_REMOTE_UPDATE = 'cloud_remote_update' +DISPATCHER_REMOTE_UPDATE = "cloud_remote_update" class InvalidTrustedNetworks(Exception): """Raised when invalid trusted networks config.""" + + +class InvalidTrustedProxies(Exception): + """Raised when invalid trusted proxies config.""" + + +class RequireRelink(Exception): + """The skill needs to be relinked.""" diff --git a/homeassistant/components/cloud/google_config.py b/homeassistant/components/cloud/google_config.py new file mode 100644 index 0000000000000..9b94b77ca45fb --- /dev/null +++ b/homeassistant/components/cloud/google_config.py @@ -0,0 +1,195 @@ +"""Google config for Cloud.""" +import asyncio +import logging + +from hass_nabucasa import cloud_api +from hass_nabucasa.google_report_state import ErrorResponse + +from homeassistant.components.google_assistant.helpers import AbstractConfig +from homeassistant.const import ( + CLOUD_NEVER_EXPOSED_ENTITIES, + EVENT_HOMEASSISTANT_STARTED, + HTTP_OK, +) +from homeassistant.core import CoreState, callback +from homeassistant.helpers import entity_registry + +from .const import ( + CONF_ENTITY_CONFIG, + DEFAULT_DISABLE_2FA, + DEFAULT_SHOULD_EXPOSE, + PREF_DISABLE_2FA, + PREF_SHOULD_EXPOSE, +) + +_LOGGER = logging.getLogger(__name__) + + +class CloudGoogleConfig(AbstractConfig): + """HA Cloud Configuration for Google Assistant.""" + + def __init__(self, hass, config, cloud_user, prefs, cloud): + """Initialize the Google config.""" + super().__init__(hass) + self._config = config + self._user = cloud_user + self._prefs = prefs + self._cloud = cloud + self._cur_entity_prefs = self._prefs.google_entity_configs + self._sync_entities_lock = asyncio.Lock() + self._sync_on_started = False + + @property + def enabled(self): + """Return if Google is enabled.""" + return self._cloud.is_logged_in and self._prefs.google_enabled + + @property + def entity_config(self): + """Return entity config.""" + return self._config.get(CONF_ENTITY_CONFIG) or {} + + @property + def secure_devices_pin(self): + """Return entity config.""" + return self._prefs.google_secure_devices_pin + + @property + def should_report_state(self): + """Return if states should be proactively reported.""" + return self._cloud.is_logged_in and self._prefs.google_report_state + + @property + def local_sdk_webhook_id(self): + """Return the local SDK webhook. + + Return None to disable the local SDK. + """ + return self._prefs.google_local_webhook_id + + @property + def local_sdk_user_id(self): + """Return the user ID to be used for actions received via the local SDK.""" + return self._user + + @property + def cloud_user(self): + """Return Cloud User account.""" + return self._user + + async def async_initialize(self): + """Perform async initialization of config.""" + await super().async_initialize() + # Remove bad data that was there until 0.103.6 - Jan 6, 2020 + self._store.pop_agent_user_id(self._user) + + self._prefs.async_listen_updates(self._async_prefs_updated) + + self.hass.bus.async_listen( + entity_registry.EVENT_ENTITY_REGISTRY_UPDATED, + self._handle_entity_registry_updated, + ) + + def should_expose(self, state): + """If a state object should be exposed.""" + return self._should_expose_entity_id(state.entity_id) + + def _should_expose_entity_id(self, entity_id): + """If an entity ID should be exposed.""" + if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: + return False + + if not self._config["filter"].empty_filter: + return self._config["filter"](entity_id) + + entity_configs = self._prefs.google_entity_configs + entity_config = entity_configs.get(entity_id, {}) + return entity_config.get(PREF_SHOULD_EXPOSE, DEFAULT_SHOULD_EXPOSE) + + @property + def agent_user_id(self): + """Return Agent User Id to use for query responses.""" + return self._cloud.username + + def get_agent_user_id(self, context): + """Get agent user ID making request.""" + return self.agent_user_id + + def should_2fa(self, state): + """If an entity should be checked for 2FA.""" + entity_configs = self._prefs.google_entity_configs + entity_config = entity_configs.get(state.entity_id, {}) + return not entity_config.get(PREF_DISABLE_2FA, DEFAULT_DISABLE_2FA) + + async def async_report_state(self, message, agent_user_id: str): + """Send a state report to Google.""" + try: + await self._cloud.google_report_state.async_send_message(message) + except ErrorResponse as err: + _LOGGER.warning("Error reporting state - %s: %s", err.code, err.message) + + async def _async_request_sync_devices(self, agent_user_id: str): + """Trigger a sync with Google.""" + if self._sync_entities_lock.locked(): + return HTTP_OK + + async with self._sync_entities_lock: + resp = await cloud_api.async_google_actions_request_sync(self._cloud) + return resp.status + + async def _async_prefs_updated(self, prefs): + """Handle updated preferences.""" + if self.should_report_state != self.is_reporting_state: + if self.should_report_state: + self.async_enable_report_state() + else: + self.async_disable_report_state() + + # State reporting is reported as a property on entities. + # So when we change it, we need to sync all entities. + await self.async_sync_entities_all() + + # If entity prefs are the same or we have filter in config.yaml, + # don't sync. + elif ( + self._cur_entity_prefs is not prefs.google_entity_configs + and self._config["filter"].empty_filter + ): + self.async_schedule_google_sync_all() + + if self.enabled and not self.is_local_sdk_active: + self.async_enable_local_sdk() + elif not self.enabled and self.is_local_sdk_active: + self.async_disable_local_sdk() + + async def _handle_entity_registry_updated(self, event): + """Handle when entity registry updated.""" + if not self.enabled or not self._cloud.is_logged_in: + return + + # Only consider entity registry updates if info relevant for Google has changed + if event.data["action"] == "update" and not bool( + set(event.data["changes"]) & entity_registry.ENTITY_DESCRIBING_ATTRIBUTES + ): + return + + entity_id = event.data["entity_id"] + + if not self._should_expose_entity_id(entity_id): + return + + if self.hass.state == CoreState.running: + self.async_schedule_google_sync_all() + return + + if self._sync_on_started: + return + + self._sync_on_started = True + + @callback + async def sync_google(_): + """Sync entities to Google.""" + await self.async_sync_entities_all() + + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, sync_google) diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index bf9b78335274e..c3809f76b8c63 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -3,84 +3,103 @@ from functools import wraps import logging -import attr import aiohttp import async_timeout +import attr +from hass_nabucasa import Cloud, auth, thingtalk +from hass_nabucasa.const import STATE_DISCONNECTED 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 ( - const as google_const) +from homeassistant.components.alexa import ( + entities as alexa_entities, + errors as alexa_errors, +) +from homeassistant.components.google_assistant import helpers as google_helpers +from homeassistant.components.http import HomeAssistantView +from homeassistant.components.http.data_validator import RequestDataValidator +from homeassistant.components.websocket_api import const as ws_const +from homeassistant.const import HTTP_BAD_REQUEST, HTTP_INTERNAL_SERVER_ERROR, HTTP_OK +from homeassistant.core import callback from .const import ( - DOMAIN, REQUEST_TIMEOUT, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE, - PREF_GOOGLE_SECURE_DEVICES_PIN, InvalidTrustedNetworks) + DOMAIN, + PREF_ALEXA_REPORT_STATE, + PREF_ENABLE_ALEXA, + PREF_ENABLE_GOOGLE, + PREF_GOOGLE_REPORT_STATE, + PREF_GOOGLE_SECURE_DEVICES_PIN, + REQUEST_TIMEOUT, + InvalidTrustedNetworks, + InvalidTrustedProxies, + RequireRelink, +) _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_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_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_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 -}) +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.') + InvalidTrustedNetworks: ( + HTTP_INTERNAL_SERVER_ERROR, + "Remote UI not compatible with 127.0.0.1/::1 as a trusted network.", + ), + InvalidTrustedProxies: ( + HTTP_INTERNAL_SERVER_ERROR, + "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 + async_register_command = hass.components.websocket_api.async_register_command + async_register_command(WS_TYPE_STATUS, websocket_cloud_status, SCHEMA_WS_STATUS) + async_register_command( + WS_TYPE_SUBSCRIPTION, websocket_subscription, SCHEMA_WS_SUBSCRIPTION ) - hass.components.websocket_api.async_register_command( - WS_TYPE_SUBSCRIPTION, websocket_subscription, - SCHEMA_WS_SUBSCRIPTION + async_register_command(websocket_update_prefs) + async_register_command( + WS_TYPE_HOOK_CREATE, websocket_hook_create, SCHEMA_WS_HOOK_CREATE ) - 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 + async_register_command( + WS_TYPE_HOOK_DELETE, websocket_hook_delete, SCHEMA_WS_HOOK_DELETE ) - 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) + async_register_command(websocket_remote_connect) + async_register_command(websocket_remote_disconnect) + + async_register_command(google_assistant_list) + async_register_command(google_assistant_update) + + async_register_command(alexa_list) + async_register_command(alexa_update) + async_register_command(alexa_sync) + + async_register_command(thingtalk_convert) + hass.http.register_view(GoogleActionsSyncView) hass.http.register_view(CloudLoginView) hass.http.register_view(CloudLogoutView) @@ -88,28 +107,31 @@ async def async_setup(hass): 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'), - }) + _CLOUD_ERRORS.update( + { + auth.UserNotFound: (HTTP_BAD_REQUEST, "User does not exist."), + auth.UserNotConfirmed: (HTTP_BAD_REQUEST, "Email not confirmed."), + auth.UserExists: ( + HTTP_BAD_REQUEST, + "An account with the given email already exists.", + ), + auth.Unauthenticated: (401, "Authentication failed."), + auth.PasswordChangeRequired: ( + HTTP_BAD_REQUEST, + "Password change required.", + ), + asyncio.TimeoutError: (502, "Unable to reach the Home Assistant cloud."), + aiohttp.ClientError: ( + HTTP_INTERNAL_SERVER_ERROR, + "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.""" @@ -120,14 +142,15 @@ async def error_handler(view, request, *args, **kwargs): 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()) + 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.""" @@ -135,8 +158,8 @@ async def error_handler(hass, connection, msg): 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) + err_status, err_msg = _process_cloud_exception(err, msg["type"]) + connection.send_error(msg["id"], err_status, err_msg) return error_handler @@ -145,144 +168,125 @@ 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)) + _LOGGER.exception("Unexpected error processing request for %s", where) + err_info = (502, f"Unexpected error: {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' + 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, loop=hass.loop): - await hass.async_add_job(cloud.auth.check_token) - - with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): - req = await websession.post( - cloud.google_actions_sync_url, headers={ - 'authorization': cloud.id_token - }) - - return self.json({}, status_code=req.status) + hass = request.app["hass"] + cloud: Cloud = hass.data[DOMAIN] + gconf = await cloud.client.get_google_config() + status = await gconf.async_sync_entities(gconf.agent_user_id) + return self.json({}, status_code=status) class CloudLoginView(HomeAssistantView): """Login to Home Assistant cloud.""" - url = '/api/cloud/login' - name = 'api:cloud:login' + url = "/api/cloud/login" + name = "api:cloud:login" @_handle_cloud_errors - @RequestDataValidator(vol.Schema({ - vol.Required('email'): str, - vol.Required('password'): str, - })) + @RequestDataValidator( + vol.Schema({vol.Required("email"): str, vol.Required("password"): str}) + ) async def post(self, request, data): """Handle login request.""" - hass = request.app['hass'] + hass = request.app["hass"] cloud = hass.data[DOMAIN] - - with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): - await hass.async_add_job(cloud.auth.login, data['email'], - data['password']) - - hass.async_add_job(cloud.iot.connect) - return self.json({'success': True}) + await cloud.login(data["email"], data["password"]) + return self.json({"success": True}) class CloudLogoutView(HomeAssistantView): """Log out of the Home Assistant cloud.""" - url = '/api/cloud/logout' - name = 'api:cloud:logout' + url = "/api/cloud/logout" + name = "api:cloud:logout" @_handle_cloud_errors async def post(self, request): """Handle logout request.""" - hass = request.app['hass'] + hass = request.app["hass"] cloud = hass.data[DOMAIN] - with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + with async_timeout.timeout(REQUEST_TIMEOUT): await cloud.logout() - return self.json_message('ok') + return self.json_message("ok") class CloudRegisterView(HomeAssistantView): """Register on the Home Assistant cloud.""" - url = '/api/cloud/register' - name = 'api:cloud:register' + 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)), - })) + @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'] + hass = request.app["hass"] cloud = hass.data[DOMAIN] - with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): - await hass.async_add_job( - cloud.auth.register, data['email'], data['password']) + with async_timeout.timeout(REQUEST_TIMEOUT): + await cloud.auth.async_register(data["email"], data["password"]) - return self.json_message('ok') + return self.json_message("ok") class CloudResendConfirmView(HomeAssistantView): """Resend email confirmation code.""" - url = '/api/cloud/resend_confirm' - name = 'api:cloud:resend_confirm' + url = "/api/cloud/resend_confirm" + name = "api:cloud:resend_confirm" @_handle_cloud_errors - @RequestDataValidator(vol.Schema({ - vol.Required('email'): str, - })) + @RequestDataValidator(vol.Schema({vol.Required("email"): str})) async def post(self, request, data): """Handle resending confirm email code request.""" - hass = request.app['hass'] + hass = request.app["hass"] cloud = hass.data[DOMAIN] - with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): - await hass.async_add_job( - cloud.auth.resend_email_confirm, data['email']) + with async_timeout.timeout(REQUEST_TIMEOUT): + await cloud.auth.async_resend_email_confirm(data["email"]) - return self.json_message('ok') + return self.json_message("ok") class CloudForgotPasswordView(HomeAssistantView): """View to start Forgot Password flow..""" - url = '/api/cloud/forgot_password' - name = 'api:cloud:forgot_password' + url = "/api/cloud/forgot_password" + name = "api:cloud:forgot_password" @_handle_cloud_errors - @RequestDataValidator(vol.Schema({ - vol.Required('email'): str, - })) + @RequestDataValidator(vol.Schema({vol.Required("email"): str})) async def post(self, request, data): """Handle forgot password request.""" - hass = request.app['hass'] + hass = request.app["hass"] cloud = hass.data[DOMAIN] - with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): - await hass.async_add_job( - cloud.auth.forgot_password, data['email']) + with async_timeout.timeout(REQUEST_TIMEOUT): + await cloud.auth.async_forgot_password(data["email"]) - return self.json_message('ok') + return self.json_message("ok") @callback @@ -293,19 +297,23 @@ def websocket_cloud_status(hass, connection, msg): """ cloud = hass.data[DOMAIN] connection.send_message( - websocket_api.result_message(msg['id'], _account_data(cloud))) + 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.')) + 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) @@ -317,24 +325,26 @@ def with_cloud_auth(hass, connection, msg): @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, loop=hass.loop): + 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')) + if response.status != HTTP_OK: + 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) + if data.get("provider") and not cloud.is_connected: + _LOGGER.debug("Found disconnected account with valid subscriotion, connecting") + await cloud.auth.async_renew_access_token() # Cancel reconnect in progress if cloud.iot.state != STATE_DISCONNECTED: @@ -342,27 +352,51 @@ async def websocket_subscription(hass, connection, msg): hass.async_create_task(cloud.iot.connect()) - connection.send_message(websocket_api.result_message(msg['id'], data)) + 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), -}) +@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_ALEXA_REPORT_STATE): bool, + vol.Optional(PREF_GOOGLE_REPORT_STATE): 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') + changes.pop("id") + changes.pop("type") + + # If we turn alexa linking on, validate that we can fetch access token + if changes.get(PREF_ALEXA_REPORT_STATE): + try: + with async_timeout.timeout(10): + await cloud.client.alexa_config.async_get_access_token() + except asyncio.TimeoutError: + connection.send_error( + msg["id"], "alexa_timeout", "Timeout validating Alexa access token." + ) + return + except (alexa_errors.NoTokenAvailable, RequireRelink): + connection.send_error( + msg["id"], + "alexa_relink", + "Please go to the Alexa app and re-link the Home Assistant " + "skill and then try to enable state reporting.", + ) + return + await cloud.client.prefs.async_update(**changes) - connection.send_message(websocket_api.result_message(msg['id'])) + connection.send_message(websocket_api.result_message(msg["id"])) @_require_cloud_login @@ -371,8 +405,8 @@ async def websocket_update_prefs(hass, connection, msg): 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)) + hook = await cloud.cloudhooks.async_create(msg["webhook_id"], False) + connection.send_message(websocket_api.result_message(msg["id"], hook)) @_require_cloud_login @@ -381,19 +415,15 @@ async def websocket_hook_create(hass, connection, msg): 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'])) + 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, - } + return {"logged_in": False, "cloud": STATE_DISCONNECTED} claims = cloud.claims client = cloud.client @@ -406,17 +436,15 @@ def _account_data(cloud): 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, - 'google_domains': list(google_const.DOMAIN_TO_GOOGLE_TYPES), - '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, + "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_user_config["filter"].config, + "remote_domain": remote.instance_domain, + "remote_connected": remote.is_connected, + "remote_certificate": certificate, } @@ -424,27 +452,165 @@ def _account_data(cloud): @_require_cloud_login @websocket_api.async_response @_ws_handle_cloud_errors -@websocket_api.websocket_command({ - 'type': 'cloud/remote/connect' -}) +@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)) + 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' -}) +@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)) + 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] + gconf = await cloud.client.get_google_config() + entities = google_helpers.async_get_entities(hass, gconf) + + 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_traits(), + } + ) + + 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): + """Update google assistant config.""" + 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"]) + ) + + +@websocket_api.require_admin +@_require_cloud_login +@websocket_api.async_response +@_ws_handle_cloud_errors +@websocket_api.websocket_command({"type": "cloud/alexa/entities"}) +async def alexa_list(hass, connection, msg): + """List all alexa entities.""" + cloud = hass.data[DOMAIN] + entities = alexa_entities.async_get_entities(hass, cloud.client.alexa_config) + + result = [] + + for entity in entities: + result.append( + { + "entity_id": entity.entity_id, + "display_categories": entity.default_display_categories(), + "interfaces": [ifc.name() for ifc in entity.interfaces()], + } + ) + + 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/alexa/entities/update", + "entity_id": str, + vol.Optional("should_expose"): bool, + } +) +async def alexa_update(hass, connection, msg): + """Update alexa entity config.""" + cloud = hass.data[DOMAIN] + changes = dict(msg) + changes.pop("type") + changes.pop("id") + + await cloud.client.prefs.async_update_alexa_entity_config(**changes) + + connection.send_result( + msg["id"], cloud.client.prefs.alexa_entity_configs.get(msg["entity_id"]) + ) + + +@websocket_api.require_admin +@_require_cloud_login +@websocket_api.async_response +@websocket_api.websocket_command({"type": "cloud/alexa/sync"}) +async def alexa_sync(hass, connection, msg): + """Sync with Alexa.""" + cloud = hass.data[DOMAIN] + + with async_timeout.timeout(10): + try: + success = await cloud.client.alexa_config.async_sync_entities() + except alexa_errors.NoTokenAvailable: + connection.send_error( + msg["id"], + "alexa_relink", + "Please go to the Alexa app and re-link the Home Assistant skill.", + ) + return + + if success: + connection.send_result(msg["id"]) + else: + connection.send_error(msg["id"], ws_const.ERR_UNKNOWN_ERROR, "Unknown error") + + +@websocket_api.async_response +@websocket_api.websocket_command({"type": "cloud/thingtalk/convert", "query": str}) +async def thingtalk_convert(hass, connection, msg): + """Convert a query.""" + cloud = hass.data[DOMAIN] + + with async_timeout.timeout(10): + try: + connection.send_result( + msg["id"], await thingtalk.async_convert(cloud, msg["query"]) + ) + except thingtalk.ThingTalkConversionError as err: + connection.send_error(msg["id"], ws_const.ERR_UNKNOWN_ERROR, str(err)) diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 863e3e86da413..de5496cfd99a1 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -1,15 +1,9 @@ { "domain": "cloud", - "name": "Cloud", - "documentation": "https://www.home-assistant.io/components/cloud", - "requirements": [ - "hass-nabucasa==0.12" - ], - "dependencies": [ - "http", - "webhook" - ], - "codeowners": [ - "@home-assistant/core" - ] + "name": "Home Assistant Cloud", + "documentation": "https://www.home-assistant.io/integrations/cloud", + "requirements": ["hass-nabucasa==0.34.2"], + "dependencies": ["http", "webhook", "alexa"], + "after_dependencies": ["google_assistant"], + "codeowners": ["@home-assistant/cloud"] } diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py index 0e2abae15b0b7..a7d1b59fd3988 100644 --- a/homeassistant/components/cloud/prefs.py +++ b/homeassistant/components/cloud/prefs.py @@ -1,10 +1,35 @@ """Preference management for cloud.""" from ipaddress import ip_address +from typing import Optional + +from homeassistant.auth.const import GROUP_ID_ADMIN +from homeassistant.auth.models import User +from homeassistant.core import callback +from homeassistant.util.logging import async_create_catching_coro from .const import ( - DOMAIN, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE, PREF_ENABLE_REMOTE, - PREF_GOOGLE_SECURE_DEVICES_PIN, PREF_CLOUDHOOKS, PREF_CLOUD_USER, - InvalidTrustedNetworks) + DEFAULT_ALEXA_REPORT_STATE, + DEFAULT_GOOGLE_REPORT_STATE, + DOMAIN, + PREF_ALEXA_ENTITY_CONFIGS, + PREF_ALEXA_REPORT_STATE, + PREF_ALIASES, + PREF_CLOUD_USER, + PREF_CLOUDHOOKS, + PREF_DISABLE_2FA, + PREF_ENABLE_ALEXA, + PREF_ENABLE_GOOGLE, + PREF_ENABLE_REMOTE, + PREF_GOOGLE_ENTITY_CONFIGS, + PREF_GOOGLE_LOCAL_WEBHOOK_ID, + PREF_GOOGLE_REPORT_STATE, + PREF_GOOGLE_SECURE_DEVICES_PIN, + PREF_OVERRIDE_NAME, + PREF_SHOULD_EXPOSE, + PREF_USERNAME, + InvalidTrustedNetworks, + InvalidTrustedProxies, +) STORAGE_KEY = DOMAIN STORAGE_VERSION = 1 @@ -19,47 +44,157 @@ def __init__(self, hass): self._hass = hass self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) self._prefs = None + self._listeners = [] 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_CLOUDHOOKS: {}, - PREF_CLOUD_USER: None, - } + prefs = self._empty_config("") 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): + if PREF_GOOGLE_LOCAL_WEBHOOK_ID not in self._prefs: + await self._save_prefs( + { + **self._prefs, + PREF_GOOGLE_LOCAL_WEBHOOK_ID: self._hass.components.webhook.async_generate_id(), + } + ) + + @callback + def async_listen_updates(self, listener): + """Listen for updates to the preferences.""" + self._listeners.append(listener) + + 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, + alexa_entity_configs=_UNDEF, + alexa_report_state=_UNDEF, + google_report_state=_UNDEF, + ): """Update user preferences.""" + prefs = {**self._prefs} + 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_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), + (PREF_ALEXA_ENTITY_CONFIGS, alexa_entity_configs), + (PREF_ALEXA_REPORT_STATE, alexa_report_state), + (PREF_GOOGLE_REPORT_STATE, google_report_state), ): if value is not _UNDEF: - self._prefs[key] = value + prefs[key] = value if remote_enabled is True and self._has_local_trusted_network: + prefs[PREF_ENABLE_REMOTE] = False raise InvalidTrustedNetworks - await self._store.async_save(self._prefs) + if remote_enabled is True and self._has_local_trusted_proxies: + prefs[PREF_ENABLE_REMOTE] = False + raise InvalidTrustedProxies + + await self._save_prefs(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) + + async def async_update_alexa_entity_config( + self, *, entity_id, should_expose=_UNDEF + ): + """Update config for an Alexa entity.""" + entities = self.alexa_entity_configs + entity = entities.get(entity_id, {}) + + changes = {} + for key, value in ((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(alexa_entity_configs=updated_entities) + + async def async_set_username(self, username): + """Set the username that is logged in.""" + # Logging out. + if username is None: + user = await self._load_cloud_user() + + if user is not None: + await self._hass.auth.async_remove_user(user) + await self._save_prefs({**self._prefs, PREF_CLOUD_USER: None}) + return + + cur_username = self._prefs.get(PREF_USERNAME) + + if cur_username == username: + return + + if cur_username is None: + await self._save_prefs({**self._prefs, PREF_USERNAME: username}) + else: + await self._save_prefs(self._empty_config(username)) def as_dict(self): """Return dictionary version.""" - return self._prefs + 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_ALEXA_ENTITY_CONFIGS: self.alexa_entity_configs, + PREF_ALEXA_REPORT_STATE: self.alexa_report_state, + PREF_GOOGLE_REPORT_STATE: self.google_report_state, + PREF_CLOUDHOOKS: self.cloudhooks, + } @property def remote_enabled(self): @@ -69,7 +204,7 @@ def remote_enabled(self): if not enabled: return False - if self._has_local_trusted_network: + if self._has_local_trusted_network or self._has_local_trusted_proxies: return False return True @@ -79,34 +214,78 @@ def alexa_enabled(self): """Return if Alexa is enabled.""" return self._prefs[PREF_ENABLE_ALEXA] + @property + def alexa_report_state(self): + """Return if Alexa report state is enabled.""" + return self._prefs.get(PREF_ALEXA_REPORT_STATE, DEFAULT_ALEXA_REPORT_STATE) + @property def google_enabled(self): """Return if Google is enabled.""" return self._prefs[PREF_ENABLE_GOOGLE] + @property + def google_report_state(self): + """Return if Google report state is enabled.""" + return self._prefs.get(PREF_GOOGLE_REPORT_STATE, DEFAULT_GOOGLE_REPORT_STATE) + @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 google_local_webhook_id(self): + """Return Google webhook ID to receive local messages.""" + return self._prefs[PREF_GOOGLE_LOCAL_WEBHOOK_ID] + + @property + def alexa_entity_configs(self): + """Return Alexa Entity configurations.""" + return self._prefs.get(PREF_ALEXA_ENTITY_CONFIGS, {}) + @property def cloudhooks(self): """Return the published cloud webhooks.""" return self._prefs.get(PREF_CLOUDHOOKS, {}) - @property - def cloud_user(self) -> str: + async def get_cloud_user(self) -> str: """Return ID from Home Assistant Cloud system user.""" - return self._prefs.get(PREF_CLOUD_USER) + user = await self._load_cloud_user() + + if user: + return user.id + + user = await self._hass.auth.async_create_system_user( + "Home Assistant Cloud", [GROUP_ID_ADMIN] + ) + await self.async_update(cloud_user=user.id) + return user.id + + async def _load_cloud_user(self) -> Optional[User]: + """Load cloud user if available.""" + user_id = self._prefs.get(PREF_CLOUD_USER) + + if user_id is None: + return None + + # Fetch the user. It can happen that the user no longer exists if + # an image was restored without restoring the cloud prefs. + return await self._hass.auth.async_get_user(user_id) @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') + local4 = ip_address("127.0.0.1") + local6 = ip_address("::1") for prv in self._hass.auth.auth_providers: - if prv.type != 'trusted_networks': + if prv.type != "trusted_networks": continue for network in prv.trusted_networks: @@ -114,3 +293,43 @@ def _has_local_trusted_network(self) -> bool: 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 + + async def _save_prefs(self, prefs): + """Save preferences to disk.""" + self._prefs = prefs + await self._store.async_save(self._prefs) + + for listener in self._listeners: + self._hass.async_create_task(async_create_catching_coro(listener(self))) + + @callback + def _empty_config(self, username): + """Return an empty config.""" + return { + PREF_ENABLE_ALEXA: True, + PREF_ENABLE_GOOGLE: True, + PREF_ENABLE_REMOTE: False, + PREF_GOOGLE_SECURE_DEVICES_PIN: None, + PREF_GOOGLE_ENTITY_CONFIGS: {}, + PREF_ALEXA_ENTITY_CONFIGS: {}, + PREF_CLOUDHOOKS: {}, + PREF_CLOUD_USER: None, + PREF_USERNAME: username, + PREF_GOOGLE_LOCAL_WEBHOOK_ID: self._hass.components.webhook.async_generate_id(), + } diff --git a/homeassistant/components/cloud/stt.py b/homeassistant/components/cloud/stt.py new file mode 100644 index 0000000000000..6c069ce16d777 --- /dev/null +++ b/homeassistant/components/cloud/stt.py @@ -0,0 +1,106 @@ +"""Support for the cloud for speech to text service.""" +from typing import List + +from aiohttp import StreamReader +from hass_nabucasa import Cloud +from hass_nabucasa.voice import VoiceError + +from homeassistant.components.stt import Provider, SpeechMetadata, SpeechResult +from homeassistant.components.stt.const import ( + AudioBitRates, + AudioChannels, + AudioCodecs, + AudioFormats, + AudioSampleRates, + SpeechResultState, +) + +from .const import DOMAIN + +SUPPORT_LANGUAGES = [ + "da-DK", + "de-DE", + "en-AU", + "en-CA", + "en-GB", + "en-US", + "es-ES", + "fi-FI", + "fr-CA", + "fr-FR", + "it-IT", + "ja-JP", + "nl-NL", + "pl-PL", + "pt-PT", + "ru-RU", + "sv-SE", + "th-TH", + "zh-CN", + "zh-HK", +] + + +async def async_get_engine(hass, config, discovery_info=None): + """Set up Cloud speech component.""" + cloud: Cloud = hass.data[DOMAIN] + + return CloudProvider(cloud) + + +class CloudProvider(Provider): + """NabuCasa speech API provider.""" + + def __init__(self, cloud: Cloud) -> None: + """Home Assistant NabuCasa Speech to text.""" + self.cloud = cloud + + @property + def supported_languages(self) -> List[str]: + """Return a list of supported languages.""" + return SUPPORT_LANGUAGES + + @property + def supported_formats(self) -> List[AudioFormats]: + """Return a list of supported formats.""" + return [AudioFormats.WAV, AudioFormats.OGG] + + @property + def supported_codecs(self) -> List[AudioCodecs]: + """Return a list of supported codecs.""" + return [AudioCodecs.PCM, AudioCodecs.OPUS] + + @property + def supported_bit_rates(self) -> List[AudioBitRates]: + """Return a list of supported bitrates.""" + return [AudioBitRates.BITRATE_16] + + @property + def supported_sample_rates(self) -> List[AudioSampleRates]: + """Return a list of supported samplerates.""" + return [AudioSampleRates.SAMPLERATE_16000] + + @property + def supported_channels(self) -> List[AudioChannels]: + """Return a list of supported channels.""" + return [AudioChannels.CHANNEL_MONO] + + async def async_process_audio_stream( + self, metadata: SpeechMetadata, stream: StreamReader + ) -> SpeechResult: + """Process an audio stream to STT service.""" + content = f"audio/{metadata.format!s}; codecs=audio/{metadata.codec!s}; samplerate=16000" + + # Process STT + try: + result = await self.cloud.voice.process_stt( + stream, content, metadata.language + ) + except VoiceError: + return SpeechResult(None, SpeechResultState.ERROR) + + # Return Speech as Text + return SpeechResult( + result.text, + SpeechResultState.SUCCESS if result.success else SpeechResultState.ERROR, + ) diff --git a/homeassistant/components/cloud/tts.py b/homeassistant/components/cloud/tts.py new file mode 100644 index 0000000000000..ea769c6a054df --- /dev/null +++ b/homeassistant/components/cloud/tts.py @@ -0,0 +1,81 @@ +"""Support for the cloud for text to speech service.""" + +from hass_nabucasa import Cloud +from hass_nabucasa.voice import VoiceError +import voluptuous as vol + +from homeassistant.components.tts import CONF_LANG, PLATFORM_SCHEMA, Provider + +from .const import DOMAIN + +CONF_GENDER = "gender" + +SUPPORT_LANGUAGES = ["en-US", "de-DE", "es-ES"] +SUPPORT_GENDER = ["male", "female"] + +DEFAULT_LANG = "en-US" +DEFAULT_GENDER = "female" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_LANG, default=DEFAULT_LANG): vol.In(SUPPORT_LANGUAGES), + vol.Optional(CONF_GENDER, default=DEFAULT_GENDER): vol.In(SUPPORT_GENDER), + } +) + + +async def async_get_engine(hass, config, discovery_info=None): + """Set up Cloud speech component.""" + cloud: Cloud = hass.data[DOMAIN] + + if discovery_info is not None: + language = DEFAULT_LANG + gender = DEFAULT_GENDER + else: + language = config[CONF_LANG] + gender = config[CONF_GENDER] + + return CloudProvider(cloud, language, gender) + + +class CloudProvider(Provider): + """NabuCasa Cloud speech API provider.""" + + def __init__(self, cloud: Cloud, language: str, gender: str): + """Initialize cloud provider.""" + self.cloud = cloud + self.name = "Cloud" + self._language = language + self._gender = gender + + @property + def default_language(self): + """Return the default language.""" + return self._language + + @property + def supported_languages(self): + """Return list of supported languages.""" + return SUPPORT_LANGUAGES + + @property + def supported_options(self): + """Return list of supported options like voice, emotion.""" + return [CONF_GENDER] + + @property + def default_options(self): + """Return a dict include default options.""" + return {CONF_GENDER: self._gender} + + async def async_get_tts_audio(self, message, language, options=None): + """Load TTS from NabuCasa Cloud.""" + # Process TTS + try: + data = await self.cloud.voice.process_tts( + message, language, gender=options[CONF_GENDER] + ) + except VoiceError: + return (None, None) + + return ("mp3", data) diff --git a/homeassistant/components/cloud/utils.py b/homeassistant/components/cloud/utils.py index da1d3809989e7..36599b42ad336 100644 --- a/homeassistant/components/cloud/utils.py +++ b/homeassistant/components/cloud/utils.py @@ -1,13 +1,21 @@ """Helper functions for cloud components.""" from typing import Any, Dict -from aiohttp import web +from aiohttp import payload, web def aiohttp_serialize_response(response: web.Response) -> Dict[str, Any]: """Serialize an aiohttp response to a dictionary.""" - return { - 'status': response.status, - 'body': response.text, - 'headers': dict(response.headers), - } + 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 index ce88f820fe372..265621b625068 100644 --- a/homeassistant/components/cloudflare/__init__.py +++ b/homeassistant/components/cloudflare/__init__.py @@ -2,6 +2,7 @@ from datetime import timedelta import logging +from pycfdns import CloudflareUpdater import voluptuous as vol from homeassistant.const import CONF_API_KEY, CONF_EMAIL, CONF_ZONE @@ -10,25 +11,29 @@ _LOGGER = logging.getLogger(__name__) -CONF_RECORDS = 'records' +CONF_RECORDS = "records" -DOMAIN = 'cloudflare' +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) +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] @@ -45,8 +50,7 @@ def update_records_service(now): _update_cloudflare(cfupdate, email, key, zone, records) track_time_interval(hass, update_records_interval, INTERVAL) - hass.services.register( - DOMAIN, 'update_records', update_records_service) + hass.services.register(DOMAIN, "update_records", update_records_service) return True diff --git a/homeassistant/components/cloudflare/manifest.json b/homeassistant/components/cloudflare/manifest.json index 7716ae65c4e1a..d22d526d01c49 100644 --- a/homeassistant/components/cloudflare/manifest.json +++ b/homeassistant/components/cloudflare/manifest.json @@ -1,12 +1,7 @@ { "domain": "cloudflare", "name": "Cloudflare", - "documentation": "https://www.home-assistant.io/components/cloudflare", - "requirements": [ - "pycfdns==0.0.1" - ], - "dependencies": [], - "codeowners": [ - "@ludeeus" - ] + "documentation": "https://www.home-assistant.io/integrations/cloudflare", + "requirements": ["pycfdns==0.0.1"], + "codeowners": ["@ludeeus"] } diff --git a/homeassistant/components/cloudflare/services.yaml b/homeassistant/components/cloudflare/services.yaml index e69de29bb2d1d..23ffdd14d5f03 100644 --- a/homeassistant/components/cloudflare/services.yaml +++ b/homeassistant/components/cloudflare/services.yaml @@ -0,0 +1,2 @@ +update_records: + description: Manually trigger update to Cloudflare records. diff --git a/homeassistant/components/cmus/manifest.json b/homeassistant/components/cmus/manifest.json index 1528f4252b109..5a062996ab97a 100644 --- a/homeassistant/components/cmus/manifest.json +++ b/homeassistant/components/cmus/manifest.json @@ -1,10 +1,7 @@ { "domain": "cmus", - "name": "Cmus", - "documentation": "https://www.home-assistant.io/components/cmus", - "requirements": [ - "pycmus==0.1.1" - ], - "dependencies": [], + "name": "cmus", + "documentation": "https://www.home-assistant.io/integrations/cmus", + "requirements": ["pycmus==0.1.1"], "codeowners": [] } diff --git a/homeassistant/components/cmus/media_player.py b/homeassistant/components/cmus/media_player.py index 4f1dfc5053642..73a55fda8e3e8 100644 --- a/homeassistant/components/cmus/media_player.py +++ b/homeassistant/components/cmus/media_player.py @@ -1,44 +1,68 @@ """Support for interacting with and controlling the cmus music player.""" import logging +from pycmus import exceptions, remote import voluptuous as vol -from homeassistant.components.media_player import ( - MediaPlayerDevice, PLATFORM_SCHEMA) +from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerEntity 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) + 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) + 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_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, -}) +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) + port = config[CONF_PORT] + name = config[CONF_NAME] try: cmus_remote = CmusDevice(host, password, port, name) @@ -48,21 +72,19 @@ def setup_platform(hass, config, add_entities, discover_info=None): add_entities([cmus_remote], True) -class CmusDevice(MediaPlayerDevice): +class CmusDevice(MediaPlayerEntity): """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) + self.cmus = remote.PyCmus(server=server, password=password, port=port) + auto_name = f"cmus-{server}" else: self.cmus = remote.PyCmus() - auto_name = 'cmus-local' + auto_name = "cmus-local" self._name = name or auto_name self.status = {} @@ -82,16 +104,16 @@ def name(self): @property def state(self): """Return the media state.""" - if self.status.get('status') == 'playing': + if self.status.get("status") == "playing": return STATE_PLAYING - if self.status.get('status') == 'paused': + 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') + return self.status.get("file") @property def content_type(self): @@ -101,43 +123,43 @@ def content_type(self): @property def media_duration(self): """Duration of current playing media in seconds.""" - return self.status.get('duration') + return self.status.get("duration") @property def media_title(self): """Title of current playing media.""" - return self.status['tag'].get('title') + 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') + 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') + 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') + 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') + 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] + 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 + return int(volume) / 100 @property def supported_features(self): @@ -158,8 +180,8 @@ def set_volume_level(self, volume): def volume_up(self): """Set the volume up.""" - left = self.status['set'].get('vol_left') - right = self.status['set'].get('vol_right') + left = self.status["set"].get("vol_left") + right = self.status["set"].get("vol_right") if left != right: current_volume = float(left + right) / 2 else: @@ -170,8 +192,8 @@ def volume_up(self): def volume_down(self): """Set the volume down.""" - left = self.status['set'].get('vol_left') - right = self.status['set'].get('vol_right') + left = self.status["set"].get("vol_left") + right = self.status["set"].get("vol_right") if left != right: current_volume = float(left + right) / 2 else: @@ -187,7 +209,10 @@ def play_media(self, media_type, media_id, **kwargs): else: _LOGGER.error( "Invalid media type %s. Only %s and %s are supported", - media_type, MEDIA_TYPE_MUSIC, MEDIA_TYPE_PLAYLIST) + media_type, + MEDIA_TYPE_MUSIC, + MEDIA_TYPE_PLAYLIST, + ) def media_pause(self): """Send the pause command.""" diff --git a/homeassistant/components/co2signal/manifest.json b/homeassistant/components/co2signal/manifest.json index ac42e374fdd23..9b7aa80e2ccd7 100644 --- a/homeassistant/components/co2signal/manifest.json +++ b/homeassistant/components/co2signal/manifest.json @@ -1,10 +1,7 @@ { "domain": "co2signal", - "name": "Co2signal", - "documentation": "https://www.home-assistant.io/components/co2signal", - "requirements": [ - "co2signal==0.4.2" - ], - "dependencies": [], + "name": "CO2 Signal", + "documentation": "https://www.home-assistant.io/integrations/co2signal", + "requirements": ["co2signal==0.4.2"], "codeowners": [] } diff --git a/homeassistant/components/co2signal/sensor.py b/homeassistant/components/co2signal/sensor.py index 990521d041854..d7f78f9c362ff 100644 --- a/homeassistant/components/co2signal/sensor.py +++ b/homeassistant/components/co2signal/sensor.py @@ -1,30 +1,40 @@ """Support for the CO2signal platform.""" import logging +import CO2Signal 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.const import ( + ATTR_ATTRIBUTION, + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_TOKEN, + ENERGY_KILO_WATT_HOUR, +) +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity CONF_COUNTRY_CODE = "country_code" _LOGGER = logging.getLogger(__name__) -ATTRIBUTION = 'Data provided by CO2signal' +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, -}) +MSG_LOCATION = ( + "Please use either coordinates or the country code. " + "For the coordinates, " + "you need to use both latitude and longitude." +) +CO2_INTENSITY_UNIT = f"CO2eq/{ENERGY_KILO_WATT_HOUR}" +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): @@ -38,10 +48,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): devs = [] - devs.append(CO2Sensor(token, - country_code, - lat, - lon)) + devs.append(CO2Sensor(token, country_code, lat, lon)) add_entities(devs, True) @@ -59,11 +66,9 @@ def __init__(self, token, country_code, lat, lon): 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)) + device_name = f"{round(self._latitude, 2)}/{round(self._longitude, 2)}" - self._friendly_name = 'CO2 intensity - {}'.format(device_name) + self._friendly_name = f"CO2 intensity - {device_name}" @property def name(self): @@ -73,7 +78,7 @@ def name(self): @property def icon(self): """Icon to use in the frontend, if any.""" - return 'mdi:periodic-table-co2' + return "mdi:periodic-table-co2" @property def state(self): @@ -92,16 +97,16 @@ def device_state_attributes(self): 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) + self._token, country_code=self._country_code + ) else: self._data = CO2Signal.get_latest_carbon_intensity( - self._token, - latitude=self._latitude, longitude=self._longitude) + 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 index 21efd5f9b8ecc..9fd99e993b600 100644 --- a/homeassistant/components/coinbase/__init__.py +++ b/homeassistant/components/coinbase/__init__.py @@ -2,6 +2,8 @@ from datetime import timedelta import logging +from coinbase.wallet.client import Client +from coinbase.wallet.error import AuthenticationError import voluptuous as vol from homeassistant.const import CONF_API_KEY @@ -11,26 +13,33 @@ _LOGGER = logging.getLogger(__name__) -DOMAIN = 'coinbase' +DOMAIN = "coinbase" -CONF_API_SECRET = 'api_secret' -CONF_ACCOUNT_CURRENCIES = 'account_balance_currencies' -CONF_EXCHANGE_CURRENCIES = 'exchange_rate_currencies' +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) +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): @@ -39,35 +48,30 @@ def setup(hass, config): 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) + api_key = config[DOMAIN][CONF_API_KEY] + api_secret = config[DOMAIN][CONF_API_SECRET] account_currencies = config[DOMAIN].get(CONF_ACCOUNT_CURRENCIES) - exchange_currencies = config[DOMAIN].get(CONF_EXCHANGE_CURRENCIES) + exchange_currencies = config[DOMAIN][CONF_EXCHANGE_CURRENCIES] - hass.data[DATA_COINBASE] = coinbase_data = CoinbaseData( - api_key, api_secret) + hass.data[DATA_COINBASE] = coinbase_data = CoinbaseData(api_key, api_secret) - if not hasattr(coinbase_data, 'accounts'): + 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) + 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) + load_platform( + hass, + "sensor", + DOMAIN, + {"native_currency": native, "exchange_currency": currency}, + config, + ) return True @@ -77,17 +81,18 @@ class CoinbaseData: 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) + _LOGGER.error( + "Authentication error connecting to coinbase: %s", coinbase_error + ) diff --git a/homeassistant/components/coinbase/manifest.json b/homeassistant/components/coinbase/manifest.json index 5f8a189c7d129..8d134792bbd0f 100644 --- a/homeassistant/components/coinbase/manifest.json +++ b/homeassistant/components/coinbase/manifest.json @@ -1,10 +1,7 @@ { "domain": "coinbase", "name": "Coinbase", - "documentation": "https://www.home-assistant.io/components/coinbase", - "requirements": [ - "coinbase==2.1.0" - ], - "dependencies": [], + "documentation": "https://www.home-assistant.io/integrations/coinbase", + "requirements": ["coinbase==2.1.0"], "codeowners": [] } diff --git a/homeassistant/components/coinbase/sensor.py b/homeassistant/components/coinbase/sensor.py index 9470999efbb93..973c3d391591b 100644 --- a/homeassistant/components/coinbase/sensor.py +++ b/homeassistant/components/coinbase/sensor.py @@ -5,33 +5,35 @@ 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' + "BTC": "mdi:currency-btc", + "ETH": "mdi:currency-eth", + "EUR": "mdi:currency-eur", + "LTC": "mdi:litecoin", + "USD": "mdi:currency-usd", } -DEFAULT_COIN_ICON = 'mdi:coin' +DEFAULT_COIN_ICON = "mdi:coin" ATTRIBUTION = "Data provided by coinbase.com" -DATA_COINBASE = 'coinbase_cache' +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'] + 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: + 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']) + hass.data[DATA_COINBASE], + discovery_info["exchange_currency"], + discovery_info["native_currency"], + ) add_entities([sensor], True) @@ -42,7 +44,7 @@ class AccountSensor(Entity): def __init__(self, coinbase_data, name, currency): """Initialize the sensor.""" self._coinbase_data = coinbase_data - self._name = "Coinbase {}".format(name) + self._name = f"Coinbase {name}" self._state = None self._unit_of_measurement = currency self._native_balance = None @@ -73,18 +75,17 @@ 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), + ATTR_NATIVE_BALANCE: f"{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'] + for account in self._coinbase_data.accounts["data"]: + if self._name == f"Coinbase {account['name']}": + self._state = account["balance"]["amount"] + self._native_balance = account["native_balance"]["amount"] + self._native_currency = account["native_balance"]["currency"] class ExchangeRateSensor(Entity): @@ -94,7 +95,7 @@ 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._name = f"{exchange_currency} Exchange Rate" self._state = None self._unit_of_measurement = native_currency @@ -121,9 +122,7 @@ def icon(self): @property def device_state_attributes(self): """Return the state attributes of the sensor.""" - return { - ATTR_ATTRIBUTION: ATTRIBUTION - } + return {ATTR_ATTRIBUTION: ATTRIBUTION} def update(self): """Get the latest state of the sensor.""" diff --git a/homeassistant/components/coinmarketcap/manifest.json b/homeassistant/components/coinmarketcap/manifest.json index 0afb1b1c28f31..e3f827f2718ba 100644 --- a/homeassistant/components/coinmarketcap/manifest.json +++ b/homeassistant/components/coinmarketcap/manifest.json @@ -1,10 +1,7 @@ { "domain": "coinmarketcap", - "name": "Coinmarketcap", - "documentation": "https://www.home-assistant.io/components/coinmarketcap", - "requirements": [ - "coinmarketcap==5.0.3" - ], - "dependencies": [], + "name": "CoinMarketCap", + "documentation": "https://www.home-assistant.io/integrations/coinmarketcap", + "requirements": ["coinmarketcap==5.0.3"], "codeowners": [] } diff --git a/homeassistant/components/coinmarketcap/sensor.py b/homeassistant/components/coinmarketcap/sensor.py index 4d8af5a721d1f..2ae3de49817b2 100644 --- a/homeassistant/components/coinmarketcap/sensor.py +++ b/homeassistant/components/coinmarketcap/sensor.py @@ -1,73 +1,85 @@ """Details about crypto currencies from CoinMarketCap.""" -import logging from datetime import timedelta +import logging from urllib.error import HTTPError +from coinmarketcap import Market 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.const import ATTR_ATTRIBUTION, CONF_DISPLAY_CURRENCY +import homeassistant.helpers.config_validation as cv 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' +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' +CONF_CURRENCY_ID = "currency_id" +CONF_DISPLAY_CURRENCY_DECIMALS = "display_currency_decimals" DEFAULT_CURRENCY_ID = 1 -DEFAULT_DISPLAY_CURRENCY = 'USD' +DEFAULT_DISPLAY_CURRENCY = "USD" DEFAULT_DISPLAY_CURRENCY_DECIMALS = 2 -ICON = 'mdi:currency-usd' +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)), -}) +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): vol.All( + cv.string, vol.Upper + ), + 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) + currency_id = config[CONF_CURRENCY_ID] + display_currency = config[CONF_DISPLAY_CURRENCY] + display_currency_decimals = config[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) + _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) + add_entities( + [ + CoinMarketCapSensor( + CoinMarketCapData(currency_id, display_currency), + display_currency_decimals, + ) + ], + True, + ) class CoinMarketCapSensor(Entity): @@ -83,14 +95,17 @@ def __init__(self, data, display_currency_decimals): @property def name(self): """Return the name of the sensor.""" - return self._ticker.get('name') + 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) + return round( + float( + self._ticker.get("quotes").get(self.data.display_currency).get("price") + ), + self.display_currency_decimals, + ) @property def unit_of_measurement(self): @@ -106,32 +121,32 @@ def icon(self): 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_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'), + 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') + self._ticker = self.data.ticker.get("data") class CoinMarketCapData: @@ -145,6 +160,5 @@ def __init__(self, currency_id, display_currency): 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) + + self.ticker = Market().ticker(self.currency_id, convert=self.display_currency) diff --git a/homeassistant/components/comed_hourly_pricing/manifest.json b/homeassistant/components/comed_hourly_pricing/manifest.json index 47c7931a0e93d..e0d2b2bd3b44f 100644 --- a/homeassistant/components/comed_hourly_pricing/manifest.json +++ b/homeassistant/components/comed_hourly_pricing/manifest.json @@ -1,8 +1,6 @@ { "domain": "comed_hourly_pricing", - "name": "Comed hourly pricing", - "documentation": "https://www.home-assistant.io/components/comed_hourly_pricing", - "requirements": [], - "dependencies": [], + "name": "ComEd Hourly Pricing", + "documentation": "https://www.home-assistant.io/integrations/comed_hourly_pricing", "codeowners": [] } diff --git a/homeassistant/components/comed_hourly_pricing/sensor.py b/homeassistant/components/comed_hourly_pricing/sensor.py index 384aadd8bf472..90830d5223672 100644 --- a/homeassistant/components/comed_hourly_pricing/sensor.py +++ b/homeassistant/components/comed_hourly_pricing/sensor.py @@ -9,52 +9,58 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import ( - ATTR_ATTRIBUTION, CONF_NAME, CONF_OFFSET) +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' +_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' +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'], + 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), -}) +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], -}) +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): +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))) + dev.append( + ComedHourlyPricingSensor( + hass.loop, + websession, + variable[CONF_SENSOR_TYPE], + variable[CONF_OFFSET], + variable.get(CONF_NAME), + ) + ) async_add_entities(dev, True) @@ -98,21 +104,19 @@ def device_state_attributes(self): 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: + 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' + url_string += "?type=5minutefeed" else: - url_string += '?type=currenthouraverage' + url_string += "?type=currenthouraverage" - with async_timeout.timeout(60, loop=self.loop): + 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) + self._state = round(float(data[0]["price"]) + self.offset, 2) else: self._state = None diff --git a/homeassistant/components/comfoconnect/__init__.py b/homeassistant/components/comfoconnect/__init__.py index 3c50f3fb72388..2a13283738828 100644 --- a/homeassistant/components/comfoconnect/__init__.py +++ b/homeassistant/components/comfoconnect/__init__.py @@ -1,58 +1,62 @@ """Support to control a Zehnder ComfoAir Q350/450/600 ventilation unit.""" import logging +from pycomfoconnect import Bridge, ComfoConnect import voluptuous as vol from homeassistant.const import ( - CONF_HOST, CONF_NAME, CONF_PIN, CONF_TOKEN, EVENT_HOMEASSISTANT_STOP) + 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' +DOMAIN = "comfoconnect" -SIGNAL_COMFOCONNECT_UPDATE_RECEIVED = 'comfoconnect_update_received' +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" -CONF_USER_AGENT = 'user_agent' - -DEFAULT_NAME = 'ComfoAirQ' +DEFAULT_NAME = "ComfoAirQ" DEFAULT_PIN = 0 -DEFAULT_TOKEN = '00000000000000000000000000000001' -DEFAULT_USER_AGENT = 'Home Assistant' +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) +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) + host = conf[CONF_HOST] + name = conf[CONF_NAME] + token = conf[CONF_TOKEN] + user_agent = conf[CONF_USER_AGENT] + pin = conf[CONF_PIN] # Run discovery on the configured ip bridges = Bridge.discover(host) @@ -76,7 +80,7 @@ def _shutdown(_event): hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown) # Load platforms - discovery.load_platform(hass, 'fan', DOMAIN, {}, config) + discovery.load_platform(hass, "fan", DOMAIN, {}, config) return True @@ -86,15 +90,17 @@ class ComfoConnectBridge: 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.unique_id = bridge.uuid.hex() self.comfoconnect = ComfoConnect( - bridge=bridge, local_uuid=bytes.fromhex(token), - local_devicename=friendly_name, pin=pin) + bridge=bridge, + local_uuid=bytes.fromhex(token), + local_devicename=friendly_name, + pin=pin, + ) self.comfoconnect.callback_sensor = self.sensor_callback def connect(self): @@ -108,20 +114,8 @@ def disconnect(self): 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) + """Notify listeners that we have received an update.""" + _LOGGER.debug("Received update for %s: %s", var, value) + dispatcher_send( + self.hass, SIGNAL_COMFOCONNECT_UPDATE_RECEIVED.format(var), value + ) diff --git a/homeassistant/components/comfoconnect/fan.py b/homeassistant/components/comfoconnect/fan.py index 56175f0bca096..b5eac4f9afea2 100644 --- a/homeassistant/components/comfoconnect/fan.py +++ b/homeassistant/components/comfoconnect/fan.py @@ -1,51 +1,77 @@ """Platform to control a Zehnder ComfoAir Q350/450/600 ventilation unit.""" import logging +from pycomfoconnect import ( + CMD_FAN_MODE_AWAY, + CMD_FAN_MODE_HIGH, + CMD_FAN_MODE_LOW, + CMD_FAN_MODE_MEDIUM, + SENSOR_FAN_SPEED_MODE, +) + from homeassistant.components.fan import ( - SPEED_HIGH, SPEED_LOW, SPEED_MEDIUM, SPEED_OFF, SUPPORT_SET_SPEED, - FanEntity) -from homeassistant.helpers.dispatcher import dispatcher_connect + SPEED_HIGH, + SPEED_LOW, + SPEED_MEDIUM, + SPEED_OFF, + SUPPORT_SET_SPEED, + FanEntity, +) +from homeassistant.helpers.dispatcher import async_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 -} +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) + add_entities([ComfoConnectFan(ccb.name, ccb)], True) class ComfoConnectFan(FanEntity): """Representation of the ComfoConnect fan platform.""" - def __init__(self, hass, name, ccb: ComfoConnectBridge) -> None: + def __init__(self, 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) + async def async_added_to_hass(self): + """Register for sensor updates.""" + _LOGGER.debug("Registering for fan speed") + self.async_on_remove( + async_dispatcher_connect( + self.hass, + SIGNAL_COMFOCONNECT_UPDATE_RECEIVED.format(SENSOR_FAN_SPEED_MODE), + self._handle_update, + ) + ) + await self.hass.async_add_executor_job( + self._ccb.comfoconnect.register_sensor, SENSOR_FAN_SPEED_MODE + ) + + def _handle_update(self, value): + """Handle update callbacks.""" + _LOGGER.debug( + "Handle update for fan speed (%d): %s", SENSOR_FAN_SPEED_MODE, value + ) + self._ccb.data[SENSOR_FAN_SPEED_MODE] = value + self.schedule_update_ha_state() - def _handle_update(var): - if var == SENSOR_FAN_SPEED_MODE: - _LOGGER.debug("Dispatcher update for %s", var) - self.schedule_update_ha_state() + @property + def should_poll(self) -> bool: + """Do not poll.""" + return False - # Register for dispatcher updates - dispatcher_connect( - hass, SIGNAL_COMFOCONNECT_UPDATE_RECEIVED, _handle_update) + @property + def unique_id(self): + """Return a unique_id for this entity.""" + return self._ccb.unique_id @property def name(self): @@ -55,7 +81,7 @@ def name(self): @property def icon(self): """Return the icon to use in the frontend.""" - return 'mdi:air-conditioner' + return "mdi:air-conditioner" @property def supported_features(self) -> int: @@ -65,8 +91,6 @@ def supported_features(self) -> int: @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] @@ -90,11 +114,7 @@ def turn_off(self, **kwargs) -> None: 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) + _LOGGER.debug("Changing fan speed to %s", speed) if speed == SPEED_OFF: self._ccb.comfoconnect.cmd_rmi_request(CMD_FAN_MODE_AWAY) diff --git a/homeassistant/components/comfoconnect/manifest.json b/homeassistant/components/comfoconnect/manifest.json index 03319aeffa89b..966de82f21904 100644 --- a/homeassistant/components/comfoconnect/manifest.json +++ b/homeassistant/components/comfoconnect/manifest.json @@ -1,10 +1,7 @@ { "domain": "comfoconnect", - "name": "Comfoconnect", - "documentation": "https://www.home-assistant.io/components/comfoconnect", - "requirements": [ - "pycomfoconnect==0.3" - ], - "dependencies": [], - "codeowners": [] + "name": "Zehnder ComfoAir Q", + "documentation": "https://www.home-assistant.io/integrations/comfoconnect", + "requirements": ["pycomfoconnect==0.3"], + "codeowners": ["@michaelarnauts"] } diff --git a/homeassistant/components/comfoconnect/sensor.py b/homeassistant/components/comfoconnect/sensor.py index db2a9393e2b48..cea09e97dbaeb 100644 --- a/homeassistant/components/comfoconnect/sensor.py +++ b/homeassistant/components/comfoconnect/sensor.py @@ -1,84 +1,218 @@ """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 pycomfoconnect import ( + SENSOR_BYPASS_STATE, + SENSOR_DAYS_TO_REPLACE_FILTER, + SENSOR_FAN_EXHAUST_DUTY, + SENSOR_FAN_EXHAUST_FLOW, + SENSOR_FAN_EXHAUST_SPEED, + SENSOR_FAN_SUPPLY_DUTY, + SENSOR_FAN_SUPPLY_FLOW, + SENSOR_FAN_SUPPLY_SPEED, + SENSOR_HUMIDITY_EXHAUST, + SENSOR_HUMIDITY_EXTRACT, + SENSOR_HUMIDITY_OUTDOOR, + SENSOR_HUMIDITY_SUPPLY, + SENSOR_POWER_CURRENT, + SENSOR_TEMPERATURE_EXHAUST, + SENSOR_TEMPERATURE_EXTRACT, + SENSOR_TEMPERATURE_OUTDOOR, + SENSOR_TEMPERATURE_SUPPLY, +) +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + CONF_RESOURCES, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_POWER, + DEVICE_CLASS_TEMPERATURE, + POWER_WATT, + TEMP_CELSIUS, + TIME_DAYS, + TIME_HOURS, + UNIT_PERCENTAGE, +) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import async_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) +from . import DOMAIN, SIGNAL_COMFOCONNECT_UPDATE_RECEIVED, ComfoConnectBridge + +ATTR_AIR_FLOW_EXHAUST = "air_flow_exhaust" +ATTR_AIR_FLOW_SUPPLY = "air_flow_supply" +ATTR_BYPASS_STATE = "bypass_state" +ATTR_CURRENT_HUMIDITY = "current_humidity" +ATTR_CURRENT_TEMPERATURE = "current_temperature" +ATTR_DAYS_TO_REPLACE_FILTER = "days_to_replace_filter" +ATTR_EXHAUST_FAN_DUTY = "exhaust_fan_duty" +ATTR_EXHAUST_FAN_SPEED = "exhaust_fan_speed" +ATTR_EXHAUST_HUMIDITY = "exhaust_humidity" +ATTR_EXHAUST_TEMPERATURE = "exhaust_temperature" +ATTR_OUTSIDE_HUMIDITY = "outside_humidity" +ATTR_OUTSIDE_TEMPERATURE = "outside_temperature" +ATTR_POWER_CURRENT = "power_usage" +ATTR_SUPPLY_FAN_DUTY = "supply_fan_duty" +ATTR_SUPPLY_FAN_SPEED = "supply_fan_speed" +ATTR_SUPPLY_HUMIDITY = "supply_humidity" +ATTR_SUPPLY_TEMPERATURE = "supply_temperature" _LOGGER = logging.getLogger(__name__) -SENSOR_TYPES = {} +ATTR_ICON = "icon" +ATTR_ID = "id" +ATTR_LABEL = "label" +ATTR_MULTIPLIER = "multiplier" +ATTR_UNIT = "unit" +SENSOR_TYPES = { + ATTR_CURRENT_TEMPERATURE: { + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_LABEL: "Inside Temperature", + ATTR_UNIT: TEMP_CELSIUS, + ATTR_ICON: "mdi:thermometer", + ATTR_ID: SENSOR_TEMPERATURE_EXTRACT, + ATTR_MULTIPLIER: 0.1, + }, + ATTR_CURRENT_HUMIDITY: { + ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, + ATTR_LABEL: "Inside Humidity", + ATTR_UNIT: UNIT_PERCENTAGE, + ATTR_ICON: "mdi:water-percent", + ATTR_ID: SENSOR_HUMIDITY_EXTRACT, + }, + ATTR_OUTSIDE_TEMPERATURE: { + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_LABEL: "Outside Temperature", + ATTR_UNIT: TEMP_CELSIUS, + ATTR_ICON: "mdi:thermometer", + ATTR_ID: SENSOR_TEMPERATURE_OUTDOOR, + ATTR_MULTIPLIER: 0.1, + }, + ATTR_OUTSIDE_HUMIDITY: { + ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, + ATTR_LABEL: "Outside Humidity", + ATTR_UNIT: UNIT_PERCENTAGE, + ATTR_ICON: "mdi:water-percent", + ATTR_ID: SENSOR_HUMIDITY_OUTDOOR, + }, + ATTR_SUPPLY_TEMPERATURE: { + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_LABEL: "Supply Temperature", + ATTR_UNIT: TEMP_CELSIUS, + ATTR_ICON: "mdi:thermometer", + ATTR_ID: SENSOR_TEMPERATURE_SUPPLY, + ATTR_MULTIPLIER: 0.1, + }, + ATTR_SUPPLY_HUMIDITY: { + ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, + ATTR_LABEL: "Supply Humidity", + ATTR_UNIT: UNIT_PERCENTAGE, + ATTR_ICON: "mdi:water-percent", + ATTR_ID: SENSOR_HUMIDITY_SUPPLY, + }, + ATTR_SUPPLY_FAN_SPEED: { + ATTR_DEVICE_CLASS: None, + ATTR_LABEL: "Supply Fan Speed", + ATTR_UNIT: "rpm", + ATTR_ICON: "mdi:fan", + ATTR_ID: SENSOR_FAN_SUPPLY_SPEED, + }, + ATTR_SUPPLY_FAN_DUTY: { + ATTR_DEVICE_CLASS: None, + ATTR_LABEL: "Supply Fan Duty", + ATTR_UNIT: UNIT_PERCENTAGE, + ATTR_ICON: "mdi:fan", + ATTR_ID: SENSOR_FAN_SUPPLY_DUTY, + }, + ATTR_EXHAUST_FAN_SPEED: { + ATTR_DEVICE_CLASS: None, + ATTR_LABEL: "Exhaust Fan Speed", + ATTR_UNIT: "rpm", + ATTR_ICON: "mdi:fan", + ATTR_ID: SENSOR_FAN_EXHAUST_SPEED, + }, + ATTR_EXHAUST_FAN_DUTY: { + ATTR_DEVICE_CLASS: None, + ATTR_LABEL: "Exhaust Fan Duty", + ATTR_UNIT: UNIT_PERCENTAGE, + ATTR_ICON: "mdi:fan", + ATTR_ID: SENSOR_FAN_EXHAUST_DUTY, + }, + ATTR_EXHAUST_TEMPERATURE: { + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_LABEL: "Exhaust Temperature", + ATTR_UNIT: TEMP_CELSIUS, + ATTR_ICON: "mdi:thermometer", + ATTR_ID: SENSOR_TEMPERATURE_EXHAUST, + ATTR_MULTIPLIER: 0.1, + }, + ATTR_EXHAUST_HUMIDITY: { + ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, + ATTR_LABEL: "Exhaust Humidity", + ATTR_UNIT: UNIT_PERCENTAGE, + ATTR_ICON: "mdi:water-percent", + ATTR_ID: SENSOR_HUMIDITY_EXHAUST, + }, + ATTR_AIR_FLOW_SUPPLY: { + ATTR_DEVICE_CLASS: None, + ATTR_LABEL: "Supply airflow", + ATTR_UNIT: f"m³/{TIME_HOURS}", + ATTR_ICON: "mdi:fan", + ATTR_ID: SENSOR_FAN_SUPPLY_FLOW, + }, + ATTR_AIR_FLOW_EXHAUST: { + ATTR_DEVICE_CLASS: None, + ATTR_LABEL: "Exhaust airflow", + ATTR_UNIT: f"m³/{TIME_HOURS}", + ATTR_ICON: "mdi:fan", + ATTR_ID: SENSOR_FAN_EXHAUST_FLOW, + }, + ATTR_BYPASS_STATE: { + ATTR_DEVICE_CLASS: None, + ATTR_LABEL: "Bypass State", + ATTR_UNIT: UNIT_PERCENTAGE, + ATTR_ICON: "mdi:camera-iris", + ATTR_ID: SENSOR_BYPASS_STATE, + }, + ATTR_DAYS_TO_REPLACE_FILTER: { + ATTR_DEVICE_CLASS: None, + ATTR_LABEL: "Days to replace filter", + ATTR_UNIT: TIME_DAYS, + ATTR_ICON: "mdi:calendar", + ATTR_ID: SENSOR_DAYS_TO_REPLACE_FILTER, + }, + ATTR_POWER_CURRENT: { + ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER, + ATTR_LABEL: "Power usage", + ATTR_UNIT: POWER_WATT, + ATTR_ICON: "mdi:flash", + ATTR_ID: SENSOR_POWER_CURRENT, + }, +} -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 - ], +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Optional(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 ComfoConnect fan platform.""" 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]), + name=f"{ccb.name} {SENSOR_TYPES[resource][ATTR_LABEL]}", ccb=ccb, - sensor_type=sensor_type + sensor_type=resource, ) ) @@ -88,25 +222,41 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class ComfoConnectSensor(Entity): """Representation of a ComfoConnect sensor.""" - def __init__(self, hass, name, ccb: ComfoConnectBridge, - sensor_type) -> None: + def __init__(self, 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._sensor_id = SENSOR_TYPES[self._sensor_type][ATTR_ID] 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() + async def async_added_to_hass(self): + """Register for sensor updates.""" + _LOGGER.debug( + "Registering for sensor %s (%d)", self._sensor_type, self._sensor_id + ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, + SIGNAL_COMFOCONNECT_UPDATE_RECEIVED.format(self._sensor_id), + self._handle_update, + ) + ) + await self.hass.async_add_executor_job( + self._ccb.comfoconnect.register_sensor, self._sensor_id + ) - # Register for dispatcher updates - dispatcher_connect( - hass, SIGNAL_COMFOCONNECT_UPDATE_RECEIVED, _handle_update) + def _handle_update(self, value): + """Handle update callbacks.""" + _LOGGER.debug( + "Handle update for sensor %s (%d): %s", + self._sensor_type, + self._sensor_id, + value, + ) + self._ccb.data[self._sensor_id] = round( + value * SENSOR_TYPES[self._sensor_type].get(ATTR_MULTIPLIER, 1), 2 + ) + self.schedule_update_ha_state() @property def state(self): @@ -116,6 +266,16 @@ def state(self): except KeyError: return None + @property + def should_poll(self) -> bool: + """Do not poll.""" + return False + + @property + def unique_id(self): + """Return a unique_id for this entity.""" + return f"{self._ccb.unique_id}-{self._sensor_type}" + @property def name(self): """Return the name of the sensor.""" @@ -123,10 +283,15 @@ def name(self): @property def icon(self): - """Return the icon to use in the frontend, if any.""" - return SENSOR_TYPES[self._sensor_type][2] + """Return the icon to use in the frontend.""" + return SENSOR_TYPES[self._sensor_type][ATTR_ICON] @property def unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return SENSOR_TYPES[self._sensor_type][1] + """Return the unit of measurement of this entity.""" + return SENSOR_TYPES[self._sensor_type][ATTR_UNIT] + + @property + def device_class(self): + """Return the device_class.""" + return SENSOR_TYPES[self._sensor_type][ATTR_DEVICE_CLASS] diff --git a/homeassistant/components/command_line/binary_sensor.py b/homeassistant/components/command_line/binary_sensor.py index 860367d809188..dc62d8daa9d73 100644 --- a/homeassistant/components/command_line/binary_sensor.py +++ b/homeassistant/components/command_line/binary_sensor.py @@ -5,35 +5,44 @@ import voluptuous as vol from homeassistant.components.binary_sensor import ( - DEVICE_CLASSES_SCHEMA, PLATFORM_SCHEMA, BinarySensorDevice) + DEVICE_CLASSES_SCHEMA, + PLATFORM_SCHEMA, + BinarySensorEntity, +) from homeassistant.const import ( - CONF_COMMAND, CONF_DEVICE_CLASS, CONF_NAME, CONF_PAYLOAD_OFF, - CONF_PAYLOAD_ON, CONF_VALUE_TEMPLATE) + 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' +DEFAULT_NAME = "Binary Command Sensor" +DEFAULT_PAYLOAD_ON = "ON" +DEFAULT_PAYLOAD_OFF = "OFF" SCAN_INTERVAL = timedelta(seconds=60) -CONF_COMMAND_TIMEOUT = 'command_timeout' +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, -}) +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): @@ -49,16 +58,22 @@ def setup_platform(hass, config, add_entities, discovery_info=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) + add_entities( + [ + CommandBinarySensor( + hass, data, name, device_class, payload_on, payload_off, value_template + ) + ], + True, + ) -class CommandBinarySensor(BinarySensorDevice): +class CommandBinarySensor(BinarySensorEntity): """Representation of a command line binary sensor.""" - def __init__(self, hass, data, name, device_class, payload_on, - payload_off, value_template): + 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 @@ -79,7 +94,7 @@ def is_on(self): """Return true if the binary sensor is on.""" return self._state - @ property + @property def device_class(self): """Return the class of the binary sensor.""" return self._device_class @@ -90,8 +105,7 @@ def update(self): value = self.data.value if self._value_template is not None: - value = self._value_template.render_with_possible_json_value( - value, False) + value = self._value_template.render_with_possible_json_value(value, False) if value == self._payload_on: self._state = True elif value == self._payload_off: diff --git a/homeassistant/components/command_line/cover.py b/homeassistant/components/command_line/cover.py index 7f3c52799052c..6f2a038d05183 100644 --- a/homeassistant/components/command_line/cover.py +++ b/homeassistant/components/command_line/cover.py @@ -4,26 +4,34 @@ import voluptuous as vol -from homeassistant.components.cover import (CoverDevice, PLATFORM_SCHEMA) +from homeassistant.components.cover import PLATFORM_SCHEMA, CoverEntity from homeassistant.const import ( - CONF_COMMAND_CLOSE, CONF_COMMAND_OPEN, CONF_COMMAND_STATE, - CONF_COMMAND_STOP, CONF_COVERS, CONF_VALUE_TEMPLATE, CONF_FRIENDLY_NAME) + CONF_COMMAND_CLOSE, + CONF_COMMAND_OPEN, + CONF_COMMAND_STATE, + CONF_COMMAND_STOP, + CONF_COVERS, + CONF_FRIENDLY_NAME, + CONF_VALUE_TEMPLATE, +) 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, -}) +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), -}) +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): @@ -55,11 +63,19 @@ def setup_platform(hass, config, add_entities, discovery_info=None): add_entities(covers) -class CommandCover(CoverDevice): +class CommandCover(CoverEntity): """Representation a command line cover.""" - def __init__(self, hass, name, command_open, command_close, command_stop, - command_state, value_template): + def __init__( + self, + hass, + name, + command_open, + command_close, + command_stop, + command_state, + value_template, + ): """Initialize the cover.""" self._hass = hass self._name = name @@ -75,7 +91,7 @@ def _move_cover(command): """Execute the actual commands.""" _LOGGER.info("Running command: %s", command) - success = (subprocess.call(command, shell=True) == 0) + success = subprocess.call(command, shell=True) == 0 # nosec # shell by design if not success: _LOGGER.error("Command failed: %s", command) @@ -88,8 +104,10 @@ def _query_state_value(command): _LOGGER.info("Running state command: %s", command) try: - return_value = subprocess.check_output(command, shell=True) - return return_value.strip().decode('utf-8') + return_value = subprocess.check_output( + command, shell=True # nosec # shell by design + ) + return return_value.strip().decode("utf-8") except subprocess.CalledProcessError: _LOGGER.error("Command failed: %s", command) @@ -129,8 +147,7 @@ def update(self): if self._command_state: payload = str(self._query_state()) if self._value_template: - payload = self._value_template.render_with_possible_json_value( - payload) + payload = self._value_template.render_with_possible_json_value(payload) self._state = int(payload) def open_cover(self, **kwargs): diff --git a/homeassistant/components/command_line/manifest.json b/homeassistant/components/command_line/manifest.json index ff94522210d81..ffb1a33ed7bfb 100644 --- a/homeassistant/components/command_line/manifest.json +++ b/homeassistant/components/command_line/manifest.json @@ -1,8 +1,6 @@ { "domain": "command_line", - "name": "Command line", - "documentation": "https://www.home-assistant.io/components/command_line", - "requirements": [], - "dependencies": [], + "name": "Command Line", + "documentation": "https://www.home-assistant.io/integrations/command_line", "codeowners": [] } diff --git a/homeassistant/components/command_line/notify.py b/homeassistant/components/command_line/notify.py index 941be72aa8169..50b0bec74ee49 100644 --- a/homeassistant/components/command_line/notify.py +++ b/homeassistant/components/command_line/notify.py @@ -4,18 +4,15 @@ import voluptuous as vol +from homeassistant.components.notify import PLATFORM_SCHEMA, BaseNotificationService 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, -}) +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): @@ -35,8 +32,12 @@ def __init__(self, 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 = subprocess.Popen( + self.command, + universal_newlines=True, + stdin=subprocess.PIPE, + shell=True, # nosec # shell by design + ) proc.communicate(input=message) if proc.returncode != 0: _LOGGER.error("Command failed: %s", self.command) diff --git a/homeassistant/components/command_line/sensor.py b/homeassistant/components/command_line/sensor.py index 587cfe53d3c60..f7ae21ab70418 100644 --- a/homeassistant/components/command_line/sensor.py +++ b/homeassistant/components/command_line/sensor.py @@ -1,5 +1,5 @@ """Allows to configure custom shell commands to turn a value for a sensor.""" -import collections +from collections.abc import Mapping from datetime import timedelta import json import logging @@ -10,8 +10,12 @@ from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_COMMAND, CONF_NAME, CONF_UNIT_OF_MEASUREMENT, CONF_VALUE_TEMPLATE, - STATE_UNKNOWN) + 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 @@ -19,23 +23,24 @@ _LOGGER = logging.getLogger(__name__) -CONF_COMMAND_TIMEOUT = 'command_timeout' -CONF_JSON_ATTRIBUTES = 'json_attributes' +CONF_COMMAND_TIMEOUT = "command_timeout" +CONF_JSON_ATTRIBUTES = "json_attributes" -DEFAULT_NAME = 'Command Sensor' +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, -}) +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): @@ -50,15 +55,17 @@ def setup_platform(hass, config, add_entities, discovery_info=None): 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) + 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): + def __init__( + self, hass, data, name, unit_of_measurement, value_template, json_attributes + ): """Initialize the sensor.""" self._hass = hass self.data = data @@ -99,15 +106,16 @@ def update(self): 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} + if isinstance(json_dict, 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) + _LOGGER.warning("Unable to parse output as JSON: %s", value) else: _LOGGER.warning("Empty reply found when expecting JSON data") @@ -115,7 +123,8 @@ def update(self): value = STATE_UNKNOWN elif self._value_template is not None: self._state = self._value_template.render_with_possible_json_value( - value, STATE_UNKNOWN) + value, STATE_UNKNOWN + ) else: self._state = value @@ -137,13 +146,13 @@ def update(self): if command in cache: prog, args, args_compiled = cache[command] - elif ' ' not in command: + elif " " not in command: prog = command args = None args_compiled = None cache[command] = (prog, args, args_compiled) else: - prog, args = command.split(' ', 1) + prog, args = command.split(" ", 1) args_compiled = template.Template(args, self.hass) cache[command] = (prog, args, args_compiled) @@ -159,16 +168,16 @@ def update(self): if rendered_args == args: # No template used. default behavior - shell = True + pass else: # Template used. Construct the string used in the shell - command = str(' '.join([prog] + shlex.split(rendered_args))) - shell = True + command = str(" ".join([prog] + shlex.split(rendered_args))) 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') + command, shell=True, timeout=self.timeout # nosec # shell by design + ) + self.value = return_value.strip().decode("utf-8") except subprocess.CalledProcessError: _LOGGER.error("Command failed: %s", command) except subprocess.TimeoutExpired: diff --git a/homeassistant/components/command_line/switch.py b/homeassistant/components/command_line/switch.py index 8d97198ad6635..7f62970b63997 100644 --- a/homeassistant/components/command_line/switch.py +++ b/homeassistant/components/command_line/switch.py @@ -4,27 +4,36 @@ import voluptuous as vol -import homeassistant.helpers.config_validation as cv - from homeassistant.components.switch import ( - SwitchDevice, PLATFORM_SCHEMA, ENTITY_ID_FORMAT) + ENTITY_ID_FORMAT, + PLATFORM_SCHEMA, + SwitchEntity, +) from homeassistant.const import ( - CONF_FRIENDLY_NAME, CONF_SWITCHES, CONF_VALUE_TEMPLATE, CONF_COMMAND_OFF, - CONF_COMMAND_ON, CONF_COMMAND_STATE) + CONF_COMMAND_OFF, + CONF_COMMAND_ON, + CONF_COMMAND_STATE, + CONF_FRIENDLY_NAME, + CONF_SWITCHES, + CONF_VALUE_TEMPLATE, +) +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -SWITCH_SCHEMA = vol.Schema({ - vol.Optional(CONF_COMMAND_OFF, default='true'): cv.string, - vol.Optional(CONF_COMMAND_ON, default='true'): cv.string, - vol.Optional(CONF_COMMAND_STATE): cv.string, - vol.Optional(CONF_FRIENDLY_NAME): cv.string, - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, -}) +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), -}) +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): @@ -46,7 +55,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): device_config.get(CONF_COMMAND_ON), device_config.get(CONF_COMMAND_OFF), device_config.get(CONF_COMMAND_STATE), - value_template + value_template, ) ) @@ -57,11 +66,19 @@ def setup_platform(hass, config, add_entities, discovery_info=None): add_entities(switches) -class CommandSwitch(SwitchDevice): +class CommandSwitch(SwitchEntity): """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): + 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) @@ -77,7 +94,7 @@ def _switch(command): """Execute the actual commands.""" _LOGGER.info("Running command: %s", command) - success = (subprocess.call(command, shell=True) == 0) + success = subprocess.call(command, shell=True) == 0 # nosec # shell by design if not success: _LOGGER.error("Command failed: %s", command) @@ -90,8 +107,10 @@ def _query_state_value(command): _LOGGER.info("Running state command: %s", command) try: - return_value = subprocess.check_output(command, shell=True) - return return_value.strip().decode('utf-8') + return_value = subprocess.check_output( + command, shell=True # nosec # shell by design + ) + return return_value.strip().decode("utf-8") except subprocess.CalledProcessError: _LOGGER.error("Command failed: %s", command) @@ -99,7 +118,7 @@ def _query_state_value(command): 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 + return subprocess.call(command, shell=True) == 0 # nosec # shell by design @property def should_poll(self): @@ -135,20 +154,17 @@ def update(self): 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') + 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): + 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): + 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/alarm_control_panel.py b/homeassistant/components/concord232/alarm_control_panel.py index c56e7e7153129..94880dcccf749 100644 --- a/homeassistant/components/concord232/alarm_control_panel.py +++ b/homeassistant/components/concord232/alarm_control_panel.py @@ -2,43 +2,57 @@ import datetime import logging +from concord232 import client as concord232_client 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.components.alarm_control_panel.const import ( + SUPPORT_ALARM_ARM_AWAY, + SUPPORT_ALARM_ARM_HOME, +) 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) + CONF_CODE, + CONF_HOST, + CONF_MODE, + CONF_NAME, + CONF_PORT, + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_HOME, + STATE_ALARM_DISARMED, +) +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -DEFAULT_HOST = 'localhost' -DEFAULT_NAME = 'CONCORD232' +DEFAULT_HOST = "localhost" +DEFAULT_NAME = "CONCORD232" DEFAULT_PORT = 5007 -DEFAULT_MODE = 'audible' +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, -}) +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) + name = config[CONF_NAME] code = config.get(CONF_CODE) - mode = config.get(CONF_MODE) - host = config.get(CONF_HOST) - port = config.get(CONF_PORT) + mode = config[CONF_MODE] + host = config[CONF_HOST] + port = config[CONF_PORT] - url = 'http://{}:{}'.format(host, port) + url = f"http://{host}:{port}" try: add_entities([Concord232Alarm(url, name, code, mode)], True) @@ -46,12 +60,11 @@ def setup_platform(hass, config, add_entities, discovery_info=None): _LOGGER.error("Unable to connect to Concord232: %s", str(ex)) -class Concord232Alarm(alarm.AlarmControlPanel): +class Concord232Alarm(alarm.AlarmControlPanelEntity): """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 @@ -60,7 +73,6 @@ def __init__(self, url, name, code, 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): @@ -77,21 +89,28 @@ def state(self): """Return the state of the device.""" return self._state + @property + def supported_features(self) -> int: + """Return the list of supported features.""" + return SUPPORT_ALARM_ARM_HOME | SUPPORT_ALARM_ARM_AWAY + 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)) + _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': + if part["arming_level"] == "Off": self._state = STATE_ALARM_DISARMED - elif 'Home' in part['arming_level']: + elif "Home" in part["arming_level"]: self._state = STATE_ALARM_ARMED_HOME else: self._state = STATE_ALARM_ARMED_AWAY @@ -106,16 +125,16 @@ 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') + if self._mode == "silent": + self._alarm.arm("stay", "silent") else: - self._alarm.arm('stay') + 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') + self._alarm.arm("away") def _validate_code(self, code, state): """Validate given code.""" @@ -124,8 +143,7 @@ def _validate_code(self, code, state): if isinstance(self._code, str): alarm_code = self._code else: - alarm_code = self._code.render(from_state=self._state, - to_state=state) + 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) diff --git a/homeassistant/components/concord232/binary_sensor.py b/homeassistant/components/concord232/binary_sensor.py index ae464da97987e..3077056c397df 100644 --- a/homeassistant/components/concord232/binary_sensor.py +++ b/homeassistant/components/concord232/binary_sensor.py @@ -2,54 +2,59 @@ import datetime import logging +from concord232 import client as concord232_client 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) + DEVICE_CLASSES, + PLATFORM_SCHEMA, + BinarySensorEntity, +) +from homeassistant.const import CONF_HOST, CONF_PORT import homeassistant.helpers.config_validation as cv +import homeassistant.util.dt as dt_util _LOGGER = logging.getLogger(__name__) -CONF_EXCLUDE_ZONES = 'exclude_zones' -CONF_ZONE_TYPES = 'zone_types' +CONF_EXCLUDE_ZONES = "exclude_zones" +CONF_ZONE_TYPES = "zone_types" -DEFAULT_HOST = 'localhost' -DEFAULT_NAME = 'Alarm' -DEFAULT_PORT = '5007' +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), -}) +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, -}) +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) + host = config[CONF_HOST] + port = config[CONF_PORT] + exclude = config[CONF_EXCLUDE_ZONES] + zone_types = config[CONF_ZONE_TYPES] sensors = [] try: _LOGGER.debug("Initializing client") - client = concord232_client.Client('http://{}:{}'.format(host, port)) + client = concord232_client.Client(f"http://{host}:{port}") client.zones = client.list_zones() - client.last_zone_update = datetime.datetime.now() + client.last_zone_update = dt_util.utcnow() except requests.exceptions.ConnectionError as ex: _LOGGER.error("Unable to connect to Concord232: %s", str(ex)) @@ -60,15 +65,17 @@ def setup_platform(hass, config, add_entities, discovery_info=None): # 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']) + 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: + _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)) + hass, + client, + zone, + zone_types.get(zone["number"], get_opening_type(zone)), ) ) @@ -77,18 +84,18 @@ def setup_platform(hass, config, add_entities, discovery_info=None): 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): + 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(BinarySensorEntity): """Representation of a Concord232 zone as a sensor.""" def __init__(self, hass, client, zone, zone_type): @@ -96,7 +103,7 @@ def __init__(self, hass, client, zone, zone_type): self._hass = hass self._client = client self._zone = zone - self._number = zone['number'] + self._number = zone["number"] self._zone_type = zone_type @property @@ -112,23 +119,24 @@ def should_poll(self): @property def name(self): """Return the name of the binary sensor.""" - return self._zone['name'] + 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') + return bool(self._zone["state"] != "Normal") def update(self): """Get updated stats from API.""" - last_update = datetime.datetime.now() - self._client.last_zone_update + last_update = dt_util.utcnow() - 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']) + self._client.last_zone_update = dt_util.utcnow() + _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) + 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 index f26da49d3f1b8..97ae62bc3b06f 100644 --- a/homeassistant/components/concord232/manifest.json +++ b/homeassistant/components/concord232/manifest.json @@ -1,10 +1,7 @@ { "domain": "concord232", "name": "Concord232", - "documentation": "https://www.home-assistant.io/components/concord232", - "requirements": [ - "concord232==0.15" - ], - "dependencies": [], + "documentation": "https://www.home-assistant.io/integrations/concord232", + "requirements": ["concord232==0.15"], "codeowners": [] } diff --git a/homeassistant/components/config/__init__.py b/homeassistant/components/config/__init__.py index 3752d5d37bf14..d7a257b1d9b5a 100644 --- a/homeassistant/components/config/__init__.py +++ b/homeassistant/components/config/__init__.py @@ -5,37 +5,47 @@ import voluptuous as vol +from homeassistant.components.http import HomeAssistantView +from homeassistant.const import ( + CONF_ID, + EVENT_COMPONENT_LOADED, + HTTP_BAD_REQUEST, + HTTP_NOT_FOUND, +) from homeassistant.core import callback -from homeassistant.const import EVENT_COMPONENT_LOADED, CONF_ID +from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import ATTR_COMPONENT -from homeassistant.components.http import HomeAssistantView -from homeassistant.util.yaml import load_yaml, dump +from homeassistant.util.yaml import dump, load_yaml -DOMAIN = 'config' +DOMAIN = "config" SECTIONS = ( - 'area_registry', - 'auth', - 'auth_provider_homeassistant', - 'automation', - 'config_entries', - 'core', - 'customize', - 'device_registry', - 'entity_registry', - 'group', - 'script', + "area_registry", + "auth", + "auth_provider_homeassistant", + "automation", + "config_entries", + "core", + "customize", + "device_registry", + "entity_registry", + "group", + "script", + "scene", ) -ON_DEMAND = ('zwave',) +ON_DEMAND = ("zwave",) +ACTION_CREATE_UPDATE = "create_update" +ACTION_DELETE = "delete" async def async_setup(hass, config): """Set up the config component.""" - await hass.components.frontend.async_register_built_in_panel( - 'config', 'config', 'hass:settings', require_admin=True) + 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__) + panel = importlib.import_module(f".{panel_name}", __name__) if not panel: return @@ -43,7 +53,7 @@ async def setup_panel(panel_name): success = await panel.async_setup(hass) if success: - key = '{}.{}'.format(DOMAIN, panel_name) + key = f"{DOMAIN}.{panel_name}" hass.bus.async_fire(EVENT_COMPONENT_LOADED, {ATTR_COMPONENT: key}) @callback @@ -62,7 +72,7 @@ def component_loaded(event): tasks.append(setup_panel(panel_name)) if tasks: - await asyncio.wait(tasks, loop=hass.loop) + await asyncio.wait(tasks) return True @@ -70,15 +80,26 @@ def component_loaded(event): class BaseEditConfigView(HomeAssistantView): """Configure a Group endpoint.""" - def __init__(self, component, config_type, path, key_schema, data_schema, - *, post_write_hook=None): + def __init__( + self, + component, + config_type, + path, + key_schema, + data_schema, + *, + post_write_hook=None, + data_validator=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.url = f"/api/config/{component}/{config_type}/{{config_key}}" + self.name = f"api:config:{component}:{config_type}" self.path = path self.key_schema = key_schema self.data_schema = data_schema self.post_write_hook = post_write_hook + self.data_validator = data_validator + self.mutation_lock = asyncio.Lock() def _empty_config(self): """Empty config if file not found.""" @@ -92,14 +113,19 @@ 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) + hass = request.app["hass"] + async with self.mutation_lock: + 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_message("Resource not found", HTTP_NOT_FOUND) return self.json(value) @@ -108,39 +134,62 @@ async def post(self, request, config_key): try: data = await request.json() except ValueError: - return self.json_message('Invalid JSON specified', 400) + return self.json_message("Invalid JSON specified", HTTP_BAD_REQUEST) try: self.key_schema(config_key) except vol.Invalid as err: - return self.json_message('Key malformed: {}'.format(err), 400) + return self.json_message(f"Key malformed: {err}", HTTP_BAD_REQUEST) + + hass = request.app["hass"] 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) + if self.data_validator: + await self.data_validator(hass, data) + else: + self.data_schema(data) + except (vol.Invalid, HomeAssistantError) as err: + return self.json_message(f"Message malformed: {err}", HTTP_BAD_REQUEST) - hass = request.app['hass'] path = hass.config.path(self.path) - current = await self.read_config(hass) - self._write_value(hass, current, config_key, data) + async with self.mutation_lock: + current = await self.read_config(hass) + self._write_value(hass, current, config_key, data) - await hass.async_add_job(_write, path, current) + 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)) + hass.async_create_task( + self.post_write_hook(ACTION_CREATE_UPDATE, config_key) + ) + + return self.json({"result": "ok"}) - return self.json({ - 'result': 'ok', - }) + async def delete(self, request, config_key): + """Remove an entry.""" + hass = request.app["hass"] + async with self.mutation_lock: + 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", HTTP_NOT_FOUND) + + 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(ACTION_DELETE, config_key)) + + 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)) + current = await hass.async_add_job(_read, hass.config.path(self.path)) if not current: current = self._empty_config() return current @@ -161,6 +210,10 @@ 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.""" @@ -171,8 +224,7 @@ def _empty_config(self): 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) + 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.""" @@ -184,6 +236,13 @@ def _write_value(self, hass, data, config_key, new_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.""" @@ -198,5 +257,5 @@ def _write(path, data): # 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: + 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 index 06fc3eae34d15..81daf35339e88 100644 --- a/homeassistant/components/config/area_registry.py +++ b/homeassistant/components/config/area_registry.py @@ -3,34 +3,35 @@ from homeassistant.components import websocket_api from homeassistant.components.websocket_api.decorators import ( - async_response, require_admin) + 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, -}) +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): @@ -54,12 +55,15 @@ async def async_setup(hass): 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()] - )) + 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 @@ -68,15 +72,15 @@ async def websocket_create_area(hass, connection, msg): """Create area command.""" registry = await async_get_registry(hass) try: - entry = registry.async_create(msg['name']) + entry = registry.async_create(msg["name"]) except ValueError as err: - connection.send_message(websocket_api.error_message( - msg['id'], 'invalid_info', str(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) - )) + connection.send_message( + websocket_api.result_message(msg["id"], _entry_dict(entry)) + ) @require_admin @@ -86,15 +90,15 @@ async def websocket_delete_area(hass, connection, msg): registry = await async_get_registry(hass) try: - await registry.async_delete(msg['area_id']) + 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" - )) + 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' - )) + connection.send_message(websocket_api.result_message(msg["id"], "success")) @require_admin @@ -104,21 +108,18 @@ async def websocket_update_area(hass, connection, msg): registry = await async_get_registry(hass) try: - entry = registry.async_update(msg['area_id'], msg['name']) + 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) - )) + 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) - )) + 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 - } + return {"area_id": entry.id, "name": entry.name} diff --git a/homeassistant/components/config/auth.py b/homeassistant/components/config/auth.py index e6451e09a98a8..d5bbb60e27def 100644 --- a/homeassistant/components/config/auth.py +++ b/homeassistant/components/config/auth.py @@ -3,39 +3,26 @@ 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_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, -}) +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} +) 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 + 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 + WS_TYPE_DELETE, websocket_delete, SCHEMA_WS_DELETE ) + hass.components.websocket_api.async_register_command(websocket_create) hass.components.websocket_api.async_register_command(websocket_update) return True @@ -46,91 +33,102 @@ 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)) + 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')) + 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']) + 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')) + 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'])) + connection.send_message(websocket_api.result_message(msg["id"])) @websocket_api.require_admin @websocket_api.async_response +@websocket_api.websocket_command( + { + vol.Required("type"): "config/auth/create", + vol.Required("name"): str, + vol.Optional("group_ids"): [str], + } +) async def websocket_create(hass, connection, msg): """Create a user.""" - user = await hass.auth.async_create_user(msg['name']) + user = await hass.auth.async_create_user(msg["name"], msg.get("group_ids")) connection.send_message( - websocket_api.result_message(msg['id'], { - 'user': _user_info(user) - })) + 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] -}) +@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')) + 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')) + 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.')) + 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') + 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), - })) + 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 - ] + "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 index f6fc4bc8ceffc..dec7fb24d27f7 100644 --- a/homeassistant/components/config/auth_provider_homeassistant.py +++ b/homeassistant/components/config/auth_provider_homeassistant.py @@ -4,42 +4,41 @@ 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 -}) +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 + WS_TYPE_CREATE, websocket_create, SCHEMA_WS_CREATE ) hass.components.websocket_api.async_register_command( - WS_TYPE_DELETE, websocket_delete, - SCHEMA_WS_DELETE + 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 + WS_TYPE_CHANGE_PASSWORD, websocket_change_password, SCHEMA_WS_CHANGE_PASSWORD ) return True @@ -47,10 +46,10 @@ async def async_setup(hass): def _get_provider(hass): """Get homeassistant auth provider.""" for prv in hass.auth.auth_providers: - if prv.type == 'homeassistant': + if prv.type == "homeassistant": return prv - raise RuntimeError('Provider not found') + raise RuntimeError("Provider not found") @websocket_api.require_admin @@ -60,34 +59,43 @@ async def websocket_create(hass, connection, msg): provider = _get_provider(hass) await provider.async_initialize() - user = await hass.auth.async_get_user(msg['user_id']) + 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')) + 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.')) + 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']) + 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')) + 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'] - }) + 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'])) + connection.send_message(websocket_api.result_message(msg["id"])) @websocket_api.require_admin @@ -97,29 +105,30 @@ async def websocket_delete(hass, connection, msg): provider = _get_provider(hass) await provider.async_initialize() - credentials = await provider.async_get_or_create_credentials({ - 'username': msg['username'] - }) + 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'])) + connection.send_message(websocket_api.result_message(msg["id"])) return try: - provider.data.async_remove_auth(msg['username']) + 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.')) + 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'])) + connection.send_message(websocket_api.result_message(msg["id"])) @websocket_api.async_response @@ -127,8 +136,9 @@ 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')) + connection.send_message( + websocket_api.error_message(msg["id"], "user_not_found", "User not found") + ) return provider = _get_provider(hass) @@ -137,25 +147,30 @@ async def websocket_change_password(hass, connection, msg): username = None for credential in user.credentials: if credential.auth_provider_type == provider.type: - username = credential.data['username'] + username = credential.data["username"] break if username is None: - connection.send_message(websocket_api.error_message( - msg['id'], 'credentials_not_found', 'Credentials not found')) + 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']) + 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')) + 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']) + provider.data.change_password, username, msg["new_password"] + ) await provider.data.async_save() - connection.send_message( - websocket_api.result_message(msg['id'])) + connection.send_message(websocket_api.result_message(msg["id"])) diff --git a/homeassistant/components/config/automation.py b/homeassistant/components/config/automation.py index 175d90ff59ca2..6216a52fc130a 100644 --- a/homeassistant/components/config/automation.py +++ b/homeassistant/components/config/automation.py @@ -3,24 +3,44 @@ import uuid from homeassistant.components.automation import DOMAIN, PLATFORM_SCHEMA +from homeassistant.components.automation.config import async_validate_config_item +from homeassistant.config import AUTOMATION_CONFIG_PATH from homeassistant.const import CONF_ID, SERVICE_RELOAD -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry -from . import EditIdBasedConfigView - -CONFIG_PATH = 'automations.yaml' +from . import ACTION_DELETE, EditIdBasedConfigView async def async_setup(hass): """Set up the Automation config API.""" - async def hook(hass): + + async def hook(action, config_key): """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 - )) + if action != ACTION_DELETE: + return + + ent_reg = await entity_registry.async_get_registry(hass) + + entity_id = ent_reg.async_get_entity_id(DOMAIN, DOMAIN, config_key) + + if entity_id is None: + return + + ent_reg.async_remove(entity_id) + + hass.http.register_view( + EditAutomationConfigView( + DOMAIN, + "config", + AUTOMATION_CONFIG_PATH, + cv.string, + PLATFORM_SCHEMA, + post_write_hook=hook, + data_validator=async_validate_config_item, + ) + ) return True @@ -46,7 +66,7 @@ def _write_value(self, hass, data, config_key, new_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'): + for key in ("id", "alias", "description", "trigger", "condition", "action"): if key in cur_value: updated_value[key] = cur_value[key] if key in new_value: diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index 8865ff39ceaf6..584255764a34b 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -1,43 +1,57 @@ """Http views to control the config manager.""" +import aiohttp.web_exceptions +import voluptuous as vol +import voluptuous_serialize from homeassistant import config_entries, data_entry_flow from homeassistant.auth.permissions.const import CAT_CONFIG_ENTRIES +from homeassistant.components import websocket_api from homeassistant.components.http import HomeAssistantView +from homeassistant.const import HTTP_NOT_FOUND +from homeassistant.core import callback from homeassistant.exceptions import Unauthorized +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.data_entry_flow import ( - FlowManagerIndexView, FlowManagerResourceView) + FlowManagerIndexView, + FlowManagerResourceView, +) +from homeassistant.loader import async_get_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(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)) + + hass.http.register_view(OptionManagerFlowIndexView(hass.config_entries.options)) + hass.http.register_view(OptionManagerFlowResourceView(hass.config_entries.options)) + + hass.components.websocket_api.async_register_command(config_entry_update) + hass.components.websocket_api.async_register_command(config_entries_progress) + hass.components.websocket_api.async_register_command(system_options_list) + hass.components.websocket_api.async_register_command(system_options_update) + hass.components.websocket_api.async_register_command(ignore_config_flow) + return True def _prepare_json(result): """Convert result for JSON.""" - if result['type'] != data_entry_flow.RESULT_TYPE_FORM: + if result["type"] != data_entry_flow.RESULT_TYPE_FORM: return result - import voluptuous_serialize - data = result.copy() - schema = data['data_schema'] + schema = data["data_schema"] if schema is None: - data['data_schema'] = [] + data["data_schema"] = [] else: - data['data_schema'] = voluptuous_serialize.convert(schema) + data["data_schema"] = voluptuous_serialize.convert( + schema, custom_serializer=cv.custom_serializer + ) return data @@ -45,43 +59,35 @@ def _prepare_json(result): class ConfigManagerEntryIndexView(HomeAssistantView): """View to get available config entries.""" - url = '/api/config/config_entries/entry' - name = 'api:config:config_entries:entry' + 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'] + 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()]) + return self.json( + [entry_json(entry) 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' + 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') + if not request["hass_user"].is_admin: + raise Unauthorized(config_entry_id=entry_id, permission="remove") - hass = request.app['hass'] + 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_message("Invalid entry specified", HTTP_NOT_FOUND) return self.json(result) @@ -89,97 +95,83 @@ async def delete(self, request, entry_id): class ConfigManagerFlowIndexView(FlowManagerIndexView): """View to create config flows.""" - url = '/api/config/config_entries/flow' - name = 'api:config:config_entries:flow' + 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]) + """Not implemented.""" + raise aiohttp.web_exceptions.HTTPMethodNotAllowed("GET", ["POST"]) # 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') + 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: + 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') + 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' + 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') + 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') + 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: + 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') + 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' + 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_entries.FLOWS) + hass = request.app["hass"] + return self.json(await async_get_config_flows(hass)) 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' + url = "/api/config/config_entries/options/flow" + name = "api:config:config_entries:option:flow" # pylint: disable=arguments-differ async def post(self, request): @@ -187,9 +179,8 @@ async def post(self, request): handler in request is entry_id. """ - if not request['hass_user'].is_admin: - raise Unauthorized( - perm_category=CAT_CONFIG_ENTRIES, permission='edit') + 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) @@ -198,23 +189,160 @@ async def post(self, 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' + 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') + 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') + 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) + + +@websocket_api.require_admin +@websocket_api.websocket_command({"type": "config_entries/flow/progress"}) +def config_entries_progress(hass, connection, msg): + """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. + """ + connection.send_result( + msg["id"], + [ + flw + for flw in hass.config_entries.flow.async_progress() + if flw["context"]["source"] != config_entries.SOURCE_USER + ], + ) + + +@websocket_api.require_admin +@websocket_api.async_response +@websocket_api.websocket_command( + {"type": "config_entries/system_options/list", "entry_id": str} +) +async def system_options_list(hass, connection, msg): + """List all system options for a config entry.""" + entry_id = msg["entry_id"] + entry = hass.config_entries.async_get_entry(entry_id) + + if entry: + connection.send_result(msg["id"], entry.system_options.as_dict()) + + +@websocket_api.require_admin +@websocket_api.async_response +@websocket_api.websocket_command( + { + "type": "config_entries/system_options/update", + "entry_id": str, + vol.Optional("disable_new_entities"): bool, + } +) +async def system_options_update(hass, connection, msg): + """Update config entry system options.""" + changes = dict(msg) + changes.pop("id") + changes.pop("type") + entry_id = changes.pop("entry_id") + entry = hass.config_entries.async_get_entry(entry_id) + + if entry is None: + connection.send_error( + msg["id"], websocket_api.const.ERR_NOT_FOUND, "Config entry not found" + ) + return + + hass.config_entries.async_update_entry(entry, system_options=changes) + connection.send_result(msg["id"], entry.system_options.as_dict()) + + +@websocket_api.require_admin +@websocket_api.async_response +@websocket_api.websocket_command( + {"type": "config_entries/update", "entry_id": str, vol.Optional("title"): str} +) +async def config_entry_update(hass, connection, msg): + """Update config entry system options.""" + changes = dict(msg) + changes.pop("id") + changes.pop("type") + entry_id = changes.pop("entry_id") + + entry = hass.config_entries.async_get_entry(entry_id) + + if entry is None: + connection.send_error( + msg["id"], websocket_api.const.ERR_NOT_FOUND, "Config entry not found" + ) + return + + hass.config_entries.async_update_entry(entry, **changes) + connection.send_result(msg["id"], entry_json(entry)) + + +@websocket_api.require_admin +@websocket_api.async_response +@websocket_api.websocket_command({"type": "config_entries/ignore_flow", "flow_id": str}) +async def ignore_config_flow(hass, connection, msg): + """Ignore a config flow.""" + flow = next( + ( + flw + for flw in hass.config_entries.flow.async_progress() + if flw["flow_id"] == msg["flow_id"] + ), + None, + ) + + if flow is None: + connection.send_error( + msg["id"], websocket_api.const.ERR_NOT_FOUND, "Config entry not found" + ) + return + + if "unique_id" not in flow["context"]: + connection.send_error( + msg["id"], "no_unique_id", "Specified flow has no unique ID." + ) + return + + await hass.config_entries.flow.async_init( + flow["handler"], + context={"source": config_entries.SOURCE_IGNORE}, + data={"unique_id": flow["context"]["unique_id"]}, + ) + connection.send_result(msg["id"]) + + +@callback +def entry_json(entry: config_entries.ConfigEntry) -> dict: + """Return JSON value of a config entry.""" + handler = config_entries.HANDLERS.get(entry.domain) + supports_options = ( + # Guard in case handler is no longer registered (custom compnoent etc) + handler is not None + # pylint: disable=comparison-with-callable + and handler.async_get_options_flow + != config_entries.ConfigFlow.async_get_options_flow + ) + return { + "entry_id": entry.entry_id, + "domain": entry.domain, + "title": entry.title, + "source": entry.source, + "state": entry.state, + "connection_class": entry.connection_class, + "supports_options": supports_options, + } diff --git a/homeassistant/components/config/core.py b/homeassistant/components/config/core.py index ce7675c41f47b..e9ceb7eac57e4 100644 --- a/homeassistant/components/config/core.py +++ b/homeassistant/components/config/core.py @@ -1,28 +1,90 @@ """Component to interact with Hassbian tools.""" +import voluptuous as vol + +from homeassistant.components import websocket_api from homeassistant.components.http import HomeAssistantView from homeassistant.config import async_check_ha_config_file +from homeassistant.const import CONF_UNIT_SYSTEM_IMPERIAL, CONF_UNIT_SYSTEM_METRIC +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' + 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']) + 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 - state = 'invalid' if errors else 'valid' + if location_info.time_zone: + info["time_zone"] = location_info.time_zone - return self.json({ - "result": state, - "errors": errors, - }) + connection.send_result(msg["id"], info) diff --git a/homeassistant/components/config/customize.py b/homeassistant/components/config/customize.py index 85e9c0e688670..3b1122fc3a57b 100644 --- a/homeassistant/components/config/customize.py +++ b/homeassistant/components/config/customize.py @@ -6,19 +6,21 @@ from . import EditKeyBasedConfigView -CONFIG_PATH = 'customize.yaml' +CONFIG_PATH = "customize.yaml" async def async_setup(hass): """Set up the Customize config API.""" - async def hook(hass): + + async def hook(action, config_key): """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 - )) + hass.http.register_view( + CustomizeConfigView( + "customize", "config", CONFIG_PATH, cv.entity_id, dict, post_write_hook=hook + ) + ) return True @@ -29,7 +31,7 @@ class CustomizeConfigView(EditKeyBasedConfigView): 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, {})} + return {"global": customize, "local": data.get(config_key, {})} def _write_value(self, hass, data, config_key, new_value): """Set value.""" diff --git a/homeassistant/components/config/device_registry.py b/homeassistant/components/config/device_registry.py index d9e55bbe67e73..08f53f948fe09 100644 --- a/homeassistant/components/config/device_registry.py +++ b/homeassistant/components/config/device_registry.py @@ -3,29 +3,32 @@ from homeassistant.components import websocket_api from homeassistant.components.websocket_api.decorators import ( - async_response, require_admin) + 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_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), -}) +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 + 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 @@ -37,9 +40,11 @@ async def async_setup(hass): 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()] - )) + connection.send_message( + websocket_api.result_message( + msg["id"], [_entry_dict(entry) for entry in registry.devices.values()] + ) + ) @require_admin @@ -48,28 +53,26 @@ 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') + 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) - )) + 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, - 'hub_device_id': entry.hub_device_id, - 'area_id': entry.area_id, - 'name_by_user': entry.name_by_user, + "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 index 341b05f966b9c..f024f146a601d 100644 --- a/homeassistant/components/config/entity_registry.py +++ b/homeassistant/components/config/entity_registry.py @@ -1,95 +1,81 @@ """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) + async_response, + require_admin, +) +from homeassistant.core import callback 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 -}) +from homeassistant.helpers.entity_registry import async_get_registry 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 - ) + hass.components.websocket_api.async_register_command(websocket_list_entities) + hass.components.websocket_api.async_register_command(websocket_get_entity) + hass.components.websocket_api.async_register_command(websocket_update_entity) + hass.components.websocket_api.async_register_command(websocket_remove_entity) return True @async_response +@websocket_api.websocket_command({vol.Required("type"): "config/entity_registry/list"}) 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()] - )) + connection.send_message( + websocket_api.result_message( + msg["id"], [_entry_dict(entry) for entry in registry.entities.values()] + ) + ) @async_response +@websocket_api.websocket_command( + { + vol.Required("type"): "config/entity_registry/get", + vol.Required("entity_id"): cv.entity_id, + } +) 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']) + 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')) + 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) - )) + connection.send_message( + websocket_api.result_message(msg["id"], _entry_ext_dict(entry)) + ) @require_admin @async_response +@websocket_api.websocket_command( + { + vol.Required("type"): "config/entity_registry/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("icon"): vol.Any(str, None), + vol.Optional("new_entity_id"): str, + # We only allow setting disabled_by user via API. + vol.Optional("disabled_by"): vol.Any("user", None), + } +) async def websocket_update_entity(hass, connection, msg): """Handle update entity websocket command. @@ -97,39 +83,49 @@ async def websocket_update_entity(hass, connection, msg): """ 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')) + 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')) + for key in ("name", "icon", "disabled_by"): + if key in msg: + changes[key] = msg[key] + + 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) + 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) - )) + 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) - )) + connection.send_message( + websocket_api.result_message(msg["id"], _entry_ext_dict(entry)) + ) @require_admin @async_response +@websocket_api.websocket_command( + { + vol.Required("type"): "config/entity_registry/remove", + vol.Required("entity_id"): cv.entity_id, + } +) async def websocket_remove_entity(hass, connection, msg): """Handle remove entity websocket command. @@ -137,23 +133,36 @@ async def websocket_remove_entity(hass, connection, msg): """ 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')) + 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'])) + 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, + "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, + "icon": entry.icon, + "platform": entry.platform, } + + +@callback +def _entry_ext_dict(entry): + """Convert entry to API format.""" + data = _entry_dict(entry) + data["original_name"] = entry.original_name + data["original_icon"] = entry.original_icon + data["unique_id"] = entry.unique_id + data["capabilities"] = entry.capabilities + return data diff --git a/homeassistant/components/config/group.py b/homeassistant/components/config/group.py index 60421bcc12559..e26b2b80bc13f 100644 --- a/homeassistant/components/config/group.py +++ b/homeassistant/components/config/group.py @@ -1,21 +1,27 @@ """Provide configuration end points for Groups.""" from homeassistant.components.group import DOMAIN, GROUP_SCHEMA +from homeassistant.config import GROUP_CONFIG_PATH 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): + + async def hook(action, config_key): """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 - )) + hass.http.register_view( + EditKeyBasedConfigView( + "group", + "config", + GROUP_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 index 9c0c50a25957e..57dfd0d360a28 100644 --- a/homeassistant/components/config/manifest.json +++ b/homeassistant/components/config/manifest.json @@ -1,12 +1,8 @@ { "domain": "config", - "name": "Config", - "documentation": "https://www.home-assistant.io/components/config", - "requirements": [], - "dependencies": [ - "http" - ], - "codeowners": [ - "@home-assistant/core" - ] + "name": "Configuration", + "documentation": "https://www.home-assistant.io/integrations/config", + "dependencies": ["http"], + "codeowners": ["@home-assistant/core"], + "quality_scale": "internal" } diff --git a/homeassistant/components/config/scene.py b/homeassistant/components/config/scene.py new file mode 100644 index 0000000000000..19cfb7cd31a2b --- /dev/null +++ b/homeassistant/components/config/scene.py @@ -0,0 +1,78 @@ +"""Provide configuration end points for Scenes.""" +from collections import OrderedDict +import uuid + +from homeassistant.components.scene import DOMAIN, PLATFORM_SCHEMA +from homeassistant.config import SCENE_CONFIG_PATH +from homeassistant.const import CONF_ID, SERVICE_RELOAD +from homeassistant.core import DOMAIN as HA_DOMAIN +from homeassistant.helpers import config_validation as cv, entity_registry + +from . import ACTION_DELETE, EditIdBasedConfigView + + +async def async_setup(hass): + """Set up the Scene config API.""" + + async def hook(action, config_key): + """post_write_hook for Config View that reloads scenes.""" + await hass.services.async_call(DOMAIN, SERVICE_RELOAD) + + if action != ACTION_DELETE: + return + + ent_reg = await entity_registry.async_get_registry(hass) + + entity_id = ent_reg.async_get_entity_id(DOMAIN, HA_DOMAIN, config_key) + + if entity_id is None: + return + + ent_reg.async_remove(entity_id) + + hass.http.register_view( + EditSceneConfigView( + DOMAIN, + "config", + SCENE_CONFIG_PATH, + cv.string, + PLATFORM_SCHEMA, + post_write_hook=hook, + ) + ) + return True + + +class EditSceneConfigView(EditIdBasedConfigView): + """Edit scene 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 scenes 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 = {} + 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", "name", "entities"): + 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/script.py b/homeassistant/components/config/script.py index c8a58e5d72a2f..de9c25b223ff6 100644 --- a/homeassistant/components/config/script.py +++ b/homeassistant/components/config/script.py @@ -1,21 +1,27 @@ """Provide configuration end points for scripts.""" from homeassistant.components.script import DOMAIN, SCRIPT_ENTRY_SCHEMA +from homeassistant.config import SCRIPT_CONFIG_PATH 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): + + async def hook(action, config_key): """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 - )) + hass.http.register_view( + EditKeyBasedConfigView( + "script", + "config", + SCRIPT_CONFIG_PATH, + cv.slug, + SCRIPT_ENTRY_SCHEMA, + post_write_hook=hook, + ) + ) return True diff --git a/homeassistant/components/config/zwave.py b/homeassistant/components/config/zwave.py index e7e39968401d7..b8331d8192b80 100644 --- a/homeassistant/components/config/zwave.py +++ b/homeassistant/components/config/zwave.py @@ -6,23 +6,28 @@ 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 +from homeassistant.const import HTTP_BAD_REQUEST, 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' +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( + 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) @@ -40,23 +45,23 @@ class ZWaveLogView(HomeAssistantView): url = "/api/zwave/ozwlog" name = "api:zwave:ozwlog" -# pylint: disable=no-self-use + # pylint: disable=no-self-use async def get(self, request): """Retrieve the lines from ZWave log.""" try: - lines = int(request.query.get('lines', 0)) + lines = int(request.query.get("lines", 0)) except ValueError: - return Response(text='Invalid datetime', status=400) + return Response(text="Invalid datetime", status=HTTP_BAD_REQUEST) - hass = request.app['hass'] + hass = request.app["hass"] response = await hass.async_add_job(self._get_log, hass, lines) - return Response(text='\n'.join(response)) + 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: + with open(logfilepath) as logfile: data = (line.rstrip() for line in logfile) if lines == 0: loglines = list(data) @@ -74,15 +79,13 @@ class ZWaveConfigWriteView(HomeAssistantView): @ha.callback def post(self, request): """Save cache configuration to zwcfg_xxxxx.xml.""" - hass = request.app['hass'] + 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) + 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) + return self.json_message("Z-Wave configuration saved to file.", HTTP_OK) class ZWaveNodeValueView(HomeAssistantView): @@ -95,7 +98,7 @@ class ZWaveNodeValueView(HomeAssistantView): def get(self, request, node_id): """Retrieve groups of node.""" nodeid = int(node_id) - hass = request.app['hass'] + hass = request.app["hass"] values_list = hass.data[const.DATA_ENTITY_VALUES] values_data = {} @@ -106,10 +109,10 @@ def get(self, request, node_id): 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, + "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) @@ -124,19 +127,20 @@ class ZWaveNodeGroupView(HomeAssistantView): def get(self, request, node_id): """Retrieve groups of node.""" nodeid = int(node_id) - hass = request.app['hass'] + 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) + 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} + groups[key] = { + "associations": value.associations, + "association_instances": value.associations_instances, + "label": value.label, + "max_associations": value.max_associations, + } return self.json(groups) @@ -150,22 +154,24 @@ class ZWaveNodeConfigView(HomeAssistantView): def get(self, request, node_id): """Retrieve configurations of node.""" nodeid = int(node_id) - hass = request.app['hass'] + 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) + 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} + 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) @@ -179,22 +185,22 @@ class ZWaveUserCodeView(HomeAssistantView): def get(self, request, node_id): """Retrieve usercodes of node.""" nodeid = int(node_id) - hass = request.app['hass'] + 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) + 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()): + 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)} + usercodes[value.index] = { + "code": value.data, + "label": value.label, + "length": len(value.data), + } return self.json(usercodes) @@ -207,22 +213,23 @@ class ZWaveProtectionView(HomeAssistantView): async def get(self, request, node_id): """Retrieve the protection commandclass options of node.""" nodeid = int(node_id) - hass = request.app['hass'] + 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) + 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])} + "value_id": "{: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) @@ -230,7 +237,7 @@ def _fetch_protection(): async def post(self, request, node_id): """Change the selected option in protection commandclass.""" nodeid = int(node_id) - hass = request.app['hass'] + hass = request.app["hass"] network = hass.data.get(const.DATA_NETWORK) protection_data = await request.json() @@ -240,15 +247,14 @@ def _set_protection(): 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) + 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) + "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 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/__init__.py b/homeassistant/components/configurator/__init__.py index 74d8339b1fa93..d03dbe1fe7b0e 100644 --- a/homeassistant/components/configurator/__init__.py +++ b/homeassistant/components/configurator/__init__.py @@ -9,51 +9,62 @@ 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.const import ( + ATTR_ENTITY_PICTURE, + ATTR_FRIENDLY_NAME, + EVENT_TIME_CHANGED, +) +from homeassistant.core import Event, callback as async_callback from homeassistant.helpers.entity import async_generate_entity_id +from homeassistant.loader import bind_hass from homeassistant.util.async_ import run_callback_threadsafe _LOGGER = logging.getLogger(__name__) -_KEY_INSTANCE = 'configurator' +_KEY_INSTANCE = "configurator" -DATA_REQUESTS = 'configurator_requests' +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' +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' +DOMAIN = "configurator" -ENTITY_ID_FORMAT = DOMAIN + '.{}' +ENTITY_ID_FORMAT = DOMAIN + ".{}" -SERVICE_CONFIGURE = 'configure' -STATE_CONFIGURE = 'configure' -STATE_CONFIGURED = 'configured' +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): + 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) + description += f"\n\n[{link_name}]({link_url})" if description_image is not None: - description += '\n\n![Description image]({})'.format(description_image) + description += f"\n\n![Description image]({description_image})" instance = hass.data.get(_KEY_INSTANCE) @@ -61,7 +72,8 @@ def async_request_config( instance = hass.data[_KEY_INSTANCE] = Configurator(hass) request_id = instance.async_request_config( - name, callback, description, submit_caption, fields, entity_picture) + name, callback, description, submit_caption, fields, entity_picture + ) if DATA_REQUESTS not in hass.data: hass.data[DATA_REQUESTS] = {} @@ -87,8 +99,7 @@ def request_config(hass, *args, **kwargs): 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) + hass.data[DATA_REQUESTS][request_id].async_notify_errors(request_id, error) except KeyError: # If request_id does not exist pass @@ -135,15 +146,15 @@ def __init__(self, hass): self._cur_id = 0 self._requests = {} hass.services.async_register( - DOMAIN, SERVICE_CONFIGURE, self.async_handle_service_call) + DOMAIN, SERVICE_CONFIGURE, self.async_handle_service_call + ) @async_callback def async_request_config( - self, name, callback, description, submit_caption, fields, - entity_picture): + 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) + entity_id = async_generate_entity_id(ENTITY_ID_FORMAT, name, hass=self.hass) if fields is None: fields = [] @@ -159,12 +170,16 @@ def async_request_config( 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 - }) + 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) @@ -194,14 +209,14 @@ def async_request_done(self, request_id): 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). + # the result of 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): + def deferred_remove(event: Event): """Remove the request state.""" - self.hass.states.async_remove(entity_id) + self.hass.states.async_remove(entity_id, context=event.context) self.hass.bus.async_listen_once(EVENT_TIME_CHANGED, deferred_remove) @@ -217,13 +232,12 @@ async def async_handle_service_call(self, call): # field validation goes here? if callback: - await self.hass.async_add_job(callback, - call.data.get(ATTR_FIELDS, {})) + 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) + return f"{id(self)}-{self._cur_id}" def _validate_request_id(self, request_id): """Validate that the request belongs to this instance.""" diff --git a/homeassistant/components/configurator/manifest.json b/homeassistant/components/configurator/manifest.json index f01fe7324fa49..acd0fa80423b9 100644 --- a/homeassistant/components/configurator/manifest.json +++ b/homeassistant/components/configurator/manifest.json @@ -1,10 +1,7 @@ { "domain": "configurator", "name": "Configurator", - "documentation": "https://www.home-assistant.io/components/configurator", - "requirements": [], - "dependencies": [], - "codeowners": [ - "@home-assistant/core" - ] + "documentation": "https://www.home-assistant.io/integrations/configurator", + "codeowners": ["@home-assistant/core"], + "quality_scale": "internal" } diff --git a/homeassistant/components/configurator/strings.json b/homeassistant/components/configurator/strings.json new file mode 100644 index 0000000000000..570c18d3cde1f --- /dev/null +++ b/homeassistant/components/configurator/strings.json @@ -0,0 +1,9 @@ +{ + "title": "Configurator", + "state": { + "_": { + "configure": "Configure", + "configured": "Configured" + } + } +} diff --git a/homeassistant/components/configurator/translations/af.json b/homeassistant/components/configurator/translations/af.json new file mode 100644 index 0000000000000..494c8fb0293aa --- /dev/null +++ b/homeassistant/components/configurator/translations/af.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "configure": "Stel op", + "configured": "Opgestel" + } + }, + "title": "Konfigureerder" +} \ No newline at end of file diff --git a/homeassistant/components/configurator/translations/ar.json b/homeassistant/components/configurator/translations/ar.json new file mode 100644 index 0000000000000..0e0be047a224d --- /dev/null +++ b/homeassistant/components/configurator/translations/ar.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "configure": "\u0625\u0639\u062f\u0627\u062f", + "configured": "\u062a\u0645 \u0627\u0644\u0625\u0639\u062f\u0627\u062f" + } + }, + "title": "\u0627\u0644\u0625\u0639\u062f\u0627\u062f\u0627\u062a" +} \ No newline at end of file diff --git a/homeassistant/components/configurator/translations/bg.json b/homeassistant/components/configurator/translations/bg.json new file mode 100644 index 0000000000000..bf5990d8fced5 --- /dev/null +++ b/homeassistant/components/configurator/translations/bg.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "configure": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0432\u0430\u043d\u0435", + "configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0435\u043d" + } + }, + "title": "\u041a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0442\u043e\u0440" +} \ No newline at end of file diff --git a/homeassistant/components/configurator/translations/bs.json b/homeassistant/components/configurator/translations/bs.json new file mode 100644 index 0000000000000..643bd65489d84 --- /dev/null +++ b/homeassistant/components/configurator/translations/bs.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "configure": "Podesite", + "configured": "Konfigurirano" + } + }, + "title": "Konfigurator" +} \ No newline at end of file diff --git a/homeassistant/components/configurator/translations/ca.json b/homeassistant/components/configurator/translations/ca.json new file mode 100644 index 0000000000000..0a4ea1ab6fafc --- /dev/null +++ b/homeassistant/components/configurator/translations/ca.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "configure": "Configurar", + "configured": "Configurat" + } + }, + "title": "Configurador" +} \ No newline at end of file diff --git a/homeassistant/components/configurator/translations/cs.json b/homeassistant/components/configurator/translations/cs.json new file mode 100644 index 0000000000000..dcd1b4ee91e72 --- /dev/null +++ b/homeassistant/components/configurator/translations/cs.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "configure": "Nakonfigurovat", + "configured": "Nakonfigurov\u00e1no" + } + }, + "title": "Konfigur\u00e1tor" +} \ No newline at end of file diff --git a/homeassistant/components/configurator/translations/cy.json b/homeassistant/components/configurator/translations/cy.json new file mode 100644 index 0000000000000..0712f69b211a1 --- /dev/null +++ b/homeassistant/components/configurator/translations/cy.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "configure": "Ffurfweddu", + "configured": "Wedi'i ffurfweddu" + } + }, + "title": "Ffurfweddwr" +} \ No newline at end of file diff --git a/homeassistant/components/configurator/translations/da.json b/homeassistant/components/configurator/translations/da.json new file mode 100644 index 0000000000000..476dac71ee303 --- /dev/null +++ b/homeassistant/components/configurator/translations/da.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "configure": "Konfigurer", + "configured": "Konfigureret" + } + }, + "title": "Konfigurator" +} \ No newline at end of file diff --git a/homeassistant/components/configurator/translations/de.json b/homeassistant/components/configurator/translations/de.json new file mode 100644 index 0000000000000..6fd69086bcd9e --- /dev/null +++ b/homeassistant/components/configurator/translations/de.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "configure": "Konfigurieren", + "configured": "Konfiguriert" + } + }, + "title": "Konfigurator" +} \ No newline at end of file diff --git a/homeassistant/components/configurator/translations/el.json b/homeassistant/components/configurator/translations/el.json new file mode 100644 index 0000000000000..a8242694284f0 --- /dev/null +++ b/homeassistant/components/configurator/translations/el.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "configure": "\u0394\u03b9\u03b1\u03bc\u03bf\u03c1\u03c6\u03ce\u03c3\u03c4\u03b5", + "configured": "\u0394\u03b9\u03b1\u03bc\u03bf\u03c1\u03c6\u03ce\u03b8\u03b7\u03ba\u03b5" + } + }, + "title": "\u0394\u03b9\u03b1\u03bc\u03bf\u03c1\u03c6\u03c9\u03c4\u03ae\u03c2" +} \ No newline at end of file diff --git a/homeassistant/components/configurator/translations/en.json b/homeassistant/components/configurator/translations/en.json new file mode 100644 index 0000000000000..3a4b210f36323 --- /dev/null +++ b/homeassistant/components/configurator/translations/en.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "configure": "Configure", + "configured": "Configured" + } + }, + "title": "Configurator" +} \ No newline at end of file diff --git a/homeassistant/components/configurator/translations/es-419.json b/homeassistant/components/configurator/translations/es-419.json new file mode 100644 index 0000000000000..dffb90e6d4980 --- /dev/null +++ b/homeassistant/components/configurator/translations/es-419.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "configure": "Configurar", + "configured": "Configurado" + } + }, + "title": "Configurador" +} \ No newline at end of file diff --git a/homeassistant/components/configurator/translations/es.json b/homeassistant/components/configurator/translations/es.json new file mode 100644 index 0000000000000..dffb90e6d4980 --- /dev/null +++ b/homeassistant/components/configurator/translations/es.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "configure": "Configurar", + "configured": "Configurado" + } + }, + "title": "Configurador" +} \ No newline at end of file diff --git a/homeassistant/components/configurator/translations/et.json b/homeassistant/components/configurator/translations/et.json new file mode 100644 index 0000000000000..7bee612568589 --- /dev/null +++ b/homeassistant/components/configurator/translations/et.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "configure": "Seadista", + "configured": "Seadistatud" + } + }, + "title": "Seadistaja" +} \ No newline at end of file diff --git a/homeassistant/components/configurator/translations/eu.json b/homeassistant/components/configurator/translations/eu.json new file mode 100644 index 0000000000000..fafcf6863f388 --- /dev/null +++ b/homeassistant/components/configurator/translations/eu.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "configure": "Konfiguratu", + "configured": "Konfiguratuta" + } + }, + "title": "Konfiguratzailea" +} \ No newline at end of file diff --git a/homeassistant/components/configurator/translations/fa.json b/homeassistant/components/configurator/translations/fa.json new file mode 100644 index 0000000000000..8eeb6b1385e26 --- /dev/null +++ b/homeassistant/components/configurator/translations/fa.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "configure": "\u067e\u06cc\u06a9\u0631\u0628\u0646\u062f\u06cc", + "configured": "\u067e\u06cc\u06a9\u0631\u0628\u0646\u062f\u06cc \u0634\u062f\u0647" + } + }, + "title": "\u062a\u0646\u0638\u06cc\u0645 \u06a9\u0646\u0646\u062f\u0647" +} \ No newline at end of file diff --git a/homeassistant/components/configurator/translations/fi.json b/homeassistant/components/configurator/translations/fi.json new file mode 100644 index 0000000000000..88c1583182abd --- /dev/null +++ b/homeassistant/components/configurator/translations/fi.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "configure": "M\u00e4\u00e4rittele", + "configured": "M\u00e4\u00e4ritetty" + } + }, + "title": "Asetukset" +} \ No newline at end of file diff --git a/homeassistant/components/configurator/translations/fr.json b/homeassistant/components/configurator/translations/fr.json new file mode 100644 index 0000000000000..01dd299abe866 --- /dev/null +++ b/homeassistant/components/configurator/translations/fr.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "configure": "Configurer", + "configured": "Configur\u00e9" + } + }, + "title": "Configurateur" +} \ No newline at end of file diff --git a/homeassistant/components/configurator/translations/gsw.json b/homeassistant/components/configurator/translations/gsw.json new file mode 100644 index 0000000000000..7538d2dad51f3 --- /dev/null +++ b/homeassistant/components/configurator/translations/gsw.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "configure": "Konfiguri\u00e4r\u00e4", + "configured": "Konfiguri\u00e4rt" + } + }, + "title": "Configurator" +} \ No newline at end of file diff --git a/homeassistant/components/configurator/translations/he.json b/homeassistant/components/configurator/translations/he.json new file mode 100644 index 0000000000000..7cc7aad41d736 --- /dev/null +++ b/homeassistant/components/configurator/translations/he.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "configure": "\u05d4\u05d2\u05d3\u05e8", + "configured": "\u05d4\u05d5\u05d2\u05d3\u05e8" + } + }, + "title": "\u05e7\u05d5\u05e0\u05e4\u05d9\u05d2\u05d5\u05e8\u05d8\u05d5\u05e8" +} \ No newline at end of file diff --git a/homeassistant/components/configurator/translations/hr.json b/homeassistant/components/configurator/translations/hr.json new file mode 100644 index 0000000000000..f336542f78771 --- /dev/null +++ b/homeassistant/components/configurator/translations/hr.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "configure": "Konfiguriranje", + "configured": "Konfiguriran" + } + }, + "title": "Konfigurator" +} \ No newline at end of file diff --git a/homeassistant/components/configurator/translations/hu.json b/homeassistant/components/configurator/translations/hu.json new file mode 100644 index 0000000000000..eda4d16bc1bd5 --- /dev/null +++ b/homeassistant/components/configurator/translations/hu.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "configure": "Be\u00e1ll\u00edt\u00e1s", + "configured": "Be\u00e1ll\u00edtva" + } + }, + "title": "Konfigur\u00e1tor" +} \ No newline at end of file diff --git a/homeassistant/components/configurator/translations/hy.json b/homeassistant/components/configurator/translations/hy.json new file mode 100644 index 0000000000000..4ce35563baefd --- /dev/null +++ b/homeassistant/components/configurator/translations/hy.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "configure": "\u053f\u0561\u0580\u0563\u0561\u057e\u0578\u0580\u0565\u056c", + "configured": "\u053f\u0561\u0580\u0563\u0561\u057e\u0578\u0580\u057e\u0561\u056e" + } + }, + "title": "\u053f\u0561\u0580\u0563\u0561\u057e\u0578\u0580\u056b\u0579" +} \ No newline at end of file diff --git a/homeassistant/components/configurator/translations/id.json b/homeassistant/components/configurator/translations/id.json new file mode 100644 index 0000000000000..759af513228a3 --- /dev/null +++ b/homeassistant/components/configurator/translations/id.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "configure": "Konfigurasi", + "configured": "Terkonfigurasi" + } + }, + "title": "Konfigurator" +} \ No newline at end of file diff --git a/homeassistant/components/configurator/translations/is.json b/homeassistant/components/configurator/translations/is.json new file mode 100644 index 0000000000000..93a92a804b3d6 --- /dev/null +++ b/homeassistant/components/configurator/translations/is.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "configure": "Stilli", + "configured": "Stillt" + } + }, + "title": "Stillingar\u00e1lfur" +} \ No newline at end of file diff --git a/homeassistant/components/configurator/translations/it.json b/homeassistant/components/configurator/translations/it.json new file mode 100644 index 0000000000000..b8610b76d9d31 --- /dev/null +++ b/homeassistant/components/configurator/translations/it.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "configure": "Configurare", + "configured": "Configurato" + } + }, + "title": "Configuratore" +} \ No newline at end of file diff --git a/homeassistant/components/configurator/translations/ja.json b/homeassistant/components/configurator/translations/ja.json new file mode 100644 index 0000000000000..44c6ef349c003 --- /dev/null +++ b/homeassistant/components/configurator/translations/ja.json @@ -0,0 +1,8 @@ +{ + "state": { + "_": { + "configure": "\u8a2d\u5b9a", + "configured": "\u8a2d\u5b9a\u6e08\u307f" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/configurator/translations/ko.json b/homeassistant/components/configurator/translations/ko.json new file mode 100644 index 0000000000000..58bf663fefd82 --- /dev/null +++ b/homeassistant/components/configurator/translations/ko.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "configure": "\uc124\uc815", + "configured": "\uc124\uc815\ub428" + } + }, + "title": "\uad6c\uc131" +} \ No newline at end of file diff --git a/homeassistant/components/configurator/translations/lb.json b/homeassistant/components/configurator/translations/lb.json new file mode 100644 index 0000000000000..504ed491b5d5e --- /dev/null +++ b/homeassistant/components/configurator/translations/lb.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "configure": "Astellen", + "configured": "Agestallt" + } + }, + "title": "Konfigur\u00e9ieren" +} \ No newline at end of file diff --git a/homeassistant/components/configurator/translations/lv.json b/homeassistant/components/configurator/translations/lv.json new file mode 100644 index 0000000000000..0a73cca9d7a83 --- /dev/null +++ b/homeassistant/components/configurator/translations/lv.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "configure": "Konfigur\u0113t", + "configured": "Konfigur\u0113ts" + } + }, + "title": "Konfigur\u0113t\u0101js" +} \ No newline at end of file diff --git a/homeassistant/components/configurator/translations/nb.json b/homeassistant/components/configurator/translations/nb.json new file mode 100644 index 0000000000000..1f92392058394 --- /dev/null +++ b/homeassistant/components/configurator/translations/nb.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "configure": "Konfigurer", + "configured": "Konfigurert" + } + }, + "title": "Konfigurator" +} \ No newline at end of file diff --git a/homeassistant/components/configurator/translations/nl.json b/homeassistant/components/configurator/translations/nl.json new file mode 100644 index 0000000000000..d8ad5061e0fc8 --- /dev/null +++ b/homeassistant/components/configurator/translations/nl.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "configure": "Configureer", + "configured": "Geconfigureerd" + } + }, + "title": "Configurator" +} \ No newline at end of file diff --git a/homeassistant/components/configurator/translations/nn.json b/homeassistant/components/configurator/translations/nn.json new file mode 100644 index 0000000000000..c359f56cad1b2 --- /dev/null +++ b/homeassistant/components/configurator/translations/nn.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "configure": "Konfigurerer", + "configured": "Konfigurert" + } + }, + "title": "Konfigurator" +} \ No newline at end of file diff --git a/homeassistant/components/configurator/translations/no.json b/homeassistant/components/configurator/translations/no.json new file mode 100644 index 0000000000000..1f92392058394 --- /dev/null +++ b/homeassistant/components/configurator/translations/no.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "configure": "Konfigurer", + "configured": "Konfigurert" + } + }, + "title": "Konfigurator" +} \ No newline at end of file diff --git a/homeassistant/components/configurator/translations/pl.json b/homeassistant/components/configurator/translations/pl.json new file mode 100644 index 0000000000000..45e5af46722a6 --- /dev/null +++ b/homeassistant/components/configurator/translations/pl.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "configure": "skonfiguruj", + "configured": "skonfigurowany" + } + }, + "title": "Konfigurator" +} \ No newline at end of file diff --git a/homeassistant/components/configurator/translations/pt-BR.json b/homeassistant/components/configurator/translations/pt-BR.json new file mode 100644 index 0000000000000..dffb90e6d4980 --- /dev/null +++ b/homeassistant/components/configurator/translations/pt-BR.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "configure": "Configurar", + "configured": "Configurado" + } + }, + "title": "Configurador" +} \ No newline at end of file diff --git a/homeassistant/components/configurator/translations/pt.json b/homeassistant/components/configurator/translations/pt.json new file mode 100644 index 0000000000000..dffb90e6d4980 --- /dev/null +++ b/homeassistant/components/configurator/translations/pt.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "configure": "Configurar", + "configured": "Configurado" + } + }, + "title": "Configurador" +} \ No newline at end of file diff --git a/homeassistant/components/configurator/translations/ro.json b/homeassistant/components/configurator/translations/ro.json new file mode 100644 index 0000000000000..8a205563803ac --- /dev/null +++ b/homeassistant/components/configurator/translations/ro.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "configure": "Configureaz\u0103", + "configured": "Configurat" + } + }, + "title": "Configurator" +} \ No newline at end of file diff --git a/homeassistant/components/configurator/translations/ru.json b/homeassistant/components/configurator/translations/ru.json new file mode 100644 index 0000000000000..57be89551a396 --- /dev/null +++ b/homeassistant/components/configurator/translations/ru.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "configure": "\u041d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c", + "configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0435\u043d\u043e" + } + }, + "title": "\u041a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0442\u043e\u0440" +} \ No newline at end of file diff --git a/homeassistant/components/configurator/translations/sk.json b/homeassistant/components/configurator/translations/sk.json new file mode 100644 index 0000000000000..b4a22864cfb41 --- /dev/null +++ b/homeassistant/components/configurator/translations/sk.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "configure": "Konfigurova\u0165", + "configured": "Nakonfigurovan\u00e9" + } + }, + "title": "Konfigur\u00e1tor" +} \ No newline at end of file diff --git a/homeassistant/components/configurator/translations/sl.json b/homeassistant/components/configurator/translations/sl.json new file mode 100644 index 0000000000000..a612146e91965 --- /dev/null +++ b/homeassistant/components/configurator/translations/sl.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "configure": "Konfiguriraj", + "configured": "Konfigurirano" + } + }, + "title": "Konfigurator" +} \ No newline at end of file diff --git a/homeassistant/components/configurator/translations/sv.json b/homeassistant/components/configurator/translations/sv.json new file mode 100644 index 0000000000000..856be2ae01e09 --- /dev/null +++ b/homeassistant/components/configurator/translations/sv.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "configure": "Konfigurera", + "configured": "Konfigurerad" + } + }, + "title": "Konfiguratorn" +} \ No newline at end of file diff --git a/homeassistant/components/configurator/translations/ta.json b/homeassistant/components/configurator/translations/ta.json new file mode 100644 index 0000000000000..27894b3ba1174 --- /dev/null +++ b/homeassistant/components/configurator/translations/ta.json @@ -0,0 +1,8 @@ +{ + "state": { + "_": { + "configure": "\u0b89\u0bb3\u0bcd\u0bb3\u0bae\u0bc8", + "configured": "\u0b89\u0bb3\u0bcd\u0bb3\u0bae\u0bc8\u0b95\u0bcd\u0b95\u0baa\u0bcd\u0baa\u0b9f\u0bcd\u0b9f\u0ba4\u0bc1" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/configurator/translations/te.json b/homeassistant/components/configurator/translations/te.json new file mode 100644 index 0000000000000..82fba2a671d68 --- /dev/null +++ b/homeassistant/components/configurator/translations/te.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "configure": "\u0c15\u0c3e\u0c28\u0c4d\u0c2b\u0c3f\u0c17\u0c30\u0c4d", + "configured": "\u0c15\u0c3e\u0c28\u0c4d\u0c2b\u0c3f\u0c17\u0c30\u0c4d" + } + }, + "title": "\u0c15\u0c3e\u0c28\u0c4d\u0c2b\u0c3f\u0c17\u0c30\u0c47\u0c1f\u0c30\u0c4d" +} \ No newline at end of file diff --git a/homeassistant/components/configurator/translations/th.json b/homeassistant/components/configurator/translations/th.json new file mode 100644 index 0000000000000..5f82d109f0edd --- /dev/null +++ b/homeassistant/components/configurator/translations/th.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "configure": "\u0e15\u0e31\u0e49\u0e07\u0e04\u0e48\u0e32", + "configured": "\u0e15\u0e31\u0e49\u0e07\u0e04\u0e48\u0e32\u0e41\u0e25\u0e49\u0e27" + } + }, + "title": "\u0e15\u0e31\u0e27\u0e15\u0e31\u0e49\u0e07\u0e04\u0e48\u0e32" +} \ No newline at end of file diff --git a/homeassistant/components/configurator/translations/tr.json b/homeassistant/components/configurator/translations/tr.json new file mode 100644 index 0000000000000..2c78391b56320 --- /dev/null +++ b/homeassistant/components/configurator/translations/tr.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "configure": "Ayarla", + "configured": "Ayarland\u0131" + } + }, + "title": "Yap\u0131land\u0131r\u0131c\u0131" +} \ No newline at end of file diff --git a/homeassistant/components/configurator/translations/uk.json b/homeassistant/components/configurator/translations/uk.json new file mode 100644 index 0000000000000..22c03e565ebe5 --- /dev/null +++ b/homeassistant/components/configurator/translations/uk.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "configure": "\u041d\u0430\u043b\u0430\u0448\u0442\u0443\u0432\u0430\u0442\u0438", + "configured": "\u041d\u0430\u043b\u0430\u0448\u0442\u043e\u0432\u0430\u043d\u043e" + } + }, + "title": "\u041a\u043e\u043d\u0444\u0456\u0433\u0443\u0440\u0430\u0442\u043e\u0440" +} \ No newline at end of file diff --git a/homeassistant/components/configurator/translations/vi.json b/homeassistant/components/configurator/translations/vi.json new file mode 100644 index 0000000000000..4d6bb9c4c4960 --- /dev/null +++ b/homeassistant/components/configurator/translations/vi.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "configure": "C\u1ea5u h\u00ecnh", + "configured": "\u0110\u00e3 c\u1ea5u h\u00ecnh" + } + }, + "title": "Tr\u00ecnh c\u1ea5u h\u00ecnh" +} \ No newline at end of file diff --git a/homeassistant/components/configurator/translations/zh-Hans.json b/homeassistant/components/configurator/translations/zh-Hans.json new file mode 100644 index 0000000000000..78f21d69e15ae --- /dev/null +++ b/homeassistant/components/configurator/translations/zh-Hans.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "configure": "\u8bbe\u7f6e", + "configured": "\u8bbe\u7f6e\u6210\u529f" + } + }, + "title": "\u914d\u7f6e\u5668" +} \ No newline at end of file diff --git a/homeassistant/components/configurator/translations/zh-Hant.json b/homeassistant/components/configurator/translations/zh-Hant.json new file mode 100644 index 0000000000000..f3cd720e1efd0 --- /dev/null +++ b/homeassistant/components/configurator/translations/zh-Hant.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "configure": "\u8a2d\u5b9a", + "configured": "\u8a2d\u5b9a\u6210\u529f" + } + }, + "title": "\u8a2d\u5b9a\u6a94\u7de8\u8f2f\u5668" +} \ No newline at end of file diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py index bd577127fa038..dd17eca679242 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -5,180 +5,175 @@ 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 import http, websocket_api from homeassistant.components.http.data_validator import RequestDataValidator -from homeassistant.const import EVENT_COMPONENT_LOADED -from homeassistant.core import callback +from homeassistant.const import HTTP_INTERNAL_SERVER_ERROR 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 +from .agent import AbstractConversationAgent +from .default_agent import DefaultAgent, async_register _LOGGER = logging.getLogger(__name__) -ATTR_TEXT = 'text' +ATTR_TEXT = "text" -DOMAIN = 'conversation' +DOMAIN = "conversation" -REGEX_TURN_COMMAND = re.compile(r'turn (?P(?: |\w)+) (?P\w+)') -REGEX_TYPE = type(re.compile('')) +REGEX_TYPE = type(re.compile("")) +DATA_AGENT = "conversation_agent" +DATA_CONFIG = "conversation_config" -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 = 'process' +SERVICE_PROCESS_SCHEMA = vol.Schema({vol.Required(ATTR_TEXT): cv.string}) -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, +) -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) +async_register = bind_hass(async_register) # pylint: disable=invalid-name @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)) +def async_set_agent(hass: core.HomeAssistant, agent: AbstractConversationAgent): + """Set the agent to handle the conversations.""" + hass.data[DATA_AGENT] = agent 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] = {} + hass.data[DATA_CONFIG] = config - 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): + async def handle_service(service): """Parse text into commands.""" text = service.data[ATTR_TEXT] - _LOGGER.debug('Processing: <%s>', text) + _LOGGER.debug("Processing: <%s>", text) + agent = await _get_agent(hass) try: - await _process(hass, text) + await agent.async_process(text, service.context) except intent.IntentHandleError as err: - _LOGGER.error('Error processing %s: %s', text, 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) + DOMAIN, SERVICE_PROCESS, handle_service, schema=SERVICE_PROCESS_SCHEMA + ) + hass.http.register_view(ConversationProcessView()) + hass.components.websocket_api.async_register_command(websocket_process) + hass.components.websocket_api.async_register_command(websocket_get_agent_info) + hass.components.websocket_api.async_register_command(websocket_set_onboarding) return True -async def _process(hass, text): - """Process a line of text.""" - intents = hass.data.get(DOMAIN, {}) +@websocket_api.async_response +@websocket_api.websocket_command( + {"type": "conversation/process", "text": str, vol.Optional("conversation_id"): str} +) +async def websocket_process(hass, connection, msg): + """Process text.""" + connection.send_result( + msg["id"], + await _async_converse( + hass, msg["text"], msg.get("conversation_id"), connection.context(msg) + ), + ) + + +@websocket_api.async_response +@websocket_api.websocket_command({"type": "conversation/agent/info"}) +async def websocket_get_agent_info(hass, connection, msg): + """Do we need onboarding.""" + agent = await _get_agent(hass) - for intent_type, matchers in intents.items(): - for matcher in matchers: - match = matcher.match(text) + connection.send_result( + msg["id"], + { + "onboarding": await agent.async_get_onboarding(), + "attribution": agent.attribution, + }, + ) - 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 +@websocket_api.async_response +@websocket_api.websocket_command({"type": "conversation/onboarding/set", "shown": bool}) +async def websocket_set_onboarding(hass, connection, msg): + """Set onboarding status.""" + agent = await _get_agent(hass) + + success = await agent.async_set_onboarding(msg["shown"]) + + if success: + connection.send_result(msg["id"]) + else: + connection.send_error(msg["id"]) class ConversationProcessView(http.HomeAssistantView): - """View to retrieve shopping list content.""" + """View to process text.""" - url = '/api/conversation/process' + url = "/api/conversation/process" name = "api:conversation:process" - @RequestDataValidator(vol.Schema({ - vol.Required('text'): str, - })) + @RequestDataValidator( + vol.Schema({vol.Required("text"): str, vol.Optional("conversation_id"): str}) + ) async def post(self, request, data): """Send a request for processing.""" - hass = request.app['hass'] + 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") + intent_result = await _async_converse( + hass, data["text"], data.get("conversation_id"), self.context(request) + ) + except intent.IntentError as err: + _LOGGER.error("Error handling intent: %s", err) + return self.json( + { + "success": False, + "error": { + "code": str(err.__class__.__name__).lower(), + "message": str(err), + }, + }, + status_code=HTTP_INTERNAL_SERVER_ERROR, + ) return self.json(intent_result) + + +async def _get_agent(hass: core.HomeAssistant) -> AbstractConversationAgent: + """Get the active conversation agent.""" + agent = hass.data.get(DATA_AGENT) + if agent is None: + agent = hass.data[DATA_AGENT] = DefaultAgent(hass) + await agent.async_initialize(hass.data.get(DATA_CONFIG)) + return agent + + +async def _async_converse( + hass: core.HomeAssistant, text: str, conversation_id: str, context: core.Context +) -> intent.IntentResponse: + """Process text and get intent.""" + agent = await _get_agent(hass) + try: + intent_result = await agent.async_process(text, context, conversation_id) + 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 intent_result diff --git a/homeassistant/components/conversation/agent.py b/homeassistant/components/conversation/agent.py new file mode 100644 index 0000000000000..c9c2ab46cf9fd --- /dev/null +++ b/homeassistant/components/conversation/agent.py @@ -0,0 +1,29 @@ +"""Agent foundation for conversation integration.""" +from abc import ABC, abstractmethod +from typing import Optional + +from homeassistant.core import Context +from homeassistant.helpers import intent + + +class AbstractConversationAgent(ABC): + """Abstract conversation agent.""" + + @property + def attribution(self): + """Return the attribution.""" + return None + + async def async_get_onboarding(self): + """Get onboard data.""" + return None + + async def async_set_onboarding(self, shown): + """Set onboard data.""" + return True + + @abstractmethod + async def async_process( + self, text: str, context: Context, conversation_id: Optional[str] = None + ) -> intent.IntentResponse: + """Process a sentence.""" diff --git a/homeassistant/components/conversation/const.py b/homeassistant/components/conversation/const.py new file mode 100644 index 0000000000000..04bfa37306138 --- /dev/null +++ b/homeassistant/components/conversation/const.py @@ -0,0 +1,3 @@ +"""Const for conversation integration.""" + +DOMAIN = "conversation" diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py new file mode 100644 index 0000000000000..2f09cba2eb196 --- /dev/null +++ b/homeassistant/components/conversation/default_agent.py @@ -0,0 +1,137 @@ +"""Standard conversastion implementation for Home Assistant.""" +import logging +import re +from typing import Optional + +from homeassistant import core, setup +from homeassistant.components.cover.intent import INTENT_CLOSE_COVER, INTENT_OPEN_COVER +from homeassistant.components.shopping_list.intent import ( + INTENT_ADD_ITEM, + INTENT_LAST_ITEMS, +) +from homeassistant.const import EVENT_COMPONENT_LOADED +from homeassistant.core import callback +from homeassistant.helpers import intent +from homeassistant.setup import ATTR_COMPONENT + +from .agent import AbstractConversationAgent +from .const import DOMAIN +from .util import create_matcher + +_LOGGER = logging.getLogger(__name__) + +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]"], + }, + "shopping_list": { + INTENT_ADD_ITEM: ["Add [the] [a] [an] {item} to my shopping list"], + INTENT_LAST_ITEMS: ["What is on my shopping list"], + }, +} + + +@core.callback +def async_register(hass, intent_type, utterances): + """Register utterances and any custom intents for the default agent. + + Registrations don't require conversations to be loaded. They will become + active once the conversation component is loaded. + """ + intents = hass.data.setdefault(DOMAIN, {}) + conf = intents.setdefault(intent_type, []) + + for utterance in utterances: + if isinstance(utterance, REGEX_TYPE): + conf.append(utterance) + else: + conf.append(create_matcher(utterance)) + + +class DefaultAgent(AbstractConversationAgent): + """Default agent for conversation agent.""" + + def __init__(self, hass: core.HomeAssistant): + """Initialize the default agent.""" + self.hass = hass + + async def async_initialize(self, config): + """Initialize the default agent.""" + if "intent" not in self.hass.config.components: + await setup.async_setup_component(self.hass, "intent", {}) + + config = config.get(DOMAIN, {}) + intents = self.hass.data.setdefault(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) + + # 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( + self.hass, + intent.INTENT_TURN_ON, + ["Turn [the] [a] {name}[s] on", "Turn on [the] [a] [an] {name}[s]"], + ) + async_register( + self.hass, + intent.INTENT_TURN_OFF, + ["Turn [the] [a] [an] {name}[s] off", "Turn off [the] [a] [an] {name}[s]"], + ) + async_register( + self.hass, + intent.INTENT_TOGGLE, + ["Toggle [the] [a] [an] {name}[s]", "[the] [a] [an] {name}[s] toggle"], + ) + + @callback + def component_loaded(event): + """Handle a new component loaded.""" + self.register_utterances(event.data[ATTR_COMPONENT]) + + self.hass.bus.async_listen(EVENT_COMPONENT_LOADED, component_loaded) + + # Check already loaded components. + for component in self.hass.config.components: + self.register_utterances(component) + + @callback + def register_utterances(self, component): + """Register utterances for a component.""" + if component not in UTTERANCES: + return + for intent_type, sentences in UTTERANCES[component].items(): + async_register(self.hass, intent_type, sentences) + + async def async_process( + self, text: str, context: core.Context, conversation_id: Optional[str] = None + ) -> intent.IntentResponse: + """Process a sentence.""" + intents = self.hass.data[DOMAIN] + + for intent_type, matchers in intents.items(): + for matcher in matchers: + match = matcher.match(text) + + if not match: + continue + + return await intent.async_handle( + self.hass, + DOMAIN, + intent_type, + {key: {"value": value} for key, value in match.groupdict().items()}, + text, + context, + ) diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index ddd3b6205efdd..4f7a8f489bf43 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -1,12 +1,8 @@ { "domain": "conversation", "name": "Conversation", - "documentation": "https://www.home-assistant.io/components/conversation", - "requirements": [], - "dependencies": [ - "http" - ], - "codeowners": [ - "@home-assistant/core" - ] + "documentation": "https://www.home-assistant.io/integrations/conversation", + "dependencies": ["http"], + "codeowners": ["@home-assistant/core"], + "quality_scale": "internal" } diff --git a/homeassistant/components/conversation/services.yaml b/homeassistant/components/conversation/services.yaml index a1b980d8e05a3..032edba8db18f 100644 --- a/homeassistant/components/conversation/services.yaml +++ b/homeassistant/components/conversation/services.yaml @@ -1,10 +1,7 @@ # 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/strings.json b/homeassistant/components/conversation/strings.json new file mode 100644 index 0000000000000..dc6f2b5f52bdd --- /dev/null +++ b/homeassistant/components/conversation/strings.json @@ -0,0 +1 @@ +{ "title": "Conversation" } diff --git a/homeassistant/components/conversation/translations/af.json b/homeassistant/components/conversation/translations/af.json new file mode 100644 index 0000000000000..b74d6fcd9a604 --- /dev/null +++ b/homeassistant/components/conversation/translations/af.json @@ -0,0 +1,3 @@ +{ + "title": "Konversasie" +} \ No newline at end of file diff --git a/homeassistant/components/conversation/translations/ar.json b/homeassistant/components/conversation/translations/ar.json new file mode 100644 index 0000000000000..753558615c73e --- /dev/null +++ b/homeassistant/components/conversation/translations/ar.json @@ -0,0 +1,3 @@ +{ + "title": "\u0645\u062d\u0627\u062f\u062b\u0629" +} \ No newline at end of file diff --git a/homeassistant/components/conversation/translations/bg.json b/homeassistant/components/conversation/translations/bg.json new file mode 100644 index 0000000000000..e0183cbce8c6f --- /dev/null +++ b/homeassistant/components/conversation/translations/bg.json @@ -0,0 +1,3 @@ +{ + "title": "\u0420\u0430\u0437\u0433\u043e\u0432\u043e\u0440" +} \ No newline at end of file diff --git a/homeassistant/components/conversation/translations/bs.json b/homeassistant/components/conversation/translations/bs.json new file mode 100644 index 0000000000000..60795341d2ff2 --- /dev/null +++ b/homeassistant/components/conversation/translations/bs.json @@ -0,0 +1,3 @@ +{ + "title": "Razgovor" +} \ No newline at end of file diff --git a/homeassistant/components/conversation/translations/ca.json b/homeassistant/components/conversation/translations/ca.json new file mode 100644 index 0000000000000..3bdf3862d6ac2 --- /dev/null +++ b/homeassistant/components/conversation/translations/ca.json @@ -0,0 +1,3 @@ +{ + "title": "Conversa" +} \ No newline at end of file diff --git a/homeassistant/components/conversation/translations/cs.json b/homeassistant/components/conversation/translations/cs.json new file mode 100644 index 0000000000000..8f7dfbe50e9fe --- /dev/null +++ b/homeassistant/components/conversation/translations/cs.json @@ -0,0 +1,3 @@ +{ + "title": "Konverzace" +} \ No newline at end of file diff --git a/homeassistant/components/conversation/translations/cy.json b/homeassistant/components/conversation/translations/cy.json new file mode 100644 index 0000000000000..20d6f8fefff61 --- /dev/null +++ b/homeassistant/components/conversation/translations/cy.json @@ -0,0 +1,3 @@ +{ + "title": "Sgwrs" +} \ No newline at end of file diff --git a/homeassistant/components/conversation/translations/da.json b/homeassistant/components/conversation/translations/da.json new file mode 100644 index 0000000000000..b27eaed6e90b3 --- /dev/null +++ b/homeassistant/components/conversation/translations/da.json @@ -0,0 +1,3 @@ +{ + "title": "Samtale" +} \ No newline at end of file diff --git a/homeassistant/components/conversation/translations/de.json b/homeassistant/components/conversation/translations/de.json new file mode 100644 index 0000000000000..aafff25ebacfd --- /dev/null +++ b/homeassistant/components/conversation/translations/de.json @@ -0,0 +1,3 @@ +{ + "title": "Konversation" +} \ No newline at end of file diff --git a/homeassistant/components/conversation/translations/el.json b/homeassistant/components/conversation/translations/el.json new file mode 100644 index 0000000000000..642c5a64ff4ff --- /dev/null +++ b/homeassistant/components/conversation/translations/el.json @@ -0,0 +1,3 @@ +{ + "title": "\u03a3\u03c5\u03bd\u03bf\u03bc\u03b9\u03bb\u03af\u03b1" +} \ No newline at end of file diff --git a/homeassistant/components/conversation/translations/en.json b/homeassistant/components/conversation/translations/en.json new file mode 100644 index 0000000000000..54d9f55a0467b --- /dev/null +++ b/homeassistant/components/conversation/translations/en.json @@ -0,0 +1,3 @@ +{ + "title": "Conversation" +} \ No newline at end of file diff --git a/homeassistant/components/conversation/translations/es-419.json b/homeassistant/components/conversation/translations/es-419.json new file mode 100644 index 0000000000000..2a05f60c1d7aa --- /dev/null +++ b/homeassistant/components/conversation/translations/es-419.json @@ -0,0 +1,3 @@ +{ + "title": "Conversacion" +} \ No newline at end of file diff --git a/homeassistant/components/conversation/translations/es.json b/homeassistant/components/conversation/translations/es.json new file mode 100644 index 0000000000000..bdb615bfc1871 --- /dev/null +++ b/homeassistant/components/conversation/translations/es.json @@ -0,0 +1,3 @@ +{ + "title": "Conversaci\u00f3n" +} \ No newline at end of file diff --git a/homeassistant/components/conversation/translations/et.json b/homeassistant/components/conversation/translations/et.json new file mode 100644 index 0000000000000..679432e2d4a48 --- /dev/null +++ b/homeassistant/components/conversation/translations/et.json @@ -0,0 +1,3 @@ +{ + "title": "Vestlus" +} \ No newline at end of file diff --git a/homeassistant/components/conversation/translations/eu.json b/homeassistant/components/conversation/translations/eu.json new file mode 100644 index 0000000000000..8a4c2d9cd705f --- /dev/null +++ b/homeassistant/components/conversation/translations/eu.json @@ -0,0 +1,3 @@ +{ + "title": "Elkarrizketa" +} \ No newline at end of file diff --git a/homeassistant/components/conversation/translations/fa.json b/homeassistant/components/conversation/translations/fa.json new file mode 100644 index 0000000000000..85516ffaf8306 --- /dev/null +++ b/homeassistant/components/conversation/translations/fa.json @@ -0,0 +1,3 @@ +{ + "title": "\u06af\u0641\u062a\u06af\u0648" +} \ No newline at end of file diff --git a/homeassistant/components/conversation/translations/fi.json b/homeassistant/components/conversation/translations/fi.json new file mode 100644 index 0000000000000..0a6f93565d1b9 --- /dev/null +++ b/homeassistant/components/conversation/translations/fi.json @@ -0,0 +1,3 @@ +{ + "title": "Keskustelu" +} \ No newline at end of file diff --git a/homeassistant/components/conversation/translations/fr.json b/homeassistant/components/conversation/translations/fr.json new file mode 100644 index 0000000000000..54d9f55a0467b --- /dev/null +++ b/homeassistant/components/conversation/translations/fr.json @@ -0,0 +1,3 @@ +{ + "title": "Conversation" +} \ No newline at end of file diff --git a/homeassistant/components/conversation/translations/gsw.json b/homeassistant/components/conversation/translations/gsw.json new file mode 100644 index 0000000000000..05a5a43f51164 --- /dev/null +++ b/homeassistant/components/conversation/translations/gsw.json @@ -0,0 +1,3 @@ +{ + "title": "Gspr\u00e4ch" +} \ No newline at end of file diff --git a/homeassistant/components/conversation/translations/he.json b/homeassistant/components/conversation/translations/he.json new file mode 100644 index 0000000000000..eeccec319afe7 --- /dev/null +++ b/homeassistant/components/conversation/translations/he.json @@ -0,0 +1,3 @@ +{ + "title": "\u05e9\u05c2\u05b4\u05d9\u05d7\u05b8\u05d4" +} \ No newline at end of file diff --git a/homeassistant/components/conversation/translations/hr.json b/homeassistant/components/conversation/translations/hr.json new file mode 100644 index 0000000000000..60795341d2ff2 --- /dev/null +++ b/homeassistant/components/conversation/translations/hr.json @@ -0,0 +1,3 @@ +{ + "title": "Razgovor" +} \ No newline at end of file diff --git a/homeassistant/components/conversation/translations/hu.json b/homeassistant/components/conversation/translations/hu.json new file mode 100644 index 0000000000000..863f34a26974e --- /dev/null +++ b/homeassistant/components/conversation/translations/hu.json @@ -0,0 +1,3 @@ +{ + "title": "Besz\u00e9lget\u00e9s" +} \ No newline at end of file diff --git a/homeassistant/components/conversation/translations/hy.json b/homeassistant/components/conversation/translations/hy.json new file mode 100644 index 0000000000000..d7cd9c6242442 --- /dev/null +++ b/homeassistant/components/conversation/translations/hy.json @@ -0,0 +1,3 @@ +{ + "title": "\u053d\u0578\u057d\u0561\u056f\u0581\u0578\u0582\u0569\u0575\u0578\u0582\u0576" +} \ No newline at end of file diff --git a/homeassistant/components/conversation/translations/id.json b/homeassistant/components/conversation/translations/id.json new file mode 100644 index 0000000000000..3cc821278bbee --- /dev/null +++ b/homeassistant/components/conversation/translations/id.json @@ -0,0 +1,3 @@ +{ + "title": "Percakapan" +} \ No newline at end of file diff --git a/homeassistant/components/conversation/translations/is.json b/homeassistant/components/conversation/translations/is.json new file mode 100644 index 0000000000000..ec14e3986f1d7 --- /dev/null +++ b/homeassistant/components/conversation/translations/is.json @@ -0,0 +1,3 @@ +{ + "title": "Samtal" +} \ No newline at end of file diff --git a/homeassistant/components/conversation/translations/it.json b/homeassistant/components/conversation/translations/it.json new file mode 100644 index 0000000000000..7ee70b92f5f9c --- /dev/null +++ b/homeassistant/components/conversation/translations/it.json @@ -0,0 +1,3 @@ +{ + "title": "Conversazione" +} \ No newline at end of file diff --git a/homeassistant/components/conversation/translations/ko.json b/homeassistant/components/conversation/translations/ko.json new file mode 100644 index 0000000000000..e2aec03cb12cb --- /dev/null +++ b/homeassistant/components/conversation/translations/ko.json @@ -0,0 +1,3 @@ +{ + "title": "\ub300\ud654" +} \ No newline at end of file diff --git a/homeassistant/components/conversation/translations/lb.json b/homeassistant/components/conversation/translations/lb.json new file mode 100644 index 0000000000000..b95eaea7348d9 --- /dev/null +++ b/homeassistant/components/conversation/translations/lb.json @@ -0,0 +1,3 @@ +{ + "title": "\u00cbnnerhalung" +} \ No newline at end of file diff --git a/homeassistant/components/conversation/translations/lv.json b/homeassistant/components/conversation/translations/lv.json new file mode 100644 index 0000000000000..714b10688441b --- /dev/null +++ b/homeassistant/components/conversation/translations/lv.json @@ -0,0 +1,3 @@ +{ + "title": "Saruna" +} \ No newline at end of file diff --git a/homeassistant/components/conversation/translations/nb.json b/homeassistant/components/conversation/translations/nb.json new file mode 100644 index 0000000000000..b27eaed6e90b3 --- /dev/null +++ b/homeassistant/components/conversation/translations/nb.json @@ -0,0 +1,3 @@ +{ + "title": "Samtale" +} \ No newline at end of file diff --git a/homeassistant/components/conversation/translations/nl.json b/homeassistant/components/conversation/translations/nl.json new file mode 100644 index 0000000000000..2b3dcdad5a40d --- /dev/null +++ b/homeassistant/components/conversation/translations/nl.json @@ -0,0 +1,3 @@ +{ + "title": "Conversatie" +} \ No newline at end of file diff --git a/homeassistant/components/conversation/translations/nn.json b/homeassistant/components/conversation/translations/nn.json new file mode 100644 index 0000000000000..b27eaed6e90b3 --- /dev/null +++ b/homeassistant/components/conversation/translations/nn.json @@ -0,0 +1,3 @@ +{ + "title": "Samtale" +} \ No newline at end of file diff --git a/homeassistant/components/conversation/translations/no.json b/homeassistant/components/conversation/translations/no.json new file mode 100644 index 0000000000000..b27eaed6e90b3 --- /dev/null +++ b/homeassistant/components/conversation/translations/no.json @@ -0,0 +1,3 @@ +{ + "title": "Samtale" +} \ No newline at end of file diff --git a/homeassistant/components/conversation/translations/pl.json b/homeassistant/components/conversation/translations/pl.json new file mode 100644 index 0000000000000..00e93934f92e4 --- /dev/null +++ b/homeassistant/components/conversation/translations/pl.json @@ -0,0 +1,3 @@ +{ + "title": "Rozmowa" +} \ No newline at end of file diff --git a/homeassistant/components/conversation/translations/pt-BR.json b/homeassistant/components/conversation/translations/pt-BR.json new file mode 100644 index 0000000000000..20b82694d37dd --- /dev/null +++ b/homeassistant/components/conversation/translations/pt-BR.json @@ -0,0 +1,3 @@ +{ + "title": "Conversa\u00e7\u00e3o" +} \ No newline at end of file diff --git a/homeassistant/components/conversation/translations/pt.json b/homeassistant/components/conversation/translations/pt.json new file mode 100644 index 0000000000000..3bdf3862d6ac2 --- /dev/null +++ b/homeassistant/components/conversation/translations/pt.json @@ -0,0 +1,3 @@ +{ + "title": "Conversa" +} \ No newline at end of file diff --git a/homeassistant/components/conversation/translations/ro.json b/homeassistant/components/conversation/translations/ro.json new file mode 100644 index 0000000000000..c407b2a70b31c --- /dev/null +++ b/homeassistant/components/conversation/translations/ro.json @@ -0,0 +1,3 @@ +{ + "title": "Conversa\u0163ie" +} \ No newline at end of file diff --git a/homeassistant/components/conversation/translations/ru.json b/homeassistant/components/conversation/translations/ru.json new file mode 100644 index 0000000000000..cb8c411d68912 --- /dev/null +++ b/homeassistant/components/conversation/translations/ru.json @@ -0,0 +1,3 @@ +{ + "title": "\u0414\u0438\u0430\u043b\u043e\u0433" +} \ No newline at end of file diff --git a/homeassistant/components/conversation/translations/sk.json b/homeassistant/components/conversation/translations/sk.json new file mode 100644 index 0000000000000..dcb27d50d6963 --- /dev/null +++ b/homeassistant/components/conversation/translations/sk.json @@ -0,0 +1,3 @@ +{ + "title": "Konverz\u00e1cia" +} \ No newline at end of file diff --git a/homeassistant/components/conversation/translations/sl.json b/homeassistant/components/conversation/translations/sl.json new file mode 100644 index 0000000000000..8a24231aeb64d --- /dev/null +++ b/homeassistant/components/conversation/translations/sl.json @@ -0,0 +1,3 @@ +{ + "title": "Pogovor" +} \ No newline at end of file diff --git a/homeassistant/components/conversation/translations/sv.json b/homeassistant/components/conversation/translations/sv.json new file mode 100644 index 0000000000000..ec14e3986f1d7 --- /dev/null +++ b/homeassistant/components/conversation/translations/sv.json @@ -0,0 +1,3 @@ +{ + "title": "Samtal" +} \ No newline at end of file diff --git a/homeassistant/components/conversation/translations/te.json b/homeassistant/components/conversation/translations/te.json new file mode 100644 index 0000000000000..8f3118176df26 --- /dev/null +++ b/homeassistant/components/conversation/translations/te.json @@ -0,0 +1,3 @@ +{ + "title": "\u0c38\u0c02\u0c2d\u0c3e\u0c37\u0c23" +} \ No newline at end of file diff --git a/homeassistant/components/conversation/translations/th.json b/homeassistant/components/conversation/translations/th.json new file mode 100644 index 0000000000000..35921d062e059 --- /dev/null +++ b/homeassistant/components/conversation/translations/th.json @@ -0,0 +1,3 @@ +{ + "title": "\u0e01\u0e32\u0e23\u0e2a\u0e19\u0e17\u0e19\u0e32" +} \ No newline at end of file diff --git a/homeassistant/components/conversation/translations/tr.json b/homeassistant/components/conversation/translations/tr.json new file mode 100644 index 0000000000000..eaff120695232 --- /dev/null +++ b/homeassistant/components/conversation/translations/tr.json @@ -0,0 +1,3 @@ +{ + "title": "Konu\u015fma" +} \ No newline at end of file diff --git a/homeassistant/components/conversation/translations/uk.json b/homeassistant/components/conversation/translations/uk.json new file mode 100644 index 0000000000000..713b6c28dae08 --- /dev/null +++ b/homeassistant/components/conversation/translations/uk.json @@ -0,0 +1,3 @@ +{ + "title": "\u0420\u043e\u0437\u043c\u043e\u0432\u0430" +} \ No newline at end of file diff --git a/homeassistant/components/conversation/translations/vi.json b/homeassistant/components/conversation/translations/vi.json new file mode 100644 index 0000000000000..d8fdbc9b4d864 --- /dev/null +++ b/homeassistant/components/conversation/translations/vi.json @@ -0,0 +1,3 @@ +{ + "title": "H\u1ed9i tho\u1ea1i" +} \ No newline at end of file diff --git a/homeassistant/components/conversation/translations/zh-Hans.json b/homeassistant/components/conversation/translations/zh-Hans.json new file mode 100644 index 0000000000000..ca605ebd370d7 --- /dev/null +++ b/homeassistant/components/conversation/translations/zh-Hans.json @@ -0,0 +1,3 @@ +{ + "title": "\u8bed\u97f3\u5bf9\u8bdd" +} \ No newline at end of file diff --git a/homeassistant/components/conversation/translations/zh-Hant.json b/homeassistant/components/conversation/translations/zh-Hant.json new file mode 100644 index 0000000000000..cfd34df797c85 --- /dev/null +++ b/homeassistant/components/conversation/translations/zh-Hant.json @@ -0,0 +1,3 @@ +{ + "title": "\u8a9e\u97f3\u4e92\u52d5" +} \ No newline at end of file diff --git a/homeassistant/components/conversation/util.py b/homeassistant/components/conversation/util.py index 60d861afdbe4a..4904cb9f990da 100644 --- a/homeassistant/components/conversation/util.py +++ b/homeassistant/components/conversation/util.py @@ -6,13 +6,13 @@ 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) + parts = re.split(r"({\w+}|\[[\w\s]+\] *)", utterance) # Pattern to extract name from GROUP part. Matches {name} - group_matcher = re.compile(r'{(\w+)}') + group_matcher = re.compile(r"{(\w+)}") # Pattern to extract text from OPTIONAL part. Matches [the color] - optional_matcher = re.compile(r'\[([\w ]+)\] *') + optional_matcher = re.compile(r"\[([\w ]+)\] *") - pattern = ['^'] + pattern = ["^"] for part in parts: group_match = group_matcher.match(part) optional_match = optional_matcher.match(part) @@ -24,12 +24,11 @@ def create_matcher(utterance): # Group part if group_match is not None: - pattern.append( - r'(?P<{}>[\w ]+?)\s*'.format(group_match.groups()[0])) + 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(r"(?:{} *)?".format(optional_match.groups()[0])) - pattern.append('$') - return re.compile(''.join(pattern), re.I) + pattern.append("$") + return re.compile("".join(pattern), re.I) diff --git a/homeassistant/components/coolmaster/__init__.py b/homeassistant/components/coolmaster/__init__.py index b27ae5f25b419..c666c39cfb3bd 100644 --- a/homeassistant/components/coolmaster/__init__.py +++ b/homeassistant/components/coolmaster/__init__.py @@ -1 +1,20 @@ -"""The coolmaster component.""" +"""The Coolmaster integration.""" + + +async def async_setup(hass, config): + """Set up Coolmaster components.""" + return True + + +async def async_setup_entry(hass, entry): + """Set up Coolmaster from a config entry.""" + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, "climate") + ) + + return True + + +async def async_unload_entry(hass, entry): + """Unload a Coolmaster config entry.""" + return await hass.config_entries.async_forward_entry_unload(entry, "climate") diff --git a/homeassistant/components/coolmaster/climate.py b/homeassistant/components/coolmaster/climate.py index d6402bd893cab..6e68e858a6d42 100644 --- a/homeassistant/components/coolmaster/climate.py +++ b/homeassistant/components/coolmaster/climate.py @@ -2,44 +2,42 @@ import logging -import voluptuous as vol +from pycoolmasternet import CoolMasterNet -from homeassistant.components.climate import ClimateDevice, PLATFORM_SCHEMA +from homeassistant.components.climate import ClimateEntity 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) + HVAC_MODE_COOL, + HVAC_MODE_DRY, + HVAC_MODE_FAN_ONLY, + HVAC_MODE_HEAT, + HVAC_MODE_HEAT_COOL, + HVAC_MODE_OFF, + SUPPORT_FAN_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 + ATTR_TEMPERATURE, + CONF_HOST, + CONF_PORT, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, +) -SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_FAN_MODE | - SUPPORT_OPERATION_MODE | SUPPORT_ON_OFF) +from .const import CONF_SUPPORTED_MODES, DOMAIN -DEFAULT_PORT = 10102 - -AVAILABLE_MODES = [STATE_HEAT, STATE_COOL, STATE_AUTO, STATE_DRY, - STATE_FAN_ONLY] +SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_FAN_MODE CM_TO_HA_STATE = { - 'heat': STATE_HEAT, - 'cool': STATE_COOL, - 'auto': STATE_AUTO, - 'dry': STATE_DRY, - 'fan': STATE_FAN_ONLY, + "heat": HVAC_MODE_HEAT, + "cool": HVAC_MODE_COOL, + "auto": HVAC_MODE_HEAT_COOL, + "dry": HVAC_MODE_DRY, + "fan": HVAC_MODE_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)]), -}) +FAN_MODES = ["low", "med", "high", "auto"] _LOGGER = logging.getLogger(__name__) @@ -49,30 +47,28 @@ def _build_entity(device, supported_modes): return CoolmasterClimate(device, supported_modes) -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_entry(hass, config_entry, async_add_devices): """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] + supported_modes = config_entry.data.get(CONF_SUPPORTED_MODES) + host = config_entry.data[CONF_HOST] + port = config_entry.data[CONF_PORT] cool = CoolMasterNet(host, port=port) - devices = cool.devices() + devices = await hass.async_add_executor_job(cool.devices) - all_devices = [_build_entity(device, supported_modes) - for device in devices] + all_devices = [_build_entity(device, supported_modes) for device in devices] - add_entities(all_devices, True) + async_add_devices(all_devices, True) -class CoolmasterClimate(ClimateDevice): +class CoolmasterClimate(ClimateEntity): """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._hvac_modes = supported_modes + self._hvac_mode = None self._target_temperature = None self._current_temperature = None self._current_fan_mode = None @@ -83,19 +79,32 @@ def __init__(self, device, supported_modes): 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] + 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"] + if self._on: + self._hvac_mode = CM_TO_HA_STATE[device_mode] + else: + self._hvac_mode = HVAC_MODE_OFF - if status['unit'] == 'celsius': + if status["unit"] == "celsius": self._unit = TEMP_CELSIUS else: self._unit = TEMP_FAHRENHEIT + @property + def device_info(self): + """Return device info for this device.""" + return { + "identifiers": {(DOMAIN, self.unique_id)}, + "name": self.name, + "manufacturer": "CoolAutomation", + "model": "CoolMasterNet", + } + @property def unique_id(self): """Return unique ID for this device.""" @@ -127,27 +136,22 @@ def target_temperature(self): return self._target_temperature @property - def current_operation(self): - """Return current operation ie. heat, cool, idle.""" - return self._current_operation + def hvac_mode(self): + """Return hvac target hvac state.""" + return self._hvac_mode @property - def operation_list(self): + def hvac_modes(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 + return self._hvac_modes @property - def current_fan_mode(self): + def fan_mode(self): """Return the fan setting.""" return self._current_fan_mode @property - def fan_list(self): + def fan_modes(self): """Return the list of available fan modes.""" return FAN_MODES @@ -155,21 +159,23 @@ 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)) + _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) + _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): + def set_hvac_mode(self, hvac_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]) + _LOGGER.debug("Setting operation mode of %s to %s", self.unique_id, hvac_mode) + + if hvac_mode == HVAC_MODE_OFF: + self.turn_off() + else: + self._device.set_mode(HA_STATE_TO_CM[hvac_mode]) + self.turn_on() def turn_on(self): """Turn on.""" diff --git a/homeassistant/components/coolmaster/config_flow.py b/homeassistant/components/coolmaster/config_flow.py new file mode 100644 index 0000000000000..c267b2831181f --- /dev/null +++ b/homeassistant/components/coolmaster/config_flow.py @@ -0,0 +1,64 @@ +"""Config flow to configure Coolmaster.""" + +from pycoolmasternet import CoolMasterNet +import voluptuous as vol + +from homeassistant import config_entries, core +from homeassistant.const import CONF_HOST, CONF_PORT + +# pylint: disable=unused-import +from .const import AVAILABLE_MODES, CONF_SUPPORTED_MODES, DEFAULT_PORT, DOMAIN + +MODES_SCHEMA = {vol.Required(mode, default=True): bool for mode in AVAILABLE_MODES} + +DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str, **MODES_SCHEMA}) + + +async def _validate_connection(hass: core.HomeAssistant, host): + cool = CoolMasterNet(host, port=DEFAULT_PORT) + devices = await hass.async_add_executor_job(cool.devices) + return bool(devices) + + +class CoolmasterConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a Coolmaster config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + @core.callback + def _async_get_entry(self, data): + supported_modes = [ + key for (key, value) in data.items() if key in AVAILABLE_MODES and value + ] + return self.async_create_entry( + title=data[CONF_HOST], + data={ + CONF_HOST: data[CONF_HOST], + CONF_PORT: DEFAULT_PORT, + CONF_SUPPORTED_MODES: supported_modes, + }, + ) + + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + if user_input is None: + return self.async_show_form(step_id="user", data_schema=DATA_SCHEMA) + + errors = {} + + host = user_input[CONF_HOST] + + try: + result = await _validate_connection(self.hass, host) + if not result: + errors["base"] = "no_units" + except (ConnectionRefusedError, TimeoutError): + errors["base"] = "connection_error" + + if errors: + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + return self._async_get_entry(user_input) diff --git a/homeassistant/components/coolmaster/const.py b/homeassistant/components/coolmaster/const.py new file mode 100644 index 0000000000000..d4cfea738209e --- /dev/null +++ b/homeassistant/components/coolmaster/const.py @@ -0,0 +1,25 @@ +"""Constants for the Coolmaster integration.""" + +from homeassistant.components.climate.const import ( + HVAC_MODE_COOL, + HVAC_MODE_DRY, + HVAC_MODE_FAN_ONLY, + HVAC_MODE_HEAT, + HVAC_MODE_HEAT_COOL, + HVAC_MODE_OFF, +) + +DOMAIN = "coolmaster" + +DEFAULT_PORT = 10102 + +CONF_SUPPORTED_MODES = "supported_modes" + +AVAILABLE_MODES = [ + HVAC_MODE_OFF, + HVAC_MODE_HEAT, + HVAC_MODE_COOL, + HVAC_MODE_DRY, + HVAC_MODE_HEAT_COOL, + HVAC_MODE_FAN_ONLY, +] diff --git a/homeassistant/components/coolmaster/manifest.json b/homeassistant/components/coolmaster/manifest.json index 9489dc72689e5..bc0ebd17d40ee 100644 --- a/homeassistant/components/coolmaster/manifest.json +++ b/homeassistant/components/coolmaster/manifest.json @@ -1,12 +1,8 @@ { "domain": "coolmaster", - "name": "Coolmaster", - "documentation": "https://www.home-assistant.io/components/coolmaster", - "requirements": [ - "pycoolmasternet==0.0.4" - ], - "dependencies": [], - "codeowners": [ - "@OnFreund" - ] + "name": "CoolMasterNet", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/coolmaster", + "requirements": ["pycoolmasternet==0.0.4"], + "codeowners": ["@OnFreund"] } diff --git a/homeassistant/components/coolmaster/strings.json b/homeassistant/components/coolmaster/strings.json new file mode 100644 index 0000000000000..3bb5d3ad4e171 --- /dev/null +++ b/homeassistant/components/coolmaster/strings.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "user": { + "title": "Setup your CoolMasterNet connection details.", + "data": { + "host": "Host", + "off": "Can be turned off", + "heat": "Support heat mode", + "cool": "Support cool mode", + "heat_cool": "Support automatic heat/cool mode", + "dry": "Support dry mode", + "fan_only": "Support fan only mode" + } + } + }, + "error": { + "connection_error": "Failed to connect to CoolMasterNet instance. Please check your host.", + "no_units": "Could not find any HVAC units in CoolMasterNet host." + } + } +} diff --git a/homeassistant/components/coolmaster/translations/bg.json b/homeassistant/components/coolmaster/translations/bg.json new file mode 100644 index 0000000000000..a7fff4f036dba --- /dev/null +++ b/homeassistant/components/coolmaster/translations/bg.json @@ -0,0 +1,22 @@ +{ + "config": { + "error": { + "connection_error": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435 \u0441 CoolMasterNet. \u041c\u043e\u043b\u044f, \u043f\u0440\u043e\u0432\u0435\u0440\u0435\u0442\u0435 \u0430\u0434\u0440\u0435\u0441\u0430.", + "no_units": "\u041d\u0435 \u0431\u044f\u0445\u0430 \u043d\u0430\u043c\u0435\u0440\u0435\u043d\u0438 \u043a\u043b\u0438\u043c\u0430\u0442\u0438\u0447\u043d\u0438/\u0432\u0435\u043d\u0442\u0438\u043b\u0430\u0446\u0438\u043e\u043d\u043d\u0438 \u0441\u0438\u0441\u0442\u0435\u043c\u0438 \u043d\u0430 \u0437\u0430\u0434\u0430\u0434\u0435\u043d\u0438\u044f CoolMasterNet \u0430\u0434\u0440\u0435\u0441." + }, + "step": { + "user": { + "data": { + "cool": "\u041f\u043e\u0434\u0434\u0440\u044a\u0436\u043a\u0430 \u043d\u0430 \u0440\u0435\u0436\u0438\u043c \u043e\u0445\u043b\u0430\u0436\u0434\u0430\u043d\u0435", + "dry": "\u041f\u043e\u0434\u0434\u0440\u044a\u0436\u043a\u0430 \u043d\u0430 \u0440\u0435\u0436\u0438\u043c \u0438\u0437\u0441\u0443\u0448\u0430\u0432\u0430\u043d\u0435", + "fan_only": "\u041f\u043e\u0434\u0434\u0440\u044a\u0436\u043a\u0430 \u043d\u0430 \u0440\u0435\u0436\u0438\u043c \u0432\u0435\u043d\u0442\u0438\u043b\u0430\u0442\u043e\u0440", + "heat": "\u041f\u043e\u0434\u0434\u0440\u044a\u0436\u043a\u0430 \u043d\u0430 \u0440\u0435\u0436\u0438\u043c \u043e\u0442\u043e\u043f\u043b\u0435\u043d\u0438\u0435", + "heat_cool": "\u041f\u043e\u0434\u0434\u0440\u044a\u0436\u043a\u0430 \u043d\u0430 \u0430\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0447\u0435\u043d \u0440\u0435\u0436\u0438\u043c \u043e\u0442\u043e\u043f\u043b\u0435\u043d\u0438\u0435/\u043e\u0445\u043b\u0430\u0436\u0434\u0430\u043d\u0435", + "host": "\u0410\u0434\u0440\u0435\u0441", + "off": "\u041c\u043e\u0436\u0435 \u0434\u0430 \u0431\u044a\u0434\u0435 \u0438\u0437\u043a\u043b\u044e\u0447\u0435\u043d" + }, + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 \u0441\u0432\u043e\u0438\u0442\u0435 \u0434\u0430\u043d\u043d\u0438 \u0437\u0430 \u0432\u0440\u044a\u0437\u043a\u0430 \u0441 CoolMasterNet." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coolmaster/translations/ca.json b/homeassistant/components/coolmaster/translations/ca.json new file mode 100644 index 0000000000000..fb256e52f8824 --- /dev/null +++ b/homeassistant/components/coolmaster/translations/ca.json @@ -0,0 +1,22 @@ +{ + "config": { + "error": { + "connection_error": "No s'ha pogut connectar amb la inst\u00e0ncia de CoolMasterNet. Comprova l'amfitri\u00f3.", + "no_units": "No s'ha pogut trobar cap unitat d'HVAC a l'amfitri\u00f3 de CoolMasterNet." + }, + "step": { + "user": { + "data": { + "cool": "Suporta mode refredar", + "dry": "Suporta mode assecar", + "fan_only": "Suporta nom\u00e9s mode ventiladoci\u00f3", + "heat": "Suporta mode escalfar", + "heat_cool": "Suporta mode escalfar/refredar autom\u00e0tic", + "host": "Amfitri\u00f3", + "off": "Es pot apagar" + }, + "title": "Configuraci\u00f3 de la connexi\u00f3 amb CoolMasterNet." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coolmaster/translations/cs.json b/homeassistant/components/coolmaster/translations/cs.json new file mode 100644 index 0000000000000..56e7d591d6b42 --- /dev/null +++ b/homeassistant/components/coolmaster/translations/cs.json @@ -0,0 +1,22 @@ +{ + "config": { + "error": { + "connection_error": "Nepoda\u0159ilo se p\u0159ipojit k instanci CoolMasterNet. Zkontrolujte pros\u00edm sv\u00e9ho hostitele.", + "no_units": "V hostiteli CoolMasterNet nelze naj\u00edt \u017e\u00e1dn\u00e9 jednotky HVAC." + }, + "step": { + "user": { + "data": { + "cool": "Podpora re\u017eimu chlazen\u00ed", + "dry": "Podpora re\u017eimu vysou\u0161en\u00ed", + "fan_only": "Podpora re\u017eimu pouze ventil\u00e1tor", + "heat": "Podpora re\u017eimu topen\u00ed", + "heat_cool": "Podpora automatick\u00e9ho oh\u0159\u00edv\u00e1n\u00ed/chlazen\u00ed", + "host": "Hostitel", + "off": "Lze vypnout" + }, + "title": "Nastavte podrobnosti p\u0159ipojen\u00ed CoolMasterNet." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coolmaster/translations/da.json b/homeassistant/components/coolmaster/translations/da.json new file mode 100644 index 0000000000000..5cd4b98faf8a5 --- /dev/null +++ b/homeassistant/components/coolmaster/translations/da.json @@ -0,0 +1,22 @@ +{ + "config": { + "error": { + "connection_error": "Kunne ikke oprette forbindelse til CoolMasterNet-instansen. Tjek din v\u00e6rt.", + "no_units": "Kunne ikke finde nogen klimaanl\u00e6g i CoolMasterNet-v\u00e6rt." + }, + "step": { + "user": { + "data": { + "cool": "Underst\u00f8tter k\u00f8lingstilstand", + "dry": "Underst\u00f8tter t\u00f8rringstilstand", + "fan_only": "Underst\u00f8tter kun-bl\u00e6ser-tilstand", + "heat": "Underst\u00f8tter varmetilstand", + "heat_cool": "Underst\u00f8tter automatisk varm/k\u00f8l-tilstand", + "host": "V\u00e6rt", + "off": "Kan slukkes" + }, + "title": "Ops\u00e6t dine CoolMasterNet-forbindelsesdetaljer." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coolmaster/translations/de.json b/homeassistant/components/coolmaster/translations/de.json new file mode 100644 index 0000000000000..b29decd38bcf9 --- /dev/null +++ b/homeassistant/components/coolmaster/translations/de.json @@ -0,0 +1,22 @@ +{ + "config": { + "error": { + "connection_error": "Verbindung zur CoolMasterNet-Instanz fehlgeschlagen. Bitte \u00fcberpr\u00fcfe deinen Host.", + "no_units": "Es wurden keine HVAC-Ger\u00e4te im CoolMasterNet-Host gefunden." + }, + "step": { + "user": { + "data": { + "cool": "Unterst\u00fctzt K\u00fchl-Modus", + "dry": "Unterst\u00fctzt Trockenmodus", + "fan_only": "Unterst\u00fctzt Fan-Only-Modus", + "heat": "Unterst\u00fctzt Heiz-Modus", + "heat_cool": "Unterst\u00fctzung automatische Heiz-/K\u00fchlmodus", + "host": "Host", + "off": "Kann ausgeschaltet werden" + }, + "title": "Richte deine CoolMasterNet-Verbindungsdaten ein." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coolmaster/translations/en.json b/homeassistant/components/coolmaster/translations/en.json new file mode 100644 index 0000000000000..6c09ceb725b94 --- /dev/null +++ b/homeassistant/components/coolmaster/translations/en.json @@ -0,0 +1,22 @@ +{ + "config": { + "error": { + "connection_error": "Failed to connect to CoolMasterNet instance. Please check your host.", + "no_units": "Could not find any HVAC units in CoolMasterNet host." + }, + "step": { + "user": { + "data": { + "cool": "Support cool mode", + "dry": "Support dry mode", + "fan_only": "Support fan only mode", + "heat": "Support heat mode", + "heat_cool": "Support automatic heat/cool mode", + "host": "Host", + "off": "Can be turned off" + }, + "title": "Setup your CoolMasterNet connection details." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coolmaster/translations/es-419.json b/homeassistant/components/coolmaster/translations/es-419.json new file mode 100644 index 0000000000000..e1da9263a0c1b --- /dev/null +++ b/homeassistant/components/coolmaster/translations/es-419.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "off": "Puede ser apagado" + }, + "title": "Configure los detalles de su conexi\u00f3n CoolMasterNet." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coolmaster/translations/es.json b/homeassistant/components/coolmaster/translations/es.json new file mode 100644 index 0000000000000..6835914c5137e --- /dev/null +++ b/homeassistant/components/coolmaster/translations/es.json @@ -0,0 +1,22 @@ +{ + "config": { + "error": { + "connection_error": "Error al conectarse a la instancia de CoolMasterNet. Por favor revise su anfitri\u00f3n.", + "no_units": "No se ha encontrado ninguna unidad HVAC en el host CoolMasterNet." + }, + "step": { + "user": { + "data": { + "cool": "Soporta el modo de enfriamiento", + "dry": "Soporta el modo seco", + "fan_only": "Soporta modo solo ventilador", + "heat": "Soporta modo calor", + "heat_cool": "Soporta el modo autom\u00e1tico de calor/fr\u00edo", + "host": "Host", + "off": "Se puede apagar" + }, + "title": "Configure los detalles de su conexi\u00f3n a CoolMasterNet." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coolmaster/translations/fr.json b/homeassistant/components/coolmaster/translations/fr.json new file mode 100644 index 0000000000000..f790e0187186b --- /dev/null +++ b/homeassistant/components/coolmaster/translations/fr.json @@ -0,0 +1,22 @@ +{ + "config": { + "error": { + "connection_error": "\u00c9chec de la connexion \u00e0 l'instance CoolMasterNet. S'il vous pla\u00eet v\u00e9rifier votre h\u00f4te.", + "no_units": "Impossible de trouver des unit\u00e9s HVAC dans l'h\u00f4te CoolMasterNet." + }, + "step": { + "user": { + "data": { + "cool": "Prise en charge du mode refroidissement", + "dry": "Prise en charge du mode d\u00e9shumidification", + "fan_only": "Prise en charge du mode ventilateur uniquement", + "heat": "Prise en charge du mode chauffage", + "heat_cool": "Prise en charge du mode chauffage / refroidissement automatique", + "host": "H\u00f4te", + "off": "Peut \u00eatre \u00e9teint" + }, + "title": "Configurez les d\u00e9tails de votre connexion CoolMasterNet." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/axis/.translations/hu.json b/homeassistant/components/coolmaster/translations/hu.json similarity index 100% rename from homeassistant/components/axis/.translations/hu.json rename to homeassistant/components/coolmaster/translations/hu.json diff --git a/homeassistant/components/coolmaster/translations/it.json b/homeassistant/components/coolmaster/translations/it.json new file mode 100644 index 0000000000000..33ac306ce1a04 --- /dev/null +++ b/homeassistant/components/coolmaster/translations/it.json @@ -0,0 +1,22 @@ +{ + "config": { + "error": { + "connection_error": "Impossibile connettersi all'istanza CoolMasterNet. Controlla il tuo host.", + "no_units": "Impossibile trovare alcuna unit\u00e0 HVAC nell'host CoolMasterNet." + }, + "step": { + "user": { + "data": { + "cool": "Supporta la modalit\u00e0 fresco", + "dry": "Supporta la modalit\u00e0 asciutto", + "fan_only": "Supporta la modalit\u00e0 solo ventilatore", + "heat": "Supporta la modalit\u00e0 di riscaldamento", + "heat_cool": "Supporta la modalit\u00e0 di riscaldamento/raffreddamento automatica", + "host": "Host", + "off": "Pu\u00f2 essere spento" + }, + "title": "Impostare i dettagli della connessione CoolMasterNet." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coolmaster/translations/ko.json b/homeassistant/components/coolmaster/translations/ko.json new file mode 100644 index 0000000000000..cd9ac7a3970eb --- /dev/null +++ b/homeassistant/components/coolmaster/translations/ko.json @@ -0,0 +1,22 @@ +{ + "config": { + "error": { + "connection_error": "CoolMasterNet \uc778\uc2a4\ud134\uc2a4\uc5d0 \uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. \ud638\uc2a4\ud2b8\ub97c \ud655\uc778\ud574\uc8fc\uc138\uc694.", + "no_units": "CoolMasterNet \ud638\uc2a4\ud2b8\uc5d0\uc11c HVAC \uae30\uae30\ub97c \ucc3e\uc744 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4." + }, + "step": { + "user": { + "data": { + "cool": "\ub0c9\ubc29 \ubaa8\ub4dc \uc9c0\uc6d0", + "dry": "\uc81c\uc2b5 \ubaa8\ub4dc \uc9c0\uc6d0", + "fan_only": "\uc1a1\ud48d \ubaa8\ub4dc \uc9c0\uc6d0", + "heat": "\ub09c\ubc29 \ubaa8\ub4dc \uc9c0\uc6d0", + "heat_cool": "\uc790\ub3d9 \ub0c9/\ub09c\ubc29 \ubaa8\ub4dc \uc9c0\uc6d0", + "host": "\ud638\uc2a4\ud2b8", + "off": "\uc804\uc6d0 \ub044\uae30 \ud5c8\uc6a9" + }, + "title": "CoolMasterNet \uc5f0\uacb0 \uc0c1\uc138\uc815\ubcf4\ub97c \uc124\uc815\ud574\uc8fc\uc138\uc694." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coolmaster/translations/lb.json b/homeassistant/components/coolmaster/translations/lb.json new file mode 100644 index 0000000000000..e010aeb3e66e0 --- /dev/null +++ b/homeassistant/components/coolmaster/translations/lb.json @@ -0,0 +1,22 @@ +{ + "config": { + "error": { + "connection_error": "Feeler beim verbanne mat der CoolMasterNet Instanz. Iwwerpr\u00e9ift w.e.g. \u00e4ren Apparat.", + "no_units": "Konnt keng HVAC Eenheeten am CoolMasterNet Apparat fannen." + }, + "step": { + "user": { + "data": { + "cool": "\u00cbnnerst\u00ebtzt KillModus", + "dry": "\u00cbnnerst\u00ebtzt Dr\u00e9che Modus", + "fan_only": "\u00cbnnerst\u00ebtzt n\u00ebmmen Ventilatiouns Modus", + "heat": "\u00cbnnerst\u00ebtzt H\u00ebtzt Modus", + "heat_cool": "\u00cbnnerst\u00ebtzt automateschen H\u00ebtzt/Kill Modus", + "host": "Apparat", + "off": "Kann ausgeschalt ginn" + }, + "title": "CoolMasterNet Verbindungs Detailer ariichten" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coolmaster/translations/nl.json b/homeassistant/components/coolmaster/translations/nl.json new file mode 100644 index 0000000000000..46fb120375a0f --- /dev/null +++ b/homeassistant/components/coolmaster/translations/nl.json @@ -0,0 +1,22 @@ +{ + "config": { + "error": { + "connection_error": "Kan geen verbinding maken met CoolMasterNet-instantie. Controleer uw host", + "no_units": "Kon geen HVAC units vinden in CoolMasterNet host." + }, + "step": { + "user": { + "data": { + "cool": "Ondersteuning afkoelen modus", + "dry": "Ondersteuning droog modus", + "fan_only": "Ondersteunt alleen ventilatormodus", + "heat": "Ondersteuning warmtemodus", + "heat_cool": "Ondersteuning van automatische warmte/koelmodus", + "host": "Host", + "off": "Kan uitgeschakeld worden" + }, + "title": "Stel uw CoolMasterNet-verbindingsgegevens in." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coolmaster/translations/no.json b/homeassistant/components/coolmaster/translations/no.json new file mode 100644 index 0000000000000..328b113a1824f --- /dev/null +++ b/homeassistant/components/coolmaster/translations/no.json @@ -0,0 +1,22 @@ +{ + "config": { + "error": { + "connection_error": "Kunne ikke koble til CoolMasterNet-forekomsten. Sjekk verten din.", + "no_units": "Kunne ikke finne noen HVAC-enheter i CoolMasterNet vert." + }, + "step": { + "user": { + "data": { + "cool": "St\u00f8tte kj\u00f8lemodus", + "dry": "St\u00f8tt t\u00f8rr modus", + "fan_only": "St\u00f8tt kun modus for vifte", + "heat": "St\u00f8tt varmemodus", + "heat_cool": "St\u00f8tter automatisk varme/kj\u00f8l-modus", + "host": "Vert", + "off": "Kan sl\u00e5s av" + }, + "title": "Konfigurer informasjonen om CoolMasterNet-tilkoblingen." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coolmaster/translations/pl.json b/homeassistant/components/coolmaster/translations/pl.json new file mode 100644 index 0000000000000..9b0e4bc5846a2 --- /dev/null +++ b/homeassistant/components/coolmaster/translations/pl.json @@ -0,0 +1,22 @@ +{ + "config": { + "error": { + "connection_error": "Nie mo\u017cna po\u0142\u0105czy\u0107 z CoolMasterNet. Sprawd\u017a adres hosta.", + "no_units": "Nie mo\u017cna znale\u017a\u0107 urz\u0105dze\u0144 HVAC na ho\u015bcie CoolMasterNet." + }, + "step": { + "user": { + "data": { + "cool": "Obs\u0142uga trybu ch\u0142odzenia", + "dry": "Obs\u0142uga trybu osuszania", + "fan_only": "Obs\u0142uga trybu \"tylko wentylator\"", + "heat": "Obs\u0142uga trybu grzania", + "heat_cool": "Obs\u0142uga automatycznego trybu grzanie/ch\u0142odzenie", + "host": "Nazwa hosta lub adres IP", + "off": "Mo\u017ce by\u0107 wy\u0142\u0105czone" + }, + "title": "Skonfiguruj szczeg\u00f3\u0142y po\u0142\u0105czenia CoolMasterNet." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coolmaster/translations/pt-BR.json b/homeassistant/components/coolmaster/translations/pt-BR.json new file mode 100644 index 0000000000000..bb821341818ed --- /dev/null +++ b/homeassistant/components/coolmaster/translations/pt-BR.json @@ -0,0 +1,17 @@ +{ + "config": { + "step": { + "user": { + "data": { + "cool": "Suporta o modo de resfriamento", + "dry": "Suporta o modo seco", + "fan_only": "Suporte apenas o modo ventilador", + "heat": "Suporta o modo de aquecimento", + "heat_cool": "Suporta o modo de aquecimento/resfriamento autom\u00e1tico", + "host": "Host", + "off": "Pode ser desligado" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coolmaster/translations/ru.json b/homeassistant/components/coolmaster/translations/ru.json new file mode 100644 index 0000000000000..993a66539b2db --- /dev/null +++ b/homeassistant/components/coolmaster/translations/ru.json @@ -0,0 +1,22 @@ +{ + "config": { + "error": { + "connection_error": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f, \u043f\u0440\u043e\u0432\u0435\u0440\u044c\u0442\u0435 \u0430\u0434\u0440\u0435\u0441 \u0445\u043e\u0441\u0442\u0430.", + "no_units": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043d\u0430\u0439\u0442\u0438 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u043e\u0442\u043e\u043f\u043b\u0435\u043d\u0438\u044f, \u0432\u0435\u043d\u0442\u0438\u043b\u044f\u0446\u0438\u0438 \u0438 \u043a\u043e\u043d\u0434\u0438\u0446\u0438\u043e\u043d\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f." + }, + "step": { + "user": { + "data": { + "cool": "\u0420\u0435\u0436\u0438\u043c \u043e\u0445\u043b\u0430\u0436\u0434\u0435\u043d\u0438\u044f", + "dry": "\u0420\u0435\u0436\u0438\u043c \u043e\u0441\u0443\u0448\u0435\u043d\u0438\u044f", + "fan_only": "\u0420\u0435\u0436\u0438\u043c \u0432\u0435\u043d\u0442\u0438\u043b\u044f\u0446\u0438\u0438", + "heat": "\u0420\u0435\u0436\u0438\u043c \u043e\u0431\u043e\u0433\u0440\u0435\u0432\u0430", + "heat_cool": "\u0410\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0447\u0435\u0441\u043a\u0438\u0439 \u0440\u0435\u0436\u0438\u043c", + "host": "\u0425\u043e\u0441\u0442", + "off": "\u0420\u0430\u0437\u0440\u0435\u0448\u0438\u0442\u044c \u043e\u0442\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435" + }, + "title": "CoolMasterNet" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coolmaster/translations/sl.json b/homeassistant/components/coolmaster/translations/sl.json new file mode 100644 index 0000000000000..d97fe244cda33 --- /dev/null +++ b/homeassistant/components/coolmaster/translations/sl.json @@ -0,0 +1,22 @@ +{ + "config": { + "error": { + "connection_error": "Povezava s CoolMasterNet ni uspela. Preverite svojega gostitelja.", + "no_units": "V gostitelju CoolMasterNet ni bilo mogo\u010de najti nobenih enot HVAC." + }, + "step": { + "user": { + "data": { + "cool": "Podpira na\u010din hlajenja", + "dry": "Podpira na\u010din su\u0161enja", + "fan_only": "Podpira samo na\u010din ventilacije", + "heat": "Podpira na\u010din ogrevanja", + "heat_cool": "Podpira samodejni na\u010din ogrevanja / hlajenja", + "host": "Gostitelj", + "off": "Lahko se izklopi" + }, + "title": "Nastavite svoje podatke CoolMasterNet." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coolmaster/translations/sv.json b/homeassistant/components/coolmaster/translations/sv.json new file mode 100644 index 0000000000000..60a26c2102321 --- /dev/null +++ b/homeassistant/components/coolmaster/translations/sv.json @@ -0,0 +1,22 @@ +{ + "config": { + "error": { + "connection_error": "Det gick inte att ansluta till CoolMasterNet-instansen. Kontrollera din v\u00e4rd.", + "no_units": "Det gick inte att hitta n\u00e5gra HVAC-enheter i CoolMasterNet-v\u00e4rden." + }, + "step": { + "user": { + "data": { + "cool": "St\u00f6d svalt l\u00e4ge", + "dry": "St\u00f6d torrl\u00e4ge", + "fan_only": "St\u00f6d endast fl\u00e4ktl\u00e4ge", + "heat": "St\u00f6d v\u00e4rmel\u00e4ge", + "heat_cool": "St\u00f6d automatiskt v\u00e4rme/kyl-l\u00e4ge", + "host": "V\u00e4rd", + "off": "Kan st\u00e4ngas av" + }, + "title": "St\u00e4ll in dina CoolMasterNet-anslutningsdetaljer." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coolmaster/translations/zh-Hant.json b/homeassistant/components/coolmaster/translations/zh-Hant.json new file mode 100644 index 0000000000000..a96bf8bd43208 --- /dev/null +++ b/homeassistant/components/coolmaster/translations/zh-Hant.json @@ -0,0 +1,22 @@ +{ + "config": { + "error": { + "connection_error": "\u9023\u7dda\u81f3 CoolMasterNet \u5931\u6557\uff0c\u8acb\u6aa2\u67e5\u4e3b\u6a5f\u7aef\u3002", + "no_units": "\u7121\u6cd5\u65bc CoolMasterNet \u4e3b\u6a5f\u627e\u5230\u4efb\u4f55 HVAC \u8a2d\u5099\u3002" + }, + "step": { + "user": { + "data": { + "cool": "\u652f\u63f4\u5236\u51b7\u6a21\u5f0f", + "dry": "\u652f\u63f4\u9664\u6fd5\u6a21\u5f0f", + "fan_only": "\u652f\u63f4\u50c5\u9001\u98a8\u6a21\u5f0f", + "heat": "\u652f\u63f4\u4fdd\u6696\u6a21\u5f0f", + "heat_cool": "\u652f\u63f4\u81ea\u52d5\u4fdd\u6696/\u5236\u51b7\u6a21\u5f0f", + "host": "\u4e3b\u6a5f\u7aef", + "off": "\u53ef\u4ee5\u95dc\u9589" + }, + "title": "\u8a2d\u5b9a CoolMasterNet \u9023\u7dda\u8cc7\u8a0a\u3002" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coronavirus/__init__.py b/homeassistant/components/coronavirus/__init__.py new file mode 100644 index 0000000000000..fa8efebe154a8 --- /dev/null +++ b/homeassistant/components/coronavirus/__init__.py @@ -0,0 +1,91 @@ +"""The Coronavirus integration.""" +import asyncio +from datetime import timedelta +import logging + +import async_timeout +import coronavirus + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import aiohttp_client, entity_registry, update_coordinator + +from .const import DOMAIN + +PLATFORMS = ["sensor"] + + +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the Coronavirus component.""" + # Make sure coordinator is initialized. + await get_coordinator(hass) + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up Coronavirus from a config entry.""" + if isinstance(entry.data["country"], int): + hass.config_entries.async_update_entry( + entry, data={**entry.data, "country": entry.title} + ) + + @callback + def _async_migrator(entity_entry: entity_registry.RegistryEntry): + """Migrate away from unstable ID.""" + country, info_type = entity_entry.unique_id.rsplit("-", 1) + if not country.isnumeric(): + return None + return {"new_unique_id": f"{entry.title}-{info_type}"} + + await entity_registry.async_migrate_entries( + hass, entry.entry_id, _async_migrator + ) + + if not entry.unique_id: + hass.config_entries.async_update_entry(entry, unique_id=entry.data["country"]) + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ] + ) + ) + + return unload_ok + + +async def get_coordinator(hass): + """Get the data update coordinator.""" + if DOMAIN in hass.data: + return hass.data[DOMAIN] + + async def async_get_cases(): + with async_timeout.timeout(10): + return { + case.country: case + for case in await coronavirus.get_cases( + aiohttp_client.async_get_clientsession(hass) + ) + } + + hass.data[DOMAIN] = update_coordinator.DataUpdateCoordinator( + hass, + logging.getLogger(__name__), + name=DOMAIN, + update_method=async_get_cases, + update_interval=timedelta(hours=1), + ) + await hass.data[DOMAIN].async_refresh() + return hass.data[DOMAIN] diff --git a/homeassistant/components/coronavirus/config_flow.py b/homeassistant/components/coronavirus/config_flow.py new file mode 100644 index 0000000000000..49183dd028eeb --- /dev/null +++ b/homeassistant/components/coronavirus/config_flow.py @@ -0,0 +1,45 @@ +"""Config flow for Coronavirus integration.""" +import logging + +import voluptuous as vol + +from homeassistant import config_entries + +from . import get_coordinator +from .const import DOMAIN, OPTION_WORLDWIDE # pylint:disable=unused-import + +_LOGGER = logging.getLogger(__name__) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Coronavirus.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + _options = None + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + + if self._options is None: + self._options = {OPTION_WORLDWIDE: "Worldwide"} + coordinator = await get_coordinator(self.hass) + for case in sorted( + coordinator.data.values(), key=lambda case: case.country + ): + self._options[case.country] = case.country + + if user_input is not None: + await self.async_set_unique_id(user_input["country"]) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=self._options[user_input["country"]], data=user_input + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({vol.Required("country"): vol.In(self._options)}), + errors=errors, + ) diff --git a/homeassistant/components/coronavirus/const.py b/homeassistant/components/coronavirus/const.py new file mode 100644 index 0000000000000..e1ffa64e88c30 --- /dev/null +++ b/homeassistant/components/coronavirus/const.py @@ -0,0 +1,6 @@ +"""Constants for the Coronavirus integration.""" +from coronavirus import DEFAULT_SOURCE + +DOMAIN = "coronavirus" +OPTION_WORLDWIDE = "__worldwide" +ATTRIBUTION = f"Data provided by {DEFAULT_SOURCE.NAME}" diff --git a/homeassistant/components/coronavirus/manifest.json b/homeassistant/components/coronavirus/manifest.json new file mode 100644 index 0000000000000..5248cf38221be --- /dev/null +++ b/homeassistant/components/coronavirus/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "coronavirus", + "name": "Coronavirus (COVID-19)", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/coronavirus", + "requirements": ["coronavirus==1.1.0"], + "codeowners": ["@home_assistant/core"] +} diff --git a/homeassistant/components/coronavirus/sensor.py b/homeassistant/components/coronavirus/sensor.py new file mode 100644 index 0000000000000..2887427ec6b22 --- /dev/null +++ b/homeassistant/components/coronavirus/sensor.py @@ -0,0 +1,81 @@ +"""Sensor platform for the Corona virus.""" +from homeassistant.const import ATTR_ATTRIBUTION +from homeassistant.helpers.entity import Entity + +from . import get_coordinator +from .const import ATTRIBUTION, OPTION_WORLDWIDE + +SENSORS = { + "confirmed": "mdi:emoticon-neutral-outline", + "current": "mdi:emoticon-sad-outline", + "recovered": "mdi:emoticon-happy-outline", + "deaths": "mdi:emoticon-cry-outline", +} + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Defer sensor setup to the shared sensor module.""" + coordinator = await get_coordinator(hass) + + async_add_entities( + CoronavirusSensor(coordinator, config_entry.data["country"], info_type) + for info_type in SENSORS + ) + + +class CoronavirusSensor(Entity): + """Sensor representing corona virus data.""" + + name = None + unique_id = None + + def __init__(self, coordinator, country, info_type): + """Initialize coronavirus sensor.""" + if country == OPTION_WORLDWIDE: + self.name = f"Worldwide Coronavirus {info_type}" + else: + self.name = f"{coordinator.data[country].country} Coronavirus {info_type}" + self.unique_id = f"{country}-{info_type}" + self.coordinator = coordinator + self.country = country + self.info_type = info_type + + @property + def available(self): + """Return if sensor is available.""" + return self.coordinator.last_update_success and ( + self.country in self.coordinator.data or self.country == OPTION_WORLDWIDE + ) + + @property + def state(self): + """State of the sensor.""" + if self.country == OPTION_WORLDWIDE: + return sum( + getattr(case, self.info_type) for case in self.coordinator.data.values() + ) + + return getattr(self.coordinator.data[self.country], self.info_type) + + @property + def icon(self): + """Return the icon.""" + return SENSORS[self.info_type] + + @property + def unit_of_measurement(self): + """Return unit of measurement.""" + return "people" + + @property + def device_state_attributes(self): + """Return device attributes.""" + return {ATTR_ATTRIBUTION: ATTRIBUTION} + + async def async_added_to_hass(self): + """When entity is added to hass.""" + self.coordinator.async_add_listener(self.async_write_ha_state) + + async def async_will_remove_from_hass(self): + """When entity will be removed from hass.""" + self.coordinator.async_remove_listener(self.async_write_ha_state) diff --git a/homeassistant/components/coronavirus/strings.json b/homeassistant/components/coronavirus/strings.json new file mode 100644 index 0000000000000..949034e6bc7f4 --- /dev/null +++ b/homeassistant/components/coronavirus/strings.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "title": "Pick a country to monitor", + "data": { "country": "Country" } + } + }, + "abort": { "already_configured": "This country is already configured." } + } +} diff --git a/homeassistant/components/coronavirus/translations/ca.json b/homeassistant/components/coronavirus/translations/ca.json new file mode 100644 index 0000000000000..c44da0ab21ad6 --- /dev/null +++ b/homeassistant/components/coronavirus/translations/ca.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "already_configured": "Aquest pa\u00eds ja est\u00e0 configurat." + }, + "step": { + "user": { + "data": { + "country": "Pa\u00eds" + }, + "title": "Tria un pa\u00eds a monitoritzar" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coronavirus/translations/da.json b/homeassistant/components/coronavirus/translations/da.json new file mode 100644 index 0000000000000..c368b5af561df --- /dev/null +++ b/homeassistant/components/coronavirus/translations/da.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "already_configured": "Dette land er allerede konfigureret." + }, + "step": { + "user": { + "data": { + "country": "Land" + }, + "title": "V\u00e6lg et land at overv\u00e5ge" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coronavirus/translations/de.json b/homeassistant/components/coronavirus/translations/de.json new file mode 100644 index 0000000000000..f2aee659bc058 --- /dev/null +++ b/homeassistant/components/coronavirus/translations/de.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "already_configured": "Dieses Land ist bereits konfiguriert." + }, + "step": { + "user": { + "data": { + "country": "Land" + }, + "title": "W\u00e4hlen Sie ein Land aus, das \u00fcberwacht werden soll" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coronavirus/translations/en.json b/homeassistant/components/coronavirus/translations/en.json new file mode 100644 index 0000000000000..f388c73435134 --- /dev/null +++ b/homeassistant/components/coronavirus/translations/en.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "already_configured": "This country is already configured." + }, + "step": { + "user": { + "data": { + "country": "Country" + }, + "title": "Pick a country to monitor" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coronavirus/translations/es.json b/homeassistant/components/coronavirus/translations/es.json new file mode 100644 index 0000000000000..91bd835de7e64 --- /dev/null +++ b/homeassistant/components/coronavirus/translations/es.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "already_configured": "Este pa\u00eds ya est\u00e1 configurado." + }, + "step": { + "user": { + "data": { + "country": "Pa\u00eds" + }, + "title": "Elige un pa\u00eds para monitorizar" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coronavirus/translations/fr.json b/homeassistant/components/coronavirus/translations/fr.json new file mode 100644 index 0000000000000..21a72d80f61ce --- /dev/null +++ b/homeassistant/components/coronavirus/translations/fr.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "already_configured": "Ce pays est d\u00e9j\u00e0 configur\u00e9." + }, + "step": { + "user": { + "data": { + "country": "Pays" + }, + "title": "Choisissez un pays \u00e0 surveiller" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coronavirus/translations/hu.json b/homeassistant/components/coronavirus/translations/hu.json new file mode 100644 index 0000000000000..fcee85c40e829 --- /dev/null +++ b/homeassistant/components/coronavirus/translations/hu.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "already_configured": "Ez az orsz\u00e1g m\u00e1r konfigur\u00e1lva van." + }, + "step": { + "user": { + "data": { + "country": "Orsz\u00e1g" + }, + "title": "V\u00e1lassz egy orsz\u00e1got a megfigyel\u00e9shez" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coronavirus/translations/it.json b/homeassistant/components/coronavirus/translations/it.json new file mode 100644 index 0000000000000..26b40e06ebd70 --- /dev/null +++ b/homeassistant/components/coronavirus/translations/it.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "already_configured": "Questa Nazione \u00e8 gi\u00e0 configurata." + }, + "step": { + "user": { + "data": { + "country": "Nazione" + }, + "title": "Scegliere una Nazione da monitorare" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coronavirus/translations/ko.json b/homeassistant/components/coronavirus/translations/ko.json new file mode 100644 index 0000000000000..65eec9e8bb757 --- /dev/null +++ b/homeassistant/components/coronavirus/translations/ko.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "already_configured": "\uc774 \uad6d\uac00\ub294 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + }, + "step": { + "user": { + "data": { + "country": "\uad6d\uac00" + }, + "title": "\ubaa8\ub2c8\ud130\ub9c1 \ud560 \uad6d\uac00\ub97c \uc120\ud0dd\ud558\uae30" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coronavirus/translations/lb.json b/homeassistant/components/coronavirus/translations/lb.json new file mode 100644 index 0000000000000..916a3e1d20ec3 --- /dev/null +++ b/homeassistant/components/coronavirus/translations/lb.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "already_configured": "D\u00ebst Land ass scho konfigur\u00e9iert" + }, + "step": { + "user": { + "data": { + "country": "Land" + }, + "title": "Wiel ee Land aus fir z'iwwerwaachen" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coronavirus/translations/nl.json b/homeassistant/components/coronavirus/translations/nl.json new file mode 100644 index 0000000000000..d306894f7d03a --- /dev/null +++ b/homeassistant/components/coronavirus/translations/nl.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "already_configured": "Dit land is al geconfigureerd." + }, + "step": { + "user": { + "data": { + "country": "Land" + }, + "title": "Kies een land om te monitoren" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coronavirus/translations/no.json b/homeassistant/components/coronavirus/translations/no.json new file mode 100644 index 0000000000000..359f15b332345 --- /dev/null +++ b/homeassistant/components/coronavirus/translations/no.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "already_configured": "Dette landet er allerede konfigurert." + }, + "step": { + "user": { + "data": { + "country": "Land" + }, + "title": "Velg et land du vil overv\u00e5ke" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coronavirus/translations/pl.json b/homeassistant/components/coronavirus/translations/pl.json new file mode 100644 index 0000000000000..4660aa81ca676 --- /dev/null +++ b/homeassistant/components/coronavirus/translations/pl.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "already_configured": "Ten kraj jest ju\u017c skonfigurowany." + }, + "step": { + "user": { + "data": { + "country": "Kraj" + }, + "title": "Wybierz kraj do monitorowania" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coronavirus/translations/ru.json b/homeassistant/components/coronavirus/translations/ru.json new file mode 100644 index 0000000000000..7a39c547c82ba --- /dev/null +++ b/homeassistant/components/coronavirus/translations/ru.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0434\u043b\u044f \u044d\u0442\u043e\u0433\u043e \u043c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430." + }, + "step": { + "user": { + "data": { + "country": "\u0421\u0442\u0440\u0430\u043d\u0430" + }, + "title": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0441\u0442\u0440\u0430\u043d\u0443 \u0434\u043b\u044f \u043c\u043e\u043d\u0438\u0442\u043e\u0440\u0438\u043d\u0433\u0430" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coronavirus/translations/sl.json b/homeassistant/components/coronavirus/translations/sl.json new file mode 100644 index 0000000000000..4ac4358dfc9ea --- /dev/null +++ b/homeassistant/components/coronavirus/translations/sl.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "already_configured": "Ta dr\u017eava je \u017ee nastavljena." + }, + "step": { + "user": { + "data": { + "country": "Dr\u017eava" + }, + "title": "Izberite dr\u017eavo za spremljanje" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coronavirus/translations/sv.json b/homeassistant/components/coronavirus/translations/sv.json new file mode 100644 index 0000000000000..7e6686c2a0428 --- /dev/null +++ b/homeassistant/components/coronavirus/translations/sv.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "already_configured": "Detta land \u00e4r redan konfigurerat." + }, + "step": { + "user": { + "data": { + "country": "Land" + }, + "title": "V\u00e4lj ett land att \u00f6vervaka" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coronavirus/translations/zh-Hans.json b/homeassistant/components/coronavirus/translations/zh-Hans.json new file mode 100644 index 0000000000000..5bb92ac117226 --- /dev/null +++ b/homeassistant/components/coronavirus/translations/zh-Hans.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "already_configured": "\u6b64\u56fd\u5bb6/\u5730\u533a\u5df2\u914d\u7f6e\u5b8c\u6210\u3002" + }, + "step": { + "user": { + "data": { + "country": "\u56fd\u5bb6/\u5730\u533a" + }, + "title": "\u8bf7\u9009\u62e9\u8981\u76d1\u63a7\u7684\u56fd\u5bb6/\u5730\u533a" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coronavirus/translations/zh-Hant.json b/homeassistant/components/coronavirus/translations/zh-Hant.json new file mode 100644 index 0000000000000..22d5b893e42ce --- /dev/null +++ b/homeassistant/components/coronavirus/translations/zh-Hant.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "already_configured": "\u6b64\u570b\u5bb6\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210\u3002" + }, + "step": { + "user": { + "data": { + "country": "\u570b\u5bb6" + }, + "title": "\u9078\u64c7\u6240\u8981\u76e3\u8996\u7684\u570b\u5bb6" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/counter/__init__.py b/homeassistant/components/counter/__init__.py index 53aa21c91c6d4..ad5e400011687 100644 --- a/homeassistant/components/counter/__init__.py +++ b/homeassistant/components/counter/__init__.py @@ -1,197 +1,284 @@ """Component to count within automations.""" import logging +from typing import Dict, Optional import voluptuous as vol -from homeassistant.const import ATTR_ENTITY_ID, CONF_ICON, CONF_NAME,\ - CONF_MAXIMUM, CONF_MINIMUM - +from homeassistant.const import ( + ATTR_EDITABLE, + CONF_ICON, + CONF_ID, + CONF_MAXIMUM, + CONF_MINIMUM, + CONF_NAME, +) +from homeassistant.core import callback +from homeassistant.helpers import collection import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.storage import Store +from homeassistant.helpers.typing import ConfigType, HomeAssistantType _LOGGER = logging.getLogger(__name__) -ATTR_INITIAL = 'initial' -ATTR_STEP = 'step' -ATTR_MINIMUM = 'minimum' -ATTR_MAXIMUM = 'maximum' +ATTR_INITIAL = "initial" +ATTR_STEP = "step" +ATTR_MINIMUM = "minimum" +ATTR_MAXIMUM = "maximum" +VALUE = "value" -CONF_INITIAL = 'initial' -CONF_RESTORE = 'restore' -CONF_STEP = 'step' +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): +DOMAIN = "counter" + +ENTITY_ID_FORMAT = DOMAIN + ".{}" + +SERVICE_DECREMENT = "decrement" +SERVICE_INCREMENT = "increment" +SERVICE_RESET = "reset" +SERVICE_CONFIGURE = "configure" + +STORAGE_KEY = DOMAIN +STORAGE_VERSION = 1 + +CREATE_FIELDS = { + vol.Optional(CONF_ICON): cv.icon, + vol.Optional(CONF_INITIAL, default=DEFAULT_INITIAL): cv.positive_int, + vol.Required(CONF_NAME): vol.All(cv.string, vol.Length(min=1)), + 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, +} + +UPDATE_FIELDS = { + vol.Optional(CONF_ICON): cv.icon, + vol.Optional(CONF_INITIAL): cv.positive_int, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_MAXIMUM): vol.Any(None, vol.Coerce(int)), + vol.Optional(CONF_MINIMUM): vol.Any(None, vol.Coerce(int)), + vol.Optional(CONF_RESTORE): cv.boolean, + vol.Optional(CONF_STEP): cv.positive_int, +} + + +def _none_to_empty_dict(value): + if value is None: + return {} + return value + + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: cv.schema_with_slug_keys( + vol.All( + _none_to_empty_dict, + { + 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, + }, + ) + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: """Set up the counters.""" component = EntityComponent(_LOGGER, DOMAIN, hass) + id_manager = collection.IDManager() - entities = [] + yaml_collection = collection.YamlCollection( + logging.getLogger(f"{__name__}.yaml_collection"), id_manager + ) + collection.attach_entity_component_collection( + component, yaml_collection, Counter.from_yaml + ) - for object_id, cfg in config[DOMAIN].items(): - if not cfg: - cfg = {} + storage_collection = CounterStorageCollection( + Store(hass, STORAGE_VERSION, STORAGE_KEY), + logging.getLogger(f"{__name__}.storage_collection"), + id_manager, + ) + collection.attach_entity_component_collection( + component, storage_collection, Counter + ) - 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) + await yaml_collection.async_load( + [{CONF_ID: id_, **(conf or {})} for id_, conf in config.get(DOMAIN, {}).items()] + ) + await storage_collection.async_load() - entities.append(Counter(object_id, name, initial, minimum, maximum, - restore, step, icon)) + collection.StorageCollectionWebsocket( + storage_collection, DOMAIN, DOMAIN, CREATE_FIELDS, UPDATE_FIELDS + ).async_setup(hass) - if not entities: - return False + collection.attach_entity_registry_cleaner(hass, DOMAIN, DOMAIN, yaml_collection) + collection.attach_entity_registry_cleaner(hass, DOMAIN, DOMAIN, storage_collection) + component.async_register_entity_service(SERVICE_INCREMENT, {}, "async_increment") + component.async_register_entity_service(SERVICE_DECREMENT, {}, "async_decrement") + component.async_register_entity_service(SERVICE_RESET, {}, "async_reset") 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') + SERVICE_CONFIGURE, + { + 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, + vol.Optional(ATTR_INITIAL): cv.positive_int, + vol.Optional(VALUE): cv.positive_int, + }, + "async_configure", + ) - await component.async_add_entities(entities) return True +class CounterStorageCollection(collection.StorageCollection): + """Input storage based collection.""" + + CREATE_SCHEMA = vol.Schema(CREATE_FIELDS) + UPDATE_SCHEMA = vol.Schema(UPDATE_FIELDS) + + async def _process_create_data(self, data: Dict) -> Dict: + """Validate the config is valid.""" + return self.CREATE_SCHEMA(data) + + @callback + def _get_suggested_id(self, info: Dict) -> str: + """Suggest an ID based on the config.""" + return info[CONF_NAME] + + async def _update_data(self, data: dict, update_data: Dict) -> Dict: + """Return a new updated data object.""" + update_data = self.UPDATE_SCHEMA(update_data) + return {**data, **update_data} + + class Counter(RestoreEntity): """Representation of a counter.""" - def __init__(self, object_id, name, initial, minimum, maximum, - restore, step, icon): + def __init__(self, config: Dict): """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 + self._config: Dict = config + self._state: Optional[int] = config[CONF_INITIAL] + self.editable: bool = True + + @classmethod + def from_yaml(cls, config: Dict) -> "Counter": + """Create counter instance from yaml config.""" + counter = cls(config) + counter.editable = False + counter.entity_id = ENTITY_ID_FORMAT.format(config[CONF_ID]) + return counter @property - def should_poll(self): + def should_poll(self) -> bool: """If entity should be polled.""" return False @property - def name(self): + def name(self) -> Optional[str]: """Return name of the counter.""" - return self._name + return self._config.get(CONF_NAME) @property - def icon(self): + def icon(self) -> Optional[str]: """Return the icon to be used for this entity.""" - return self._icon + return self._config.get(CONF_ICON) @property - def state(self): + def state(self) -> Optional[int]: """Return the current value of the counter.""" return self._state @property - def state_attributes(self): + def state_attributes(self) -> Dict: """Return the state attributes.""" ret = { - ATTR_INITIAL: self._initial, - ATTR_STEP: self._step, + ATTR_EDITABLE: self.editable, + ATTR_INITIAL: self._config[CONF_INITIAL], + ATTR_STEP: self._config[CONF_STEP], } - if self._min is not None: - ret[CONF_MINIMUM] = self._min - if self._max is not None: - ret[CONF_MAXIMUM] = self._max + if self._config[CONF_MINIMUM] is not None: + ret[CONF_MINIMUM] = self._config[CONF_MINIMUM] + if self._config[CONF_MAXIMUM] is not None: + ret[CONF_MAXIMUM] = self._config[CONF_MAXIMUM] return ret - def compute_next_state(self, state): + @property + def unique_id(self) -> Optional[str]: + """Return unique id of the entity.""" + return self._config[CONF_ID] + + def compute_next_state(self, state) -> int: """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) + if self._config[CONF_MINIMUM] is not None: + state = max(self._config[CONF_MINIMUM], state) + if self._config[CONF_MAXIMUM] is not None: + state = min(self._config[CONF_MAXIMUM], state) return state - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """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: + if self._config[CONF_RESTORE]: state = await self.async_get_last_state() if state is not None: self._state = self.compute_next_state(int(state.state)) + self._config[CONF_INITIAL] = state.attributes.get(ATTR_INITIAL) + self._config[CONF_MAXIMUM] = state.attributes.get(ATTR_MAXIMUM) + self._config[CONF_MINIMUM] = state.attributes.get(ATTR_MINIMUM) + self._config[CONF_STEP] = state.attributes.get(ATTR_STEP) - async def async_decrement(self): + @callback + def async_decrement(self) -> None: """Decrement the counter.""" - self._state = self.compute_next_state(self._state - self._step) - await self.async_update_ha_state() + self._state = self.compute_next_state(self._state - self._config[CONF_STEP]) + self.async_write_ha_state() - async def async_increment(self): + @callback + def async_increment(self) -> None: """Increment a counter.""" - self._state = self.compute_next_state(self._state + self._step) - await self.async_update_ha_state() + self._state = self.compute_next_state(self._state + self._config[CONF_STEP]) + self.async_write_ha_state() - async def async_reset(self): + @callback + def async_reset(self) -> None: """Reset a counter.""" - self._state = self.compute_next_state(self._initial) - await self.async_update_ha_state() + self._state = self.compute_next_state(self._config[CONF_INITIAL]) + self.async_write_ha_state() - async def async_configure(self, **kwargs): + @callback + def async_configure(self, **kwargs) -> None: """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] - + new_state = kwargs.pop(VALUE, self._state) + self._config = {**self._config, **kwargs} + self._state = self.compute_next_state(new_state) + self.async_write_ha_state() + + async def async_update_config(self, config: Dict) -> None: + """Change the counter's settings WS CRUD.""" + self._config = config self._state = self.compute_next_state(self._state) - await self.async_update_ha_state() + self.async_write_ha_state() diff --git a/homeassistant/components/counter/manifest.json b/homeassistant/components/counter/manifest.json index ae7066ea82d28..ab1a4bf043893 100644 --- a/homeassistant/components/counter/manifest.json +++ b/homeassistant/components/counter/manifest.json @@ -1,10 +1,7 @@ { "domain": "counter", "name": "Counter", - "documentation": "https://www.home-assistant.io/components/counter", - "requirements": [], - "dependencies": [], - "codeowners": [ - "@fabaff" - ] + "documentation": "https://www.home-assistant.io/integrations/counter", + "codeowners": ["@fabaff"], + "quality_scale": "internal" } diff --git a/homeassistant/components/counter/reproduce_state.py b/homeassistant/components/counter/reproduce_state.py new file mode 100644 index 0000000000000..b2dd63adedc91 --- /dev/null +++ b/homeassistant/components/counter/reproduce_state.py @@ -0,0 +1,84 @@ +"""Reproduce an Counter state.""" +import asyncio +import logging +from typing import Any, Dict, Iterable, Optional + +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import Context, State +from homeassistant.helpers.typing import HomeAssistantType + +from . import ( + ATTR_INITIAL, + ATTR_MAXIMUM, + ATTR_MINIMUM, + ATTR_STEP, + DOMAIN, + SERVICE_CONFIGURE, + VALUE, +) + +_LOGGER = logging.getLogger(__name__) + + +async def _async_reproduce_state( + hass: HomeAssistantType, + state: State, + *, + context: Optional[Context] = None, + reproduce_options: Optional[Dict[str, Any]] = None, +) -> None: + """Reproduce a single state.""" + cur_state = hass.states.get(state.entity_id) + + if cur_state is None: + _LOGGER.warning("Unable to find entity %s", state.entity_id) + return + + if not state.state.isdigit(): + _LOGGER.warning( + "Invalid state specified for %s: %s", state.entity_id, state.state + ) + return + + # Return if we are already at the right state. + if ( + cur_state.state == state.state + and cur_state.attributes.get(ATTR_INITIAL) == state.attributes.get(ATTR_INITIAL) + and cur_state.attributes.get(ATTR_MAXIMUM) == state.attributes.get(ATTR_MAXIMUM) + and cur_state.attributes.get(ATTR_MINIMUM) == state.attributes.get(ATTR_MINIMUM) + and cur_state.attributes.get(ATTR_STEP) == state.attributes.get(ATTR_STEP) + ): + return + + service_data = {ATTR_ENTITY_ID: state.entity_id, VALUE: state.state} + service = SERVICE_CONFIGURE + if ATTR_INITIAL in state.attributes: + service_data[ATTR_INITIAL] = state.attributes[ATTR_INITIAL] + if ATTR_MAXIMUM in state.attributes: + service_data[ATTR_MAXIMUM] = state.attributes[ATTR_MAXIMUM] + if ATTR_MINIMUM in state.attributes: + service_data[ATTR_MINIMUM] = state.attributes[ATTR_MINIMUM] + if ATTR_STEP in state.attributes: + service_data[ATTR_STEP] = state.attributes[ATTR_STEP] + + await hass.services.async_call( + DOMAIN, service, service_data, context=context, blocking=True + ) + + +async def async_reproduce_states( + hass: HomeAssistantType, + states: Iterable[State], + *, + context: Optional[Context] = None, + reproduce_options: Optional[Dict[str, Any]] = None, +) -> None: + """Reproduce Counter states.""" + await asyncio.gather( + *( + _async_reproduce_state( + hass, state, context=context, reproduce_options=reproduce_options + ) + for state in states + ) + ) diff --git a/homeassistant/components/counter/services.yaml b/homeassistant/components/counter/services.yaml index fc3f0ad36cb5a..960424df0ca2c 100644 --- a/homeassistant/components/counter/services.yaml +++ b/homeassistant/components/counter/services.yaml @@ -5,25 +5,25 @@ decrement: fields: entity_id: description: Entity id of the counter to decrement. - example: 'counter.count0' + example: "counter.count0" increment: description: Increment a counter. fields: entity_id: description: Entity id of the counter to increment. - example: 'counter.count0' + example: "counter.count0" reset: description: Reset a counter. fields: entity_id: description: Entity id of the counter to reset. - example: 'counter.count0' + example: "counter.count0" configure: description: Change counter parameters fields: entity_id: description: Entity id of the counter to change. - example: 'counter.count0' + example: "counter.count0" minimum: description: New minimum value for the counter or None to remove minimum example: 0 @@ -33,3 +33,9 @@ configure: step: description: New value for step example: 2 + initial: + description: New value for initial + example: 6 + value: + description: New state value + example: 3 diff --git a/homeassistant/components/cover/__init__.py b/homeassistant/components/cover/__init__.py index 8609d3c9cf640..84494e60c6ac3 100644 --- a/homeassistant/components/cover/__init__.py +++ b/homeassistant/components/cover/__init__.py @@ -2,43 +2,55 @@ from datetime import timedelta import functools as ft import logging +from typing import Any import voluptuous as vol -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 ( # 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_OPENING, STATE_CLOSING, ATTR_ENTITY_ID) + SERVICE_CLOSE_COVER, + SERVICE_CLOSE_COVER_TILT, + SERVICE_OPEN_COVER, + SERVICE_OPEN_COVER_TILT, + SERVICE_SET_COVER_POSITION, + SERVICE_SET_COVER_TILT_POSITION, + SERVICE_STOP_COVER, + SERVICE_STOP_COVER_TILT, + SERVICE_TOGGLE, + SERVICE_TOGGLE_COVER_TILT, + STATE_CLOSED, + STATE_CLOSING, + STATE_OPEN, + STATE_OPENING, +) +from homeassistant.helpers.config_validation import ( # noqa: F401 + PLATFORM_SCHEMA, + PLATFORM_SCHEMA_BASE, +) +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.loader import bind_hass + +# mypy: allow-untyped-calls, allow-untyped-defs, no-check-untyped-defs _LOGGER = logging.getLogger(__name__) -DOMAIN = 'cover' +DOMAIN = "cover" 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 + '.{}' +ENTITY_ID_FORMAT = DOMAIN + ".{}" # 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_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_GATE = "gate" +DEVICE_CLASS_SHADE = "shade" +DEVICE_CLASS_SHUTTER = "shutter" +DEVICE_CLASS_WINDOW = "window" + DEVICE_CLASSES = [ DEVICE_CLASS_AWNING, DEVICE_CLASS_BLIND, @@ -46,9 +58,10 @@ DEVICE_CLASS_DAMPER, DEVICE_CLASS_DOOR, DEVICE_CLASS_GARAGE, + DEVICE_CLASS_GATE, DEVICE_CLASS_SHADE, DEVICE_CLASS_SHUTTER, - DEVICE_CLASS_WINDOW + DEVICE_CLASS_WINDOW, ] DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.In(DEVICE_CLASSES)) @@ -61,90 +74,72 @@ 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.comp_entity_ids, -}) - -COVER_SET_COVER_POSITION_SCHEMA = COVER_SERVICE_SCHEMA.extend({ - vol.Required(ATTR_POSITION): - vol.All(vol.Coerce(int), vol.Range(min=0, max=100)), -}) - -COVER_SET_COVER_TILT_POSITION_SCHEMA = COVER_SERVICE_SCHEMA.extend({ - vol.Required(ATTR_TILT_POSITION): - vol.All(vol.Coerce(int), vol.Range(min=0, max=100)), -}) +ATTR_CURRENT_POSITION = "current_position" +ATTR_CURRENT_TILT_POSITION = "current_tilt_position" +ATTR_POSITION = "position" +ATTR_TILT_POSITION = "tilt_position" @bind_hass -def is_closed(hass, entity_id=None): +def is_closed(hass, entity_id): """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) async def async_setup(hass, config): """Track states and offer events for covers.""" component = hass.data[DOMAIN] = EntityComponent( - _LOGGER, DOMAIN, hass, SCAN_INTERVAL, GROUP_NAME_ALL_COVERS) + _LOGGER, DOMAIN, hass, SCAN_INTERVAL + ) 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_OPEN_COVER, {}, "async_open_cover") component.async_register_entity_service( - SERVICE_CLOSE_COVER, COVER_SERVICE_SCHEMA, - 'async_close_cover' + SERVICE_CLOSE_COVER, {}, "async_close_cover" ) component.async_register_entity_service( - SERVICE_SET_COVER_POSITION, COVER_SET_COVER_POSITION_SCHEMA, - 'async_set_cover_position' + SERVICE_SET_COVER_POSITION, + { + vol.Required(ATTR_POSITION): vol.All( + vol.Coerce(int), vol.Range(min=0, max=100) + ) + }, + "async_set_cover_position", ) + component.async_register_entity_service(SERVICE_STOP_COVER, {}, "async_stop_cover") + + component.async_register_entity_service(SERVICE_TOGGLE, {}, "async_toggle") + component.async_register_entity_service( - SERVICE_STOP_COVER, COVER_SERVICE_SCHEMA, - 'async_stop_cover' + SERVICE_OPEN_COVER_TILT, {}, "async_open_cover_tilt" ) component.async_register_entity_service( - SERVICE_OPEN_COVER_TILT, COVER_SERVICE_SCHEMA, - 'async_open_cover_tilt' + SERVICE_CLOSE_COVER_TILT, {}, "async_close_cover_tilt" ) component.async_register_entity_service( - SERVICE_CLOSE_COVER_TILT, COVER_SERVICE_SCHEMA, - 'async_close_cover_tilt' + SERVICE_STOP_COVER_TILT, {}, "async_stop_cover_tilt" ) component.async_register_entity_service( - SERVICE_STOP_COVER_TILT, COVER_SERVICE_SCHEMA, - 'async_stop_cover_tilt' + SERVICE_SET_COVER_TILT_POSITION, + { + vol.Required(ATTR_TILT_POSITION): vol.All( + vol.Coerce(int), vol.Range(min=0, max=100) + ) + }, + "async_set_cover_tilt_position", ) component.async_register_entity_service( - SERVICE_SET_COVER_TILT_POSITION, COVER_SET_COVER_TILT_POSITION_SCHEMA, - 'async_set_cover_tilt_position' + SERVICE_TOGGLE_COVER_TILT, {}, "async_toggle_tilt" ) - 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 {}")) - return True @@ -158,8 +153,8 @@ async def async_unload_entry(hass, entry): return await hass.data[DOMAIN].async_unload_entry(entry) -class CoverDevice(Entity): - """Representation a cover.""" +class CoverEntity(Entity): + """Representation of a cover.""" @property def current_cover_position(self): @@ -167,7 +162,6 @@ def current_cover_position(self): None is unknown, 0 is closed, 100 is fully open. """ - pass @property def current_cover_tilt_position(self): @@ -175,7 +169,6 @@ def current_cover_tilt_position(self): None is unknown, 0 is closed, 100 is fully open. """ - pass @property def state(self): @@ -217,115 +210,122 @@ def supported_features(self): if self.current_cover_tilt_position is not None: supported_features |= ( - SUPPORT_OPEN_TILT | SUPPORT_CLOSE_TILT | SUPPORT_STOP_TILT | - SUPPORT_SET_TILT_POSITION) + 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.""" raise NotImplementedError() - def open_cover(self, **kwargs): + def open_cover(self, **kwargs: Any) -> None: """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)) + async def async_open_cover(self, **kwargs): + """Open the cover.""" + await self.hass.async_add_job(ft.partial(self.open_cover, **kwargs)) - def close_cover(self, **kwargs): + def close_cover(self, **kwargs: Any) -> None: """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)) + async def async_close_cover(self, **kwargs): + """Close cover.""" + await self.hass.async_add_job(ft.partial(self.close_cover, **kwargs)) + + def toggle(self, **kwargs: Any) -> None: + """Toggle the entity.""" + if self.is_closed: + self.open_cover(**kwargs) + else: + self.close_cover(**kwargs) + + async def async_toggle(self, **kwargs): + """Toggle the entity.""" + if self.is_closed: + await self.async_open_cover(**kwargs) + else: + await self.async_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)) + async def async_set_cover_position(self, **kwargs): + """Move the cover to a specific position.""" + await 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)) + async def async_stop_cover(self, **kwargs): + """Stop the cover.""" + await self.hass.async_add_job(ft.partial(self.stop_cover, **kwargs)) - def open_cover_tilt(self, **kwargs): + def open_cover_tilt(self, **kwargs: Any) -> None: """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)) + async def async_open_cover_tilt(self, **kwargs): + """Open the cover tilt.""" + await self.hass.async_add_job(ft.partial(self.open_cover_tilt, **kwargs)) - def close_cover_tilt(self, **kwargs): + def close_cover_tilt(self, **kwargs: Any) -> None: """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)) + async def async_close_cover_tilt(self, **kwargs): + """Close the cover tilt.""" + await 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)) + async def async_set_cover_tilt_position(self, **kwargs): + """Move the cover tilt to a specific position.""" + await 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)) + async def async_stop_cover_tilt(self, **kwargs): + """Stop the cover.""" + await self.hass.async_add_job(ft.partial(self.stop_cover_tilt, **kwargs)) + + def toggle_tilt(self, **kwargs: Any) -> None: + """Toggle the entity.""" + if self.current_cover_tilt_position == 0: + self.open_cover_tilt(**kwargs) + else: + self.close_cover_tilt(**kwargs) + + async def async_toggle_tilt(self, **kwargs): + """Toggle the entity.""" + if self.current_cover_tilt_position == 0: + await self.async_open_cover_tilt(**kwargs) + else: + await self.async_close_cover_tilt(**kwargs) + + +class CoverDevice(CoverEntity): + """Representation of a cover (for backwards compatibility).""" + + def __init_subclass__(cls, **kwargs): + """Print deprecation warning.""" + super().__init_subclass__(**kwargs) + _LOGGER.warning( + "CoverDevice is deprecated, modify %s to extend CoverEntity", cls.__name__, + ) diff --git a/homeassistant/components/cover/device_action.py b/homeassistant/components/cover/device_action.py new file mode 100644 index 0000000000000..dba4ff8be89e3 --- /dev/null +++ b/homeassistant/components/cover/device_action.py @@ -0,0 +1,176 @@ +"""Provides device automations for Cover.""" +from typing import List, Optional + +import voluptuous as vol + +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + CONF_DEVICE_ID, + CONF_DOMAIN, + CONF_ENTITY_ID, + CONF_TYPE, + SERVICE_CLOSE_COVER, + SERVICE_CLOSE_COVER_TILT, + SERVICE_OPEN_COVER, + SERVICE_OPEN_COVER_TILT, + SERVICE_SET_COVER_POSITION, + SERVICE_SET_COVER_TILT_POSITION, +) +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import entity_registry +import homeassistant.helpers.config_validation as cv + +from . import ( + ATTR_POSITION, + ATTR_TILT_POSITION, + DOMAIN, + SUPPORT_CLOSE, + SUPPORT_CLOSE_TILT, + SUPPORT_OPEN, + SUPPORT_OPEN_TILT, + SUPPORT_SET_POSITION, + SUPPORT_SET_TILT_POSITION, +) + +CMD_ACTION_TYPES = {"open", "close", "open_tilt", "close_tilt"} +POSITION_ACTION_TYPES = {"set_position", "set_tilt_position"} + +CMD_ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( + { + vol.Required(CONF_TYPE): vol.In(CMD_ACTION_TYPES), + vol.Required(CONF_ENTITY_ID): cv.entity_domain(DOMAIN), + } +) + +POSITION_ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( + { + vol.Required(CONF_TYPE): vol.In(POSITION_ACTION_TYPES), + vol.Required(CONF_ENTITY_ID): cv.entity_domain(DOMAIN), + vol.Required("position"): vol.All(vol.Coerce(int), vol.Range(min=0, max=100)), + } +) + +ACTION_SCHEMA = vol.Any(CMD_ACTION_SCHEMA, POSITION_ACTION_SCHEMA) + + +async def async_get_actions(hass: HomeAssistant, device_id: str) -> List[dict]: + """List device actions for Cover devices.""" + registry = await entity_registry.async_get_registry(hass) + actions = [] + + # Get all the integrations entities for this device + for entry in entity_registry.async_entries_for_device(registry, device_id): + if entry.domain != DOMAIN: + continue + + state = hass.states.get(entry.entity_id) + if not state or ATTR_SUPPORTED_FEATURES not in state.attributes: + continue + + supported_features = state.attributes[ATTR_SUPPORTED_FEATURES] + + # Add actions for each entity that belongs to this integration + if supported_features & SUPPORT_SET_POSITION: + actions.append( + { + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "set_position", + } + ) + else: + if supported_features & SUPPORT_OPEN: + actions.append( + { + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "open", + } + ) + if supported_features & SUPPORT_CLOSE: + actions.append( + { + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "close", + } + ) + + if supported_features & SUPPORT_SET_TILT_POSITION: + actions.append( + { + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "set_tilt_position", + } + ) + else: + if supported_features & SUPPORT_OPEN_TILT: + actions.append( + { + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "open_tilt", + } + ) + if supported_features & SUPPORT_CLOSE_TILT: + actions.append( + { + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "close_tilt", + } + ) + + return actions + + +async def async_get_action_capabilities(hass: HomeAssistant, config: dict) -> dict: + """List action capabilities.""" + if config[CONF_TYPE] not in POSITION_ACTION_TYPES: + return {} + + return { + "extra_fields": vol.Schema( + { + vol.Optional("position", default=0): vol.All( + vol.Coerce(int), vol.Range(min=0, max=100) + ) + } + ) + } + + +async def async_call_action_from_config( + hass: HomeAssistant, config: dict, variables: dict, context: Optional[Context] +) -> None: + """Execute a device action.""" + config = ACTION_SCHEMA(config) + + service_data = {ATTR_ENTITY_ID: config[CONF_ENTITY_ID]} + + if config[CONF_TYPE] == "open": + service = SERVICE_OPEN_COVER + elif config[CONF_TYPE] == "close": + service = SERVICE_CLOSE_COVER + elif config[CONF_TYPE] == "open_tilt": + service = SERVICE_OPEN_COVER_TILT + elif config[CONF_TYPE] == "close_tilt": + service = SERVICE_CLOSE_COVER_TILT + elif config[CONF_TYPE] == "set_position": + service = SERVICE_SET_COVER_POSITION + service_data[ATTR_POSITION] = config["position"] + elif config[CONF_TYPE] == "set_tilt_position": + service = SERVICE_SET_COVER_TILT_POSITION + service_data[ATTR_TILT_POSITION] = config["position"] + + await hass.services.async_call( + DOMAIN, service, service_data, blocking=True, context=context + ) diff --git a/homeassistant/components/cover/device_condition.py b/homeassistant/components/cover/device_condition.py new file mode 100644 index 0000000000000..0bcec2a6e43d0 --- /dev/null +++ b/homeassistant/components/cover/device_condition.py @@ -0,0 +1,209 @@ +"""Provides device automations for Cover.""" +from typing import Any, Dict, List + +import voluptuous as vol + +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + CONF_ABOVE, + CONF_BELOW, + CONF_CONDITION, + CONF_DEVICE_ID, + CONF_DOMAIN, + CONF_ENTITY_ID, + CONF_TYPE, + STATE_CLOSED, + STATE_CLOSING, + STATE_OPEN, + STATE_OPENING, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import ( + condition, + config_validation as cv, + entity_registry, + template, +) +from homeassistant.helpers.config_validation import DEVICE_CONDITION_BASE_SCHEMA +from homeassistant.helpers.typing import ConfigType, TemplateVarsType + +from . import ( + DOMAIN, + SUPPORT_CLOSE, + SUPPORT_OPEN, + SUPPORT_SET_POSITION, + SUPPORT_SET_TILT_POSITION, +) + +POSITION_CONDITION_TYPES = {"is_position", "is_tilt_position"} +STATE_CONDITION_TYPES = {"is_open", "is_closed", "is_opening", "is_closing"} + +POSITION_CONDITION_SCHEMA = vol.All( + DEVICE_CONDITION_BASE_SCHEMA.extend( + { + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_TYPE): vol.In(POSITION_CONDITION_TYPES), + vol.Optional(CONF_ABOVE): vol.All( + vol.Coerce(int), vol.Range(min=0, max=100) + ), + vol.Optional(CONF_BELOW): vol.All( + vol.Coerce(int), vol.Range(min=0, max=100) + ), + } + ), + cv.has_at_least_one_key(CONF_BELOW, CONF_ABOVE), +) + +STATE_CONDITION_SCHEMA = DEVICE_CONDITION_BASE_SCHEMA.extend( + { + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_TYPE): vol.In(STATE_CONDITION_TYPES), + } +) + +CONDITION_SCHEMA = vol.Any(POSITION_CONDITION_SCHEMA, STATE_CONDITION_SCHEMA) + + +async def async_get_conditions(hass: HomeAssistant, device_id: str) -> List[dict]: + """List device conditions for Cover devices.""" + registry = await entity_registry.async_get_registry(hass) + conditions: List[Dict[str, Any]] = [] + + # Get all the integrations entities for this device + for entry in entity_registry.async_entries_for_device(registry, device_id): + if entry.domain != DOMAIN: + continue + + state = hass.states.get(entry.entity_id) + if not state or ATTR_SUPPORTED_FEATURES not in state.attributes: + continue + + supported_features = state.attributes[ATTR_SUPPORTED_FEATURES] + supports_open_close = supported_features & (SUPPORT_OPEN | SUPPORT_CLOSE) + + # Add conditions for each entity that belongs to this integration + if supports_open_close: + conditions.append( + { + CONF_CONDITION: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "is_open", + } + ) + conditions.append( + { + CONF_CONDITION: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "is_closed", + } + ) + conditions.append( + { + CONF_CONDITION: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "is_opening", + } + ) + conditions.append( + { + CONF_CONDITION: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "is_closing", + } + ) + if supported_features & SUPPORT_SET_POSITION: + conditions.append( + { + CONF_CONDITION: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "is_position", + } + ) + if supported_features & SUPPORT_SET_TILT_POSITION: + conditions.append( + { + CONF_CONDITION: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "is_tilt_position", + } + ) + + return conditions + + +async def async_get_condition_capabilities(hass: HomeAssistant, config: dict) -> dict: + """List condition capabilities.""" + if config[CONF_TYPE] not in ["is_position", "is_tilt_position"]: + return {} + + return { + "extra_fields": vol.Schema( + { + vol.Optional(CONF_ABOVE, default=0): vol.All( + vol.Coerce(int), vol.Range(min=0, max=100) + ), + vol.Optional(CONF_BELOW, default=100): vol.All( + vol.Coerce(int), vol.Range(min=0, max=100) + ), + } + ) + } + + +@callback +def async_condition_from_config( + config: ConfigType, config_validation: bool +) -> condition.ConditionCheckerType: + """Create a function to test a device condition.""" + if config_validation: + config = CONDITION_SCHEMA(config) + + if config[CONF_TYPE] in STATE_CONDITION_TYPES: + if config[CONF_TYPE] == "is_open": + state = STATE_OPEN + elif config[CONF_TYPE] == "is_closed": + state = STATE_CLOSED + elif config[CONF_TYPE] == "is_opening": + state = STATE_OPENING + elif config[CONF_TYPE] == "is_closing": + state = STATE_CLOSING + + def test_is_state(hass: HomeAssistant, variables: TemplateVarsType) -> bool: + """Test if an entity is a certain state.""" + return condition.state(hass, config[ATTR_ENTITY_ID], state) + + return test_is_state + + if config[CONF_TYPE] == "is_position": + position = "current_position" + if config[CONF_TYPE] == "is_tilt_position": + position = "current_tilt_position" + min_pos = config.get(CONF_ABOVE) + max_pos = config.get(CONF_BELOW) + value_template = template.Template( # type: ignore + f"{{{{ state.attributes.{position} }}}}" + ) + + @callback + def template_if(hass: HomeAssistant, variables: TemplateVarsType = None) -> bool: + """Validate template based if-condition.""" + value_template.hass = hass + + return condition.async_numeric_state( + hass, config[ATTR_ENTITY_ID], max_pos, min_pos, value_template + ) + + return template_if diff --git a/homeassistant/components/cover/device_trigger.py b/homeassistant/components/cover/device_trigger.py new file mode 100644 index 0000000000000..988427003e7a0 --- /dev/null +++ b/homeassistant/components/cover/device_trigger.py @@ -0,0 +1,212 @@ +"""Provides device automations for Cover.""" +from typing import List + +import voluptuous as vol + +from homeassistant.components.automation import ( + AutomationActionType, + numeric_state as numeric_state_automation, + state as state_automation, +) +from homeassistant.components.device_automation import TRIGGER_BASE_SCHEMA +from homeassistant.const import ( + ATTR_SUPPORTED_FEATURES, + CONF_ABOVE, + CONF_BELOW, + CONF_DEVICE_ID, + CONF_DOMAIN, + CONF_ENTITY_ID, + CONF_PLATFORM, + CONF_TYPE, + STATE_CLOSED, + STATE_CLOSING, + STATE_OPEN, + STATE_OPENING, +) +from homeassistant.core import CALLBACK_TYPE, HomeAssistant +from homeassistant.helpers import config_validation as cv, entity_registry +from homeassistant.helpers.typing import ConfigType + +from . import ( + DOMAIN, + SUPPORT_CLOSE, + SUPPORT_OPEN, + SUPPORT_SET_POSITION, + SUPPORT_SET_TILT_POSITION, +) + +POSITION_TRIGGER_TYPES = {"position", "tilt_position"} +STATE_TRIGGER_TYPES = {"opened", "closed", "opening", "closing"} + +POSITION_TRIGGER_SCHEMA = vol.All( + TRIGGER_BASE_SCHEMA.extend( + { + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_TYPE): vol.In(POSITION_TRIGGER_TYPES), + vol.Optional(CONF_ABOVE): vol.All( + vol.Coerce(int), vol.Range(min=0, max=100) + ), + vol.Optional(CONF_BELOW): vol.All( + vol.Coerce(int), vol.Range(min=0, max=100) + ), + } + ), + cv.has_at_least_one_key(CONF_BELOW, CONF_ABOVE), +) + +STATE_TRIGGER_SCHEMA = TRIGGER_BASE_SCHEMA.extend( + { + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_TYPE): vol.In(STATE_TRIGGER_TYPES), + } +) + +TRIGGER_SCHEMA = vol.Any(POSITION_TRIGGER_SCHEMA, STATE_TRIGGER_SCHEMA) + + +async def async_get_triggers(hass: HomeAssistant, device_id: str) -> List[dict]: + """List device triggers for Cover devices.""" + registry = await entity_registry.async_get_registry(hass) + triggers = [] + + # Get all the integrations entities for this device + for entry in entity_registry.async_entries_for_device(registry, device_id): + if entry.domain != DOMAIN: + continue + + state = hass.states.get(entry.entity_id) + if not state or ATTR_SUPPORTED_FEATURES not in state.attributes: + continue + + supported_features = state.attributes[ATTR_SUPPORTED_FEATURES] + supports_open_close = supported_features & (SUPPORT_OPEN | SUPPORT_CLOSE) + + # Add triggers for each entity that belongs to this integration + if supports_open_close: + triggers.append( + { + CONF_PLATFORM: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "opened", + } + ) + triggers.append( + { + CONF_PLATFORM: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "closed", + } + ) + triggers.append( + { + CONF_PLATFORM: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "opening", + } + ) + triggers.append( + { + CONF_PLATFORM: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "closing", + } + ) + if supported_features & SUPPORT_SET_POSITION: + triggers.append( + { + CONF_PLATFORM: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "position", + } + ) + if supported_features & SUPPORT_SET_TILT_POSITION: + triggers.append( + { + CONF_PLATFORM: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "tilt_position", + } + ) + + return triggers + + +async def async_get_trigger_capabilities(hass: HomeAssistant, config: dict) -> dict: + """List trigger capabilities.""" + if config[CONF_TYPE] not in ["position", "tilt_position"]: + return {} + + return { + "extra_fields": vol.Schema( + { + vol.Optional(CONF_ABOVE, default=0): vol.All( + vol.Coerce(int), vol.Range(min=0, max=100) + ), + vol.Optional(CONF_BELOW, default=100): vol.All( + vol.Coerce(int), vol.Range(min=0, max=100) + ), + } + ) + } + + +async def async_attach_trigger( + hass: HomeAssistant, + config: ConfigType, + action: AutomationActionType, + automation_info: dict, +) -> CALLBACK_TYPE: + """Attach a trigger.""" + config = TRIGGER_SCHEMA(config) + + if config[CONF_TYPE] in STATE_TRIGGER_TYPES: + if config[CONF_TYPE] == "opened": + to_state = STATE_OPEN + elif config[CONF_TYPE] == "closed": + to_state = STATE_CLOSED + elif config[CONF_TYPE] == "opening": + to_state = STATE_OPENING + elif config[CONF_TYPE] == "closing": + to_state = STATE_CLOSING + + state_config = { + state_automation.CONF_PLATFORM: "state", + CONF_ENTITY_ID: config[CONF_ENTITY_ID], + state_automation.CONF_TO: to_state, + } + state_config = state_automation.TRIGGER_SCHEMA(state_config) + return await state_automation.async_attach_trigger( + hass, state_config, action, automation_info, platform_type="device" + ) + + if config[CONF_TYPE] == "position": + position = "current_position" + if config[CONF_TYPE] == "tilt_position": + position = "current_tilt_position" + min_pos = config.get(CONF_ABOVE, -1) + max_pos = config.get(CONF_BELOW, 101) + value_template = f"{{{{ state.attributes.{position} }}}}" + + numeric_state_config = { + numeric_state_automation.CONF_PLATFORM: "numeric_state", + numeric_state_automation.CONF_ENTITY_ID: config[CONF_ENTITY_ID], + numeric_state_automation.CONF_BELOW: max_pos, + numeric_state_automation.CONF_ABOVE: min_pos, + numeric_state_automation.CONF_VALUE_TEMPLATE: value_template, + } + numeric_state_config = numeric_state_automation.TRIGGER_SCHEMA(numeric_state_config) + return await numeric_state_automation.async_attach_trigger( + hass, numeric_state_config, action, automation_info, platform_type="device" + ) diff --git a/homeassistant/components/cover/intent.py b/homeassistant/components/cover/intent.py new file mode 100644 index 0000000000000..36402025bfa38 --- /dev/null +++ b/homeassistant/components/cover/intent.py @@ -0,0 +1,22 @@ +"""Intents for the cover integration.""" +from homeassistant.core import HomeAssistant +from homeassistant.helpers import intent + +from . import DOMAIN, SERVICE_CLOSE_COVER, SERVICE_OPEN_COVER + +INTENT_OPEN_COVER = "HassOpenCover" +INTENT_CLOSE_COVER = "HassCloseCover" + + +async def async_setup_intents(hass: HomeAssistant) -> None: + """Set up the cover intents.""" + 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 {}" + ) + ) diff --git a/homeassistant/components/cover/manifest.json b/homeassistant/components/cover/manifest.json index da5a644334cb5..3da130fd79982 100644 --- a/homeassistant/components/cover/manifest.json +++ b/homeassistant/components/cover/manifest.json @@ -1,12 +1,7 @@ { "domain": "cover", "name": "Cover", - "documentation": "https://www.home-assistant.io/components/cover", - "requirements": [], - "dependencies": [ - "group" - ], - "codeowners": [ - "@home-assistant/core" - ] + "documentation": "https://www.home-assistant.io/integrations/cover", + "codeowners": ["@home-assistant/core"], + "quality_scale": "internal" } diff --git a/homeassistant/components/cover/reproduce_state.py b/homeassistant/components/cover/reproduce_state.py new file mode 100644 index 0000000000000..2a12172bdab88 --- /dev/null +++ b/homeassistant/components/cover/reproduce_state.py @@ -0,0 +1,132 @@ +"""Reproduce an Cover state.""" +import asyncio +import logging +from typing import Any, Dict, Iterable, Optional + +from homeassistant.components.cover import ( + ATTR_CURRENT_POSITION, + ATTR_CURRENT_TILT_POSITION, + ATTR_POSITION, + ATTR_TILT_POSITION, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_CLOSE_COVER, + SERVICE_CLOSE_COVER_TILT, + SERVICE_OPEN_COVER, + SERVICE_OPEN_COVER_TILT, + SERVICE_SET_COVER_POSITION, + SERVICE_SET_COVER_TILT_POSITION, + STATE_CLOSED, + STATE_CLOSING, + STATE_OPEN, + STATE_OPENING, +) +from homeassistant.core import Context, State +from homeassistant.helpers.typing import HomeAssistantType + +from . import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +VALID_STATES = {STATE_CLOSED, STATE_CLOSING, STATE_OPEN, STATE_OPENING} + + +async def _async_reproduce_state( + hass: HomeAssistantType, + state: State, + *, + context: Optional[Context] = None, + reproduce_options: Optional[Dict[str, Any]] = None, +) -> None: + """Reproduce a single state.""" + cur_state = hass.states.get(state.entity_id) + + if cur_state is None: + _LOGGER.warning("Unable to find entity %s", state.entity_id) + return + + if state.state not in VALID_STATES: + _LOGGER.warning( + "Invalid state specified for %s: %s", state.entity_id, state.state + ) + return + + # Return if we are already at the right state. + if ( + cur_state.state == state.state + and cur_state.attributes.get(ATTR_CURRENT_POSITION) + == state.attributes.get(ATTR_CURRENT_POSITION) + and cur_state.attributes.get(ATTR_CURRENT_TILT_POSITION) + == state.attributes.get(ATTR_CURRENT_TILT_POSITION) + ): + return + + service_data = {ATTR_ENTITY_ID: state.entity_id} + service_data_tilting = {ATTR_ENTITY_ID: state.entity_id} + + if not ( + cur_state.state == state.state + and cur_state.attributes.get(ATTR_CURRENT_POSITION) + == state.attributes.get(ATTR_CURRENT_POSITION) + ): + # Open/Close + if state.state in [STATE_CLOSED, STATE_CLOSING]: + service = SERVICE_CLOSE_COVER + elif state.state in [STATE_OPEN, STATE_OPENING]: + if ( + ATTR_CURRENT_POSITION in cur_state.attributes + and ATTR_CURRENT_POSITION in state.attributes + ): + service = SERVICE_SET_COVER_POSITION + service_data[ATTR_POSITION] = state.attributes[ATTR_CURRENT_POSITION] + else: + service = SERVICE_OPEN_COVER + + await hass.services.async_call( + DOMAIN, service, service_data, context=context, blocking=True + ) + + if ( + ATTR_CURRENT_TILT_POSITION in state.attributes + and ATTR_CURRENT_TILT_POSITION in cur_state.attributes + and cur_state.attributes.get(ATTR_CURRENT_TILT_POSITION) + != state.attributes.get(ATTR_CURRENT_TILT_POSITION) + ): + # Tilt position + if state.attributes.get(ATTR_CURRENT_TILT_POSITION) == 100: + service_tilting = SERVICE_OPEN_COVER_TILT + elif state.attributes.get(ATTR_CURRENT_TILT_POSITION) == 0: + service_tilting = SERVICE_CLOSE_COVER_TILT + else: + service_tilting = SERVICE_SET_COVER_TILT_POSITION + service_data_tilting[ATTR_TILT_POSITION] = state.attributes[ + ATTR_CURRENT_TILT_POSITION + ] + + await hass.services.async_call( + DOMAIN, + service_tilting, + service_data_tilting, + context=context, + blocking=True, + ) + + +async def async_reproduce_states( + hass: HomeAssistantType, + states: Iterable[State], + *, + context: Optional[Context] = None, + reproduce_options: Optional[Dict[str, Any]] = None, +) -> None: + """Reproduce Cover states.""" + # Reproduce states in parallel. + await asyncio.gather( + *( + _async_reproduce_state( + hass, state, context=context, reproduce_options=reproduce_options + ) + for state in states + ) + ) diff --git a/homeassistant/components/cover/services.yaml b/homeassistant/components/cover/services.yaml index 79f00180a8946..604955aa1992e 100644 --- a/homeassistant/components/cover/services.yaml +++ b/homeassistant/components/cover/services.yaml @@ -5,21 +5,28 @@ open_cover: fields: entity_id: description: Name(s) of cover(s) to open. - example: 'cover.living_room' + 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' + example: "cover.living_room" + +toggle: + description: Toggles a cover open/closed. + fields: + entity_id: + description: Name(s) of cover(s) to toggle. + example: "cover.garage_door" 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' + example: "cover.living_room" position: description: Position of the cover (0 to 100). example: 30 @@ -29,28 +36,35 @@ stop_cover: fields: entity_id: description: Name(s) of cover(s) to stop. - example: 'cover.living_room' + 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' + example: "cover.living_room_blinds" 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' + example: "cover.living_room_blinds" + +toggle_cover_tilt: + description: Toggles a cover tilt open/closed. + fields: + entity_id: + description: Name(s) of cover(s) to toggle tilt. + example: "cover.living_room_blinds" 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' + example: "cover.living_room_blinds" tilt_position: description: Tilt position of the cover (0 to 100). example: 30 @@ -60,4 +74,4 @@ stop_cover_tilt: fields: entity_id: description: Name(s) of cover(s) to stop. - example: 'cover.living_room' + example: "cover.living_room_blinds" diff --git a/homeassistant/components/cover/strings.json b/homeassistant/components/cover/strings.json new file mode 100644 index 0000000000000..de52614891f6a --- /dev/null +++ b/homeassistant/components/cover/strings.json @@ -0,0 +1,38 @@ +{ + "title": "Cover", + "device_automation": { + "action_type": { + "open": "Open {entity_name}", + "close": "Close {entity_name}", + "open_tilt": "Open {entity_name} tilt", + "close_tilt": "Close {entity_name} tilt", + "set_position": "Set {entity_name} position", + "set_tilt_position": "Set {entity_name} tilt position" + }, + "condition_type": { + "is_open": "{entity_name} is open", + "is_closed": "{entity_name} is closed", + "is_opening": "{entity_name} is opening", + "is_closing": "{entity_name} is closing", + "is_position": "Current {entity_name} position is", + "is_tilt_position": "Current {entity_name} tilt position is" + }, + "trigger_type": { + "opened": "{entity_name} opened", + "closed": "{entity_name} closed", + "opening": "{entity_name} opening", + "closing": "{entity_name} closing", + "position": "{entity_name} position changes", + "tilt_position": "{entity_name} tilt position changes" + } + }, + "state": { + "_": { + "open": "[%key:common::state::open%]", + "opening": "Opening", + "closed": "[%key:common::state::closed%]", + "closing": "Closing", + "stopped": "Stopped" + } + } +} diff --git a/homeassistant/components/cover/translations/af.json b/homeassistant/components/cover/translations/af.json new file mode 100644 index 0000000000000..581cc0b6919a3 --- /dev/null +++ b/homeassistant/components/cover/translations/af.json @@ -0,0 +1,12 @@ +{ + "state": { + "_": { + "closed": "Toe", + "closing": "Sluiting", + "open": "Oop", + "opening": "Opening", + "stopped": "Gestop" + } + }, + "title": "Dekking" +} \ No newline at end of file diff --git a/homeassistant/components/cover/translations/ar.json b/homeassistant/components/cover/translations/ar.json new file mode 100644 index 0000000000000..5fecd1da06ed4 --- /dev/null +++ b/homeassistant/components/cover/translations/ar.json @@ -0,0 +1,12 @@ +{ + "state": { + "_": { + "closed": "\u0645\u063a\u0644\u0642", + "closing": "\u062c\u0627\u0631\u064a \u0627\u0644\u0627\u063a\u0644\u0627\u0642", + "open": "\u0645\u0641\u062a\u0648\u062d", + "opening": "\u062c\u0627\u0631\u064a \u0627\u0644\u0641\u062a\u062d", + "stopped": "\u0645\u0648\u0642\u0641" + } + }, + "title": "\u0633\u062a\u0627\u0631" +} \ No newline at end of file diff --git a/homeassistant/components/cover/translations/bg.json b/homeassistant/components/cover/translations/bg.json new file mode 100644 index 0000000000000..99b9240f2ae1e --- /dev/null +++ b/homeassistant/components/cover/translations/bg.json @@ -0,0 +1,30 @@ +{ + "device_automation": { + "condition_type": { + "is_closed": "{entity_name} \u0435 \u0437\u0430\u0442\u0432\u043e\u0440\u0435\u043d", + "is_closing": "{entity_name} \u0441\u0435 \u0437\u0430\u0442\u0432\u0430\u0440\u044f", + "is_open": "{entity_name} \u0435 \u043e\u0442\u0432\u043e\u0440\u0435\u043d", + "is_opening": "{entity_name} \u0441\u0435 \u043e\u0442\u0432\u0430\u0440\u044f", + "is_position": "\u0422\u0435\u043a\u0443\u0449\u0430\u0442\u0430 \u043f\u043e\u0437\u0438\u0446\u0438\u044f \u043d\u0430 {entity_name} \u0435", + "is_tilt_position": "\u0422\u0435\u043a\u0443\u0449\u0430\u0442\u0430 \u043f\u043e\u0437\u0438\u0446\u0438\u044f \u043d\u0430 \u043d\u0430\u043a\u043b\u043e\u043d\u0430 \u043d\u0430 {entity_name} \u0435" + }, + "trigger_type": { + "closed": "{entity_name} \u0437\u0430\u0442\u0432\u043e\u0440\u0435\u043d", + "closing": "{entity_name} \u0441\u0435 \u0437\u0430\u0442\u0432\u0430\u0440\u044f", + "opened": "{entity_name} \u0435 \u043e\u0442\u0432\u043e\u0440\u0435\u043d", + "opening": "{entity_name} \u0441\u0435 \u043e\u0442\u0432\u0430\u0440\u044f", + "position": "{entity_name} \u043f\u0440\u043e\u043c\u0435\u043d\u0438 \u043f\u043e\u0437\u0438\u0446\u0438\u044f\u0442\u0430 \u0441\u0438", + "tilt_position": "{entity_name} \u043f\u0440\u043e\u043c\u0435\u043d\u0438 \u043d\u0430\u043a\u043b\u043e\u043d\u0430 \u0441\u0438" + } + }, + "state": { + "_": { + "closed": "\u0417\u0430\u0442\u0432\u043e\u0440\u0435\u043d\u0430", + "closing": "\u0417\u0430\u0442\u0432\u0430\u0440\u044f\u043d\u0435", + "open": "\u041e\u0442\u0432\u043e\u0440\u0435\u043d\u0430", + "opening": "\u041e\u0442\u0432\u0430\u0440\u044f\u043d\u0435", + "stopped": "\u0421\u043f\u0440\u044f\u043d\u0430" + } + }, + "title": "\u041f\u0430\u0440\u0430\u0432\u0430\u043d" +} \ No newline at end of file diff --git a/homeassistant/components/cover/translations/bs.json b/homeassistant/components/cover/translations/bs.json new file mode 100644 index 0000000000000..fba4be0c94f29 --- /dev/null +++ b/homeassistant/components/cover/translations/bs.json @@ -0,0 +1,12 @@ +{ + "state": { + "_": { + "closed": "Zatvoren", + "closing": "Zatvoreno", + "open": "Otvoren", + "opening": "Otvoreno", + "stopped": "Zaustavljen" + } + }, + "title": "Poklopac" +} \ No newline at end of file diff --git a/homeassistant/components/cover/translations/ca.json b/homeassistant/components/cover/translations/ca.json new file mode 100644 index 0000000000000..e54cc563da5db --- /dev/null +++ b/homeassistant/components/cover/translations/ca.json @@ -0,0 +1,38 @@ +{ + "device_automation": { + "action_type": { + "close": "Tanca {entity_name}", + "close_tilt": "Inclinaci\u00f3 {entity_name} tancat/ada", + "open": "Obre {entity_name}", + "open_tilt": "Inclinaci\u00f3 {entity_name} obert/a", + "set_position": "Estableix la posici\u00f3 de {entity_name}", + "set_tilt_position": "Estableix la inclinaci\u00f3 de {entity_name}" + }, + "condition_type": { + "is_closed": "{entity_name} est\u00e0 tancat/da", + "is_closing": "{entity_name} est\u00e0 tancant-se", + "is_open": "{entity_name} est\u00e0 obert/a", + "is_opening": "{entity_name} s'est\u00e0 obrint", + "is_position": "La posici\u00f3 de {entity_name} \u00e9s", + "is_tilt_position": "La posici\u00f3 d'inclinaci\u00f3 de {entity_name} \u00e9s" + }, + "trigger_type": { + "closed": "{entity_name} tancat/da", + "closing": "{entity_name} tancant-se", + "opened": "{entity_name} s'ha obert", + "opening": "{entity_name} obrint-se", + "position": "Canvia la posici\u00f3 de {entity_name}", + "tilt_position": "Canvia la inclinaci\u00f3 de {entity_name}" + } + }, + "state": { + "_": { + "closed": "Tancada", + "closing": "Tancant", + "open": "Oberta", + "opening": "Obrint", + "stopped": "Aturat" + } + }, + "title": "Coberta" +} \ No newline at end of file diff --git a/homeassistant/components/cover/translations/cs.json b/homeassistant/components/cover/translations/cs.json new file mode 100644 index 0000000000000..c32db1e8b9762 --- /dev/null +++ b/homeassistant/components/cover/translations/cs.json @@ -0,0 +1,22 @@ +{ + "device_automation": { + "condition_type": { + "is_closed": "{entity_name} je zav\u0159eno", + "is_closing": "{entity_name} se zav\u00edr\u00e1", + "is_open": "{entity_name} je otev\u0159eno", + "is_opening": "{entity_name} se otev\u00edr\u00e1", + "is_position": "pozice {entity_name} je", + "is_tilt_position": "pozice naklon\u011bn\u00ed {entity_name} je" + } + }, + "state": { + "_": { + "closed": "Zav\u0159eno", + "closing": "Zav\u00edr\u00e1n\u00ed", + "open": "Otev\u0159eno", + "opening": "Otev\u00edr\u00e1n\u00ed", + "stopped": "Zastaveno" + } + }, + "title": "Roleta" +} \ No newline at end of file diff --git a/homeassistant/components/cover/translations/cy.json b/homeassistant/components/cover/translations/cy.json new file mode 100644 index 0000000000000..508364501baa5 --- /dev/null +++ b/homeassistant/components/cover/translations/cy.json @@ -0,0 +1,12 @@ +{ + "state": { + "_": { + "closed": "Ar gau", + "closing": "Cau", + "open": "Agor", + "opening": "Yn agor", + "stopped": "Stopio" + } + }, + "title": "Clawr" +} \ No newline at end of file diff --git a/homeassistant/components/cover/translations/da.json b/homeassistant/components/cover/translations/da.json new file mode 100644 index 0000000000000..a79f3ddd1dce5 --- /dev/null +++ b/homeassistant/components/cover/translations/da.json @@ -0,0 +1,38 @@ +{ + "device_automation": { + "action_type": { + "close": "Luk {entity_name}", + "close_tilt": "Luk vippeposition for {entity_name}", + "open": "\u00c5bn {entity_name}", + "open_tilt": "\u00c5bn vippeposition for {entity_name}", + "set_position": "Indstil {entity_name}-position", + "set_tilt_position": "Angiv vippeposition for {entity_name}" + }, + "condition_type": { + "is_closed": "{entity_name} er lukket", + "is_closing": "{entity_name} lukker", + "is_open": "{entity_name} er \u00e5ben", + "is_opening": "{entity_name} \u00e5bnes", + "is_position": "Aktuel {entity_name} position er", + "is_tilt_position": "Aktuel {entity_name} vippeposition er" + }, + "trigger_type": { + "closed": "{entity_name} lukket", + "closing": "{entity_name} lukning", + "opened": "{entity_name} \u00e5bnet", + "opening": "{entity_name} \u00e5bning", + "position": "{entity_name} position \u00e6ndres", + "tilt_position": "{entity_name} vippeposition \u00e6ndres" + } + }, + "state": { + "_": { + "closed": "Lukket", + "closing": "Lukker", + "open": "\u00c5ben", + "opening": "\u00c5bner", + "stopped": "Stoppet" + } + }, + "title": "Gardin/port" +} \ No newline at end of file diff --git a/homeassistant/components/cover/translations/de.json b/homeassistant/components/cover/translations/de.json new file mode 100644 index 0000000000000..a90ec822adc36 --- /dev/null +++ b/homeassistant/components/cover/translations/de.json @@ -0,0 +1,38 @@ +{ + "device_automation": { + "action_type": { + "close": "Schlie\u00dfe {entity_name}", + "close_tilt": "{entity_name} gekippt schlie\u00dfen", + "open": "\u00d6ffne {entity_name}", + "open_tilt": "{entity_name} gekippt \u00f6ffnen", + "set_position": "Position von {entity_name} setzen", + "set_tilt_position": "Neigeposition von {entity_name} einstellen" + }, + "condition_type": { + "is_closed": "{entity_name} ist geschlossen", + "is_closing": "{entity_name} wird geschlossen", + "is_open": "{entity_name} ist offen", + "is_opening": "{entity_name} wird ge\u00f6ffnet", + "is_position": "Die Aktuelle Position von {entity_name} ist", + "is_tilt_position": "Die Aktuelle Neigungsposition von {entity_name} ist" + }, + "trigger_type": { + "closed": "{entity_name} geschlossen", + "closing": "{entity_name} wird geschlossen", + "opened": "{entity_name} ge\u00f6ffnet", + "opening": "{entity_name} wird ge\u00f6ffnet", + "position": "{entity_name} ver\u00e4ndert die Position", + "tilt_position": "{entity_name} ver\u00e4ndert die Neigungsposition" + } + }, + "state": { + "_": { + "closed": "Geschlossen", + "closing": "Schlie\u00dft", + "open": "Offen", + "opening": "\u00d6ffnet", + "stopped": "Angehalten" + } + }, + "title": "Abdeckung" +} \ No newline at end of file diff --git a/homeassistant/components/cover/translations/el.json b/homeassistant/components/cover/translations/el.json new file mode 100644 index 0000000000000..258b57716d35f --- /dev/null +++ b/homeassistant/components/cover/translations/el.json @@ -0,0 +1,12 @@ +{ + "state": { + "_": { + "closed": "\u039a\u03bb\u03b5\u03b9\u03c3\u03c4\u03cc", + "closing": "\u039a\u03bb\u03b5\u03af\u03c3\u03b9\u03bc\u03bf", + "open": "\u0391\u03bd\u03bf\u03b9\u03c7\u03c4\u03cc", + "opening": "\u0386\u03bd\u03bf\u03b9\u03b3\u03bc\u03b1", + "stopped": "\u03a3\u03c4\u03b1\u03bc\u03ac\u03c4\u03b7\u03c3\u03b5" + } + }, + "title": "\u039a\u03ac\u03bb\u03c5\u03c8\u03b7" +} \ No newline at end of file diff --git a/homeassistant/components/cover/translations/en.json b/homeassistant/components/cover/translations/en.json new file mode 100644 index 0000000000000..de2ad4e0b1575 --- /dev/null +++ b/homeassistant/components/cover/translations/en.json @@ -0,0 +1,38 @@ +{ + "device_automation": { + "action_type": { + "close": "Close {entity_name}", + "close_tilt": "Close {entity_name} tilt", + "open": "Open {entity_name}", + "open_tilt": "Open {entity_name} tilt", + "set_position": "Set {entity_name} position", + "set_tilt_position": "Set {entity_name} tilt position" + }, + "condition_type": { + "is_closed": "{entity_name} is closed", + "is_closing": "{entity_name} is closing", + "is_open": "{entity_name} is open", + "is_opening": "{entity_name} is opening", + "is_position": "Current {entity_name} position is", + "is_tilt_position": "Current {entity_name} tilt position is" + }, + "trigger_type": { + "closed": "{entity_name} closed", + "closing": "{entity_name} closing", + "opened": "{entity_name} opened", + "opening": "{entity_name} opening", + "position": "{entity_name} position changes", + "tilt_position": "{entity_name} tilt position changes" + } + }, + "state": { + "_": { + "closed": "Closed", + "closing": "Closing", + "open": "Open", + "opening": "Opening", + "stopped": "Stopped" + } + }, + "title": "Cover" +} \ No newline at end of file diff --git a/homeassistant/components/cover/translations/es-419.json b/homeassistant/components/cover/translations/es-419.json new file mode 100644 index 0000000000000..3593ba289604e --- /dev/null +++ b/homeassistant/components/cover/translations/es-419.json @@ -0,0 +1,12 @@ +{ + "state": { + "_": { + "closed": "Cerrado", + "closing": "Cerrando", + "open": "Abierto", + "opening": "Abriendo", + "stopped": "Detenido" + } + }, + "title": "Portada" +} \ No newline at end of file diff --git a/homeassistant/components/cover/translations/es.json b/homeassistant/components/cover/translations/es.json new file mode 100644 index 0000000000000..857813eefb551 --- /dev/null +++ b/homeassistant/components/cover/translations/es.json @@ -0,0 +1,38 @@ +{ + "device_automation": { + "action_type": { + "close": "Cerrar {entity_name}", + "close_tilt": "Cerrar inclinaci\u00f3n de {entity_name}", + "open": "Abrir {entity_name}", + "open_tilt": "Abrir inclinaci\u00f3n de {entity_name}", + "set_position": "Ajustar la posici\u00f3n de {entity_name}", + "set_tilt_position": "Ajustar la posici\u00f3n de inclinaci\u00f3n de {entity_name}" + }, + "condition_type": { + "is_closed": "{entity_name} est\u00e1 cerrado", + "is_closing": "{entity_name} se est\u00e1 cerrando", + "is_open": "{entity_name} est\u00e1 abierto", + "is_opening": "{entity_name} se est\u00e1 abriendo", + "is_position": "La posici\u00f3n actual de {entity_name} es", + "is_tilt_position": "La posici\u00f3n de inclinaci\u00f3n actual de {entity_name} es" + }, + "trigger_type": { + "closed": "{entity_name} cerrado", + "closing": "{entity_name} cerrando", + "opened": "abierto {entity_name}", + "opening": "abriendo {entity_name}", + "position": "Posici\u00f3n cambiada de {entity_name}", + "tilt_position": "Cambia la posici\u00f3n de inclinaci\u00f3n de {entity_name}" + } + }, + "state": { + "_": { + "closed": "Cerrado", + "closing": "Cerrando", + "open": "Abierto", + "opening": "Abriendo", + "stopped": "Detenido" + } + }, + "title": "Persiana" +} \ No newline at end of file diff --git a/homeassistant/components/cover/translations/et.json b/homeassistant/components/cover/translations/et.json new file mode 100644 index 0000000000000..96d81b3a7b61a --- /dev/null +++ b/homeassistant/components/cover/translations/et.json @@ -0,0 +1,12 @@ +{ + "state": { + "_": { + "closed": "Suletud", + "closing": "Sulgub", + "open": "Avatud", + "opening": "Avaneb", + "stopped": "Peatatud" + } + }, + "title": "Kate" +} \ No newline at end of file diff --git a/homeassistant/components/cover/translations/eu.json b/homeassistant/components/cover/translations/eu.json new file mode 100644 index 0000000000000..e9cc846746e37 --- /dev/null +++ b/homeassistant/components/cover/translations/eu.json @@ -0,0 +1,11 @@ +{ + "state": { + "_": { + "closed": "Itxita", + "closing": "Ixten", + "open": "Irekita", + "opening": "Irekitzen", + "stopped": "Geldituta" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/cover/translations/fa.json b/homeassistant/components/cover/translations/fa.json new file mode 100644 index 0000000000000..950172ad183c5 --- /dev/null +++ b/homeassistant/components/cover/translations/fa.json @@ -0,0 +1,12 @@ +{ + "state": { + "_": { + "closed": "\u0628\u0633\u062a\u0647 \u0634\u062f\u0647", + "closing": "\u062f\u0631 \u062d\u0627\u0644 \u0628\u0633\u062a\u0647 \u0634\u062f\u0646", + "open": "\u0628\u0627\u0632", + "opening": "\u062f\u0631 \u062d\u0627\u0644 \u0628\u0627\u0632 \u0634\u062f\u0646", + "stopped": "\u0645\u062a\u0648\u0642\u0641" + } + }, + "title": "\u067e\u0648\u0634\u0634" +} \ No newline at end of file diff --git a/homeassistant/components/cover/translations/fi.json b/homeassistant/components/cover/translations/fi.json new file mode 100644 index 0000000000000..282a3d9928c32 --- /dev/null +++ b/homeassistant/components/cover/translations/fi.json @@ -0,0 +1,12 @@ +{ + "state": { + "_": { + "closed": "Suljettu", + "closing": "Suljetaan", + "open": "Auki", + "opening": "Avataan", + "stopped": "Pys\u00e4ytetty" + } + }, + "title": "Kaihtimet" +} \ No newline at end of file diff --git a/homeassistant/components/cover/translations/fr.json b/homeassistant/components/cover/translations/fr.json new file mode 100644 index 0000000000000..d9ceb569753b3 --- /dev/null +++ b/homeassistant/components/cover/translations/fr.json @@ -0,0 +1,33 @@ +{ + "device_automation": { + "action_type": { + "close": "Fermer {entity_name}" + }, + "condition_type": { + "is_closed": "{entity_name} est ferm\u00e9", + "is_closing": "{entity_name} se ferme", + "is_open": "{entity_name} est ouvert", + "is_opening": "{entity_name} est en train de s'ouvrir", + "is_position": "La position de {entity_name} est", + "is_tilt_position": "La position d'inclinaison de {entity_name} est" + }, + "trigger_type": { + "closed": "{entity_name} ferm\u00e9", + "closing": "{entity_name} fermeture", + "opened": "{entity_name} ouvert", + "opening": "{entity_name} ouverture", + "position": "{entity_name} changement de position", + "tilt_position": "{entity_name} changement d'inclinaison" + } + }, + "state": { + "_": { + "closed": "Ferm\u00e9", + "closing": "Fermeture", + "open": "Ouvert", + "opening": "Ouverture", + "stopped": "Arr\u00eat\u00e9" + } + }, + "title": "Volets" +} \ No newline at end of file diff --git a/homeassistant/components/cover/translations/gsw.json b/homeassistant/components/cover/translations/gsw.json new file mode 100644 index 0000000000000..2f85109e0cb85 --- /dev/null +++ b/homeassistant/components/cover/translations/gsw.json @@ -0,0 +1,12 @@ +{ + "state": { + "_": { + "closed": "Gschloss\u00e4", + "closing": "Am schliesse", + "open": "Off\u00e4", + "opening": "Am \u00f6ffn\u00e4", + "stopped": "Gstoppt" + } + }, + "title": "Roulade" +} \ No newline at end of file diff --git a/homeassistant/components/cover/translations/he.json b/homeassistant/components/cover/translations/he.json new file mode 100644 index 0000000000000..ebc7d39b450b7 --- /dev/null +++ b/homeassistant/components/cover/translations/he.json @@ -0,0 +1,12 @@ +{ + "state": { + "_": { + "closed": "\u05e0\u05e1\u05d2\u05e8", + "closing": "\u05e1\u05d5\u05d2\u05e8", + "open": "\u05e4\u05ea\u05d5\u05d7", + "opening": "\u05e4\u05d5\u05ea\u05d7", + "stopped": "\u05e2\u05e6\u05d5\u05e8" + } + }, + "title": "\u05d5\u05d9\u05dc\u05d5\u05df" +} \ No newline at end of file diff --git a/homeassistant/components/cover/translations/hr.json b/homeassistant/components/cover/translations/hr.json new file mode 100644 index 0000000000000..5b9e285566ce1 --- /dev/null +++ b/homeassistant/components/cover/translations/hr.json @@ -0,0 +1,12 @@ +{ + "state": { + "_": { + "closed": "Zatvoreno", + "closing": "Zatvaranje", + "open": "Otvoreno", + "opening": "Otvaranje", + "stopped": "zaustavljen" + } + }, + "title": "Poklopac" +} \ No newline at end of file diff --git a/homeassistant/components/cover/translations/hu.json b/homeassistant/components/cover/translations/hu.json new file mode 100644 index 0000000000000..6d48cca125130 --- /dev/null +++ b/homeassistant/components/cover/translations/hu.json @@ -0,0 +1,38 @@ +{ + "device_automation": { + "action_type": { + "close": "{entity_name} z\u00e1r\u00e1sa", + "close_tilt": "{entity_name} d\u00f6nt\u00e9s z\u00e1r\u00e1sa", + "open": "{entity_name} nyit\u00e1sa", + "open_tilt": "{entity_name} d\u00f6nt\u00e9s nyit\u00e1sa", + "set_position": "{entity_name} poz\u00edci\u00f3j\u00e1nak be\u00e1ll\u00edt\u00e1sa", + "set_tilt_position": "{entity_name} d\u00f6nt\u00e9si poz\u00edci\u00f3j\u00e1nak be\u00e1ll\u00edt\u00e1sa" + }, + "condition_type": { + "is_closed": "{entity_name} z\u00e1rva van", + "is_closing": "{entity_name} z\u00e1r\u00f3dik", + "is_open": "{entity_name} nyitva van", + "is_opening": "{entity_name} ny\u00edlik", + "is_position": "{entity_name} jelenlegi poz\u00edci\u00f3ja", + "is_tilt_position": "{entity_name} jelenlegi d\u00f6nt\u00e9si poz\u00edci\u00f3ja" + }, + "trigger_type": { + "closed": "{entity_name} bez\u00e1r\u00f3dott", + "closing": "{entity_name} z\u00e1r\u00f3dik", + "opened": "{entity_name} kiny\u00edlt", + "opening": "{entity_name} ny\u00edlik", + "position": "{entity_name} poz\u00edci\u00f3ja v\u00e1ltozik", + "tilt_position": "{entity_name} d\u00f6nt\u00e9si poz\u00edci\u00f3ja v\u00e1ltozik" + } + }, + "state": { + "_": { + "closed": "Z\u00e1rva", + "closing": "Z\u00e1r\u00e1s", + "open": "Nyitva", + "opening": "Nyit\u00e1s", + "stopped": "Meg\u00e1ll\u00edtva" + } + }, + "title": "Bor\u00edt\u00f3" +} \ No newline at end of file diff --git a/homeassistant/components/cover/translations/hy.json b/homeassistant/components/cover/translations/hy.json new file mode 100644 index 0000000000000..6352c9d25f453 --- /dev/null +++ b/homeassistant/components/cover/translations/hy.json @@ -0,0 +1,12 @@ +{ + "state": { + "_": { + "closed": "\u0553\u0561\u056f\u057e\u0561\u056e", + "closing": "\u0553\u0561\u056f\u0578\u0582\u0574", + "open": "\u0532\u0561\u0581", + "opening": "\u0532\u0561\u0581\u0578\u0582\u0574", + "stopped": "\u0534\u0561\u0564\u0561\u0580\u0565\u0581" + } + }, + "title": "\u053e\u0561\u056e\u056f\u0565\u056c" +} \ No newline at end of file diff --git a/homeassistant/components/cover/translations/id.json b/homeassistant/components/cover/translations/id.json new file mode 100644 index 0000000000000..b38fcf86a174e --- /dev/null +++ b/homeassistant/components/cover/translations/id.json @@ -0,0 +1,12 @@ +{ + "state": { + "_": { + "closed": "Tertutup", + "closing": "Menutup", + "open": "Buka", + "opening": "Membuka", + "stopped": "Terhenti" + } + }, + "title": "Penutup" +} \ No newline at end of file diff --git a/homeassistant/components/cover/translations/is.json b/homeassistant/components/cover/translations/is.json new file mode 100644 index 0000000000000..4a61c4f7cc548 --- /dev/null +++ b/homeassistant/components/cover/translations/is.json @@ -0,0 +1,12 @@ +{ + "state": { + "_": { + "closed": "Loka\u00f0", + "closing": "Loka", + "open": "Opin", + "opening": "Opna", + "stopped": "St\u00f6\u00f0vu\u00f0" + } + }, + "title": "Gluggatj\u00f6ld" +} \ No newline at end of file diff --git a/homeassistant/components/cover/translations/it.json b/homeassistant/components/cover/translations/it.json new file mode 100644 index 0000000000000..95f2e34d8eb14 --- /dev/null +++ b/homeassistant/components/cover/translations/it.json @@ -0,0 +1,38 @@ +{ + "device_automation": { + "action_type": { + "close": "Chiudi {entity_name}", + "close_tilt": "Chiudi l'inclinazione di {entity_name}", + "open": "Apri {entity_name}", + "open_tilt": "Apri l'inclinazione di {entity_name}", + "set_position": "Imposta la posizione di {entity_name}", + "set_tilt_position": "Imposta la posizione di inclinazione di {entity_name}" + }, + "condition_type": { + "is_closed": "{entity_name} \u00e8 chiuso", + "is_closing": "{entity_name} si sta chiudendo", + "is_open": "{entity_name} \u00e8 aperto", + "is_opening": "{entity_name} si sta aprendo", + "is_position": "La posizione attuale di {entity_name} \u00e8", + "is_tilt_position": "La posizione d'inclinazione attuale di {entity_name} \u00e8" + }, + "trigger_type": { + "closed": "{entity_name} chiuso", + "closing": "{entity_name} in chiusura", + "opened": "{entity_name} aperto", + "opening": "{entity_name} in apertura", + "position": "{entity_name} cambiamenti della posizione", + "tilt_position": "{entity_name} cambiamenti della posizione d'inclinazione" + } + }, + "state": { + "_": { + "closed": "Chiuso", + "closing": "In chiusura", + "open": "Aperto", + "opening": "In apertura", + "stopped": "Arrestato" + } + }, + "title": "Scuri" +} \ No newline at end of file diff --git a/homeassistant/components/cover/translations/ja.json b/homeassistant/components/cover/translations/ja.json new file mode 100644 index 0000000000000..859240315bfb7 --- /dev/null +++ b/homeassistant/components/cover/translations/ja.json @@ -0,0 +1,8 @@ +{ + "state": { + "_": { + "closed": "\u9589\u9396", + "opening": "\u6249" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/cover/translations/ko.json b/homeassistant/components/cover/translations/ko.json new file mode 100644 index 0000000000000..0a666a8bd82d6 --- /dev/null +++ b/homeassistant/components/cover/translations/ko.json @@ -0,0 +1,38 @@ +{ + "device_automation": { + "action_type": { + "close": "{entity_name} \ub2eb\uae30", + "close_tilt": "{entity_name} \ub2eb\uae30", + "open": "{entity_name} \uc5f4\uae30", + "open_tilt": "{entity_name} \uc5f4\uae30", + "set_position": "{entity_name} \uac1c\ud3d0 \uc704\uce58 \uc124\uc815\ud558\uae30", + "set_tilt_position": "{entity_name} \uac1c\ud3d0 \uae30\uc6b8\uae30 \uc124\uc815\ud558\uae30" + }, + "condition_type": { + "is_closed": "{entity_name} \uc774(\uac00) \ub2eb\ud600 \uc788\uc73c\uba74", + "is_closing": "{entity_name} \uc774(\uac00) \ub2eb\ud788\ub294 \uc911\uc774\uba74", + "is_open": "{entity_name} \uc774(\uac00) \uc5f4\ub824 \uc788\uc73c\uba74", + "is_opening": "{entity_name} \uc774(\uac00) \uc5f4\ub9ac\ub294 \uc911\uc774\uba74", + "is_position": "\ud604\uc7ac {entity_name} \uac1c\ud3d0 \uc704\uce58\uac00 ~ \uc774\uba74", + "is_tilt_position": "\ud604\uc7ac {entity_name} \uac1c\ud3d0 \uae30\uc6b8\uae30\uac00 ~ \uc774\uba74" + }, + "trigger_type": { + "closed": "{entity_name} \uc774(\uac00) \ub2eb\ud790 \ub54c", + "closing": "{entity_name} \uc774(\uac00) \ub2eb\ud788\ub294 \uc911\uc77c \ub54c", + "opened": "{entity_name} \uc774(\uac00) \uc5f4\ub9b4 \ub54c", + "opening": "{entity_name} \uc774(\uac00) \uc5f4\ub9ac\ub294 \uc911\uc77c \ub54c", + "position": "{entity_name} \uac1c\ud3d0 \uc704\uce58\uac00 \ubcc0\ud560 \ub54c", + "tilt_position": "{entity_name} \uac1c\ud3d0 \uae30\uc6b8\uae30\uac00 \ubcc0\ud560 \ub54c" + } + }, + "state": { + "_": { + "closed": "\ub2eb\ud798", + "closing": "\ub2eb\ub294\uc911", + "open": "\uc5f4\ub9bc", + "opening": "\uc5ec\ub294\uc911", + "stopped": "\uba48\ucda4" + } + }, + "title": "\uc5ec\ub2eb\uc774" +} \ No newline at end of file diff --git a/homeassistant/components/cover/translations/lb.json b/homeassistant/components/cover/translations/lb.json new file mode 100644 index 0000000000000..4aff8a3f329b3 --- /dev/null +++ b/homeassistant/components/cover/translations/lb.json @@ -0,0 +1,38 @@ +{ + "device_automation": { + "action_type": { + "close": "{entity_name} zoumaachen", + "close_tilt": "{entity_name} Kipp zoumaachen", + "open": "{entity_name} opmaachen", + "open_tilt": "{entity_name} op Kipp stelle", + "set_position": "{entity_name} positioun programm\u00e9ieren", + "set_tilt_position": "{entity_name} kipp positioun programm\u00e9ieren" + }, + "condition_type": { + "is_closed": "{entity_name} ass zou", + "is_closing": "{entity_name} g\u00ebtt zougemaach", + "is_open": "{entity_name} ass op", + "is_opening": "{entity_name} g\u00ebtt opgemaach", + "is_position": "Aktuell {entity_name} positioun ass", + "is_tilt_position": "Aktuell {entity_name} kipp positioun ass" + }, + "trigger_type": { + "closed": "{entity_name} gouf zougemaach", + "closing": "{entity_name} mecht zou", + "opened": "{entity_name} gouf opgemaach", + "opening": "{entity_name} mecht op", + "position": "{entity_name} positioun \u00e4nnert", + "tilt_position": "{entity_name} kipp positioun ge\u00e4nnert" + } + }, + "state": { + "_": { + "closed": "Zou", + "closing": "G\u00ebtt zougemaach", + "open": "Op", + "opening": "G\u00ebtt opgemaach", + "stopped": "Gestoppt" + } + }, + "title": "Paart" +} \ No newline at end of file diff --git a/homeassistant/components/cover/translations/lv.json b/homeassistant/components/cover/translations/lv.json new file mode 100644 index 0000000000000..618e81b970d10 --- /dev/null +++ b/homeassistant/components/cover/translations/lv.json @@ -0,0 +1,12 @@ +{ + "state": { + "_": { + "closed": "Sl\u0113gts", + "closing": "Sl\u0113dzas", + "open": "Atv\u0113rts", + "opening": "Atveras", + "stopped": "Aptur\u0113ts" + } + }, + "title": "Nosegi" +} \ No newline at end of file diff --git a/homeassistant/components/cover/translations/nb.json b/homeassistant/components/cover/translations/nb.json new file mode 100644 index 0000000000000..c92cb789d030f --- /dev/null +++ b/homeassistant/components/cover/translations/nb.json @@ -0,0 +1,12 @@ +{ + "state": { + "_": { + "closed": "Lukket", + "closing": "Lukker", + "open": "\u00c5pen", + "opening": "\u00c5pner", + "stopped": "Stoppet" + } + }, + "title": "Cover" +} \ No newline at end of file diff --git a/homeassistant/components/cover/translations/nl.json b/homeassistant/components/cover/translations/nl.json new file mode 100644 index 0000000000000..7d68d78641e54 --- /dev/null +++ b/homeassistant/components/cover/translations/nl.json @@ -0,0 +1,38 @@ +{ + "device_automation": { + "action_type": { + "close": "Sluit {entity_name}", + "close_tilt": "Sluit de kanteling van {entity_name}", + "open": "Open {entity_name}", + "open_tilt": "Open de kanteling {entity_name}", + "set_position": "Stel de positie van {entity_name} in", + "set_tilt_position": "Stel de {entity_name} kantelpositie in" + }, + "condition_type": { + "is_closed": "{entity_name} is gesloten", + "is_closing": "{entity_name} wordt gesloten", + "is_open": "{entity_name} is open", + "is_opening": "{entity_name} wordt geopend", + "is_position": "Huidige {entity_name} positie is", + "is_tilt_position": "Huidige {entity_name} kantel positie is" + }, + "trigger_type": { + "closed": "{entity_name} gesloten", + "closing": "{entity_name} wordt gesloten", + "opened": "{entity_name} geopend", + "opening": "{entity_name} wordt geopend", + "position": "{entity_name} positiewijzigingen", + "tilt_position": "{entity_name} kantel positiewijzigingen" + } + }, + "state": { + "_": { + "closed": "Gesloten", + "closing": "Sluit", + "open": "Open", + "opening": "Opent", + "stopped": "Gestopt" + } + }, + "title": "Bedekking" +} \ No newline at end of file diff --git a/homeassistant/components/cover/translations/nn.json b/homeassistant/components/cover/translations/nn.json new file mode 100644 index 0000000000000..5be3b85301894 --- /dev/null +++ b/homeassistant/components/cover/translations/nn.json @@ -0,0 +1,12 @@ +{ + "state": { + "_": { + "closed": "Lukka", + "closing": "Lukkar", + "open": "Open", + "opening": "Opnar", + "stopped": "Stoppa" + } + }, + "title": "Dekke" +} \ No newline at end of file diff --git a/homeassistant/components/cover/translations/no.json b/homeassistant/components/cover/translations/no.json new file mode 100644 index 0000000000000..0aab609ea63af --- /dev/null +++ b/homeassistant/components/cover/translations/no.json @@ -0,0 +1,36 @@ +{ + "device_automation": { + "action_type": { + "close": "Lukk {entity_name}", + "close_tilt": "Lukk {entity_name} tilt", + "open": "\u00c5pne {entity_name}", + "open_tilt": "\u00c5pne {entity_name} tilt", + "set_position": "Angi {entity_name} posisjon", + "set_tilt_position": "Angi {entity_name} tilt posisjon" + }, + "condition_type": { + "is_closed": "{entity_name} er lukket", + "is_closing": "{entity_name} lukker", + "is_open": "{entity_name} er \u00e5pen", + "is_opening": "{entity_name} \u00e5pner", + "is_position": "N\u00e5v\u00e6rende {entity_name} posisjon er", + "is_tilt_position": "N\u00e5v\u00e6rende {entity_name} tilt posisjon er" + }, + "trigger_type": { + "closed": "{entity_name} lukket", + "closing": "{entity_name} lukker", + "opened": "{entity_name} \u00e5pnet", + "opening": "{entity_name} \u00e5pner", + "position": "{entity_name} posisjon endringer", + "tilt_position": "{entity_name} tilt posisjon endringer" + } + }, + "state": { + "_": { + "closing": "Lukker", + "opening": "\u00c5pner", + "stopped": "Stoppet" + } + }, + "title": "Dekke" +} \ No newline at end of file diff --git a/homeassistant/components/cover/translations/pl.json b/homeassistant/components/cover/translations/pl.json new file mode 100644 index 0000000000000..501b2f78d7a52 --- /dev/null +++ b/homeassistant/components/cover/translations/pl.json @@ -0,0 +1,38 @@ +{ + "device_automation": { + "action_type": { + "close": "zamknij {entity_name}", + "close_tilt": "zamknij pochylenie {entity_name}", + "open": "otw\u00f3rz {entity_name}", + "open_tilt": "otw\u00f3rz {entity_name} do pochylenia", + "set_position": "ustaw pozycj\u0119 {entity_name}", + "set_tilt_position": "ustaw pochylenie {entity_name}" + }, + "condition_type": { + "is_closed": "pokrywa {entity_name} jest zamkni\u0119ta", + "is_closing": "{entity_name} si\u0119 zamyka", + "is_open": "pokrywa {entity_name} jest otwarta", + "is_opening": "{entity_name} si\u0119 otwiera", + "is_position": "pozycja pokrywy {entity_name} to", + "is_tilt_position": "pochylenie pokrywy {entity_name} to" + }, + "trigger_type": { + "closed": "nast\u0105pi zamkni\u0119cie {entity_name}", + "closing": "{entity_name} si\u0119 zamyka", + "opened": "nast\u0105pi otwarcie {entity_name}", + "opening": "{entity_name} si\u0119 otwiera", + "position": "zmieni si\u0119 pozycja pokrywy {entity_name}", + "tilt_position": "zmieni si\u0119 pochylenie pokrywy {entity_name}" + } + }, + "state": { + "_": { + "closed": "zamkni\u0119ta", + "closing": "zamykanie", + "open": "otwarta", + "opening": "otwieranie", + "stopped": "zatrzymany" + } + }, + "title": "Pokrywa" +} \ No newline at end of file diff --git a/homeassistant/components/cover/translations/pt-BR.json b/homeassistant/components/cover/translations/pt-BR.json new file mode 100644 index 0000000000000..3403666dfb94c --- /dev/null +++ b/homeassistant/components/cover/translations/pt-BR.json @@ -0,0 +1,12 @@ +{ + "state": { + "_": { + "closed": "Fechado", + "closing": "Fechando", + "open": "Aberto", + "opening": "Abrindo", + "stopped": "Parado" + } + }, + "title": "Cobertura" +} \ No newline at end of file diff --git a/homeassistant/components/cover/translations/pt.json b/homeassistant/components/cover/translations/pt.json new file mode 100644 index 0000000000000..7c50b7a63cdab --- /dev/null +++ b/homeassistant/components/cover/translations/pt.json @@ -0,0 +1,28 @@ +{ + "device_automation": { + "condition_type": { + "is_closed": "{entity_name} est\u00e1 fechada", + "is_closing": "{entity_name} est\u00e1 a fechar", + "is_open": "{entity_name} est\u00e1 aberta", + "is_opening": "{entity_name} est\u00e1 a abrir", + "is_position": "A posi\u00e7\u00e3o atual de {entity_name} \u00e9", + "is_tilt_position": "A inclina\u00e7\u00e3o actual de {entity_name} \u00e9" + }, + "trigger_type": { + "closed": "{entity_name} fechou", + "closing": "{entity_name} est\u00e1 a fechar", + "opened": "{entity_name} abriu", + "opening": "{entity_name} est\u00e1 a abrir" + } + }, + "state": { + "_": { + "closed": "Fechada", + "closing": "A fechar", + "open": "Aberta", + "opening": "A abrir", + "stopped": "Parado" + } + }, + "title": "Cobertura" +} \ No newline at end of file diff --git a/homeassistant/components/cover/translations/ro.json b/homeassistant/components/cover/translations/ro.json new file mode 100644 index 0000000000000..8c6d371c2bbec --- /dev/null +++ b/homeassistant/components/cover/translations/ro.json @@ -0,0 +1,12 @@ +{ + "state": { + "_": { + "closed": "\u00cenchis", + "closing": "\u00cenchidere", + "open": "Deschis", + "opening": "Deschidere", + "stopped": "Oprit" + } + }, + "title": "Jaluzea" +} \ No newline at end of file diff --git a/homeassistant/components/cover/translations/ru.json b/homeassistant/components/cover/translations/ru.json new file mode 100644 index 0000000000000..df35c58b7ddb7 --- /dev/null +++ b/homeassistant/components/cover/translations/ru.json @@ -0,0 +1,36 @@ +{ + "device_automation": { + "action_type": { + "close": "\u0417\u0430\u043a\u0440\u044b\u0442\u044c {entity_name}", + "open": "\u041e\u0442\u043a\u0440\u044b\u0442\u044c {entity_name}", + "set_position": "\u0423\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435 {entity_name}", + "set_tilt_position": "\u0423\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u043d\u0430\u043a\u043b\u043e\u043d\u0430 {entity_name}" + }, + "condition_type": { + "is_closed": "{entity_name} \u0432 \u0437\u0430\u043a\u0440\u044b\u0442\u043e\u043c \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0438", + "is_closing": "{entity_name} \u0437\u0430\u043a\u0440\u044b\u0432\u0430\u0435\u0442\u0441\u044f", + "is_open": "{entity_name} \u0432 \u043e\u0442\u043a\u0440\u044b\u0442\u043e\u043c \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0438", + "is_opening": "{entity_name} \u043e\u0442\u043a\u0440\u044b\u0432\u0430\u0435\u0442\u0441\u044f", + "is_position": "{entity_name} \u043d\u0430\u0445\u043e\u0434\u0438\u0442\u0441\u044f \u0432 \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0438", + "is_tilt_position": "{entity_name} \u0432 \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0438 \u043d\u0430\u043a\u043b\u043e\u043d\u0430" + }, + "trigger_type": { + "closed": "{entity_name} \u0437\u0430\u043a\u0440\u044b\u0442\u043e", + "closing": "{entity_name} \u0437\u0430\u043a\u0440\u044b\u0432\u0430\u0435\u0442\u0441\u044f", + "opened": "{entity_name} \u043e\u0442\u043a\u0440\u044b\u0442\u043e", + "opening": "{entity_name} \u043e\u0442\u043a\u0440\u044b\u0432\u0430\u0435\u0442\u0441\u044f", + "position": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435", + "tilt_position": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u043d\u0430\u043a\u043b\u043e\u043d" + } + }, + "state": { + "_": { + "closed": "\u0417\u0430\u043a\u0440\u044b\u0442\u043e", + "closing": "\u0417\u0430\u043a\u0440\u044b\u0432\u0430\u0435\u0442\u0441\u044f", + "open": "\u041e\u0442\u043a\u0440\u044b\u0442\u043e", + "opening": "\u041e\u0442\u043a\u0440\u044b\u0432\u0430\u0435\u0442\u0441\u044f", + "stopped": "\u041e\u0441\u0442\u0430\u043d\u043e\u0432\u043b\u0435\u043d\u043e" + } + }, + "title": "\u0416\u0430\u043b\u044e\u0437\u0438" +} \ No newline at end of file diff --git a/homeassistant/components/cover/translations/sk.json b/homeassistant/components/cover/translations/sk.json new file mode 100644 index 0000000000000..57379849b3256 --- /dev/null +++ b/homeassistant/components/cover/translations/sk.json @@ -0,0 +1,12 @@ +{ + "state": { + "_": { + "closed": "Zatvoren\u00e9", + "closing": "Zatv\u00e1ra sa", + "open": "Otvoren\u00e9", + "opening": "Otv\u00e1ra sa", + "stopped": "Zastaven\u00e9" + } + }, + "title": "Kryt" +} \ No newline at end of file diff --git a/homeassistant/components/cover/translations/sl.json b/homeassistant/components/cover/translations/sl.json new file mode 100644 index 0000000000000..d3f29a780442d --- /dev/null +++ b/homeassistant/components/cover/translations/sl.json @@ -0,0 +1,38 @@ +{ + "device_automation": { + "action_type": { + "close": "Zapri {entity_name}", + "close_tilt": "Zapri {entity_name} nagib", + "open": "Odprite {entity_name}", + "open_tilt": "Odprite {entity_name} nagib", + "set_position": "Nastavite polo\u017eaj {entity_name}", + "set_tilt_position": "Nastavite {entity_name} nagibni polo\u017eaj" + }, + "condition_type": { + "is_closed": "{entity_name} je/so zaprt/a", + "is_closing": "{entity_name} se zapira/jo", + "is_open": "{entity_name} je odprt/a/o", + "is_opening": "{entity_name} se odpira/jo", + "is_position": "Trenutna pozicija {entity_name} je", + "is_tilt_position": "Trenutni polo\u017eaj nagiba {entity_name} je" + }, + "trigger_type": { + "closed": "{entity_name} se je/so se zaprla", + "closing": "{entity_name} se zapira/jo", + "opened": "{entity_name} se/so je odprla", + "opening": "{entity_name} se odpira/jo", + "position": "{entity_name} spremembe polo\u017eaja", + "tilt_position": "{entity_name} spremembe nagiba" + } + }, + "state": { + "_": { + "closed": "Zaprto", + "closing": "Zapiranje", + "open": "Odprto", + "opening": "Odpiranje", + "stopped": "Ustavljeno" + } + }, + "title": "Cover" +} \ No newline at end of file diff --git a/homeassistant/components/cover/translations/sv.json b/homeassistant/components/cover/translations/sv.json new file mode 100644 index 0000000000000..0a8dbecf12442 --- /dev/null +++ b/homeassistant/components/cover/translations/sv.json @@ -0,0 +1,30 @@ +{ + "device_automation": { + "condition_type": { + "is_closed": "{entity_name} \u00e4r st\u00e4ngd", + "is_closing": "{entity_name} st\u00e4ngs", + "is_open": "{entity_name} \u00e4r \u00f6ppen", + "is_opening": "{entity_name} \u00f6ppnas", + "is_position": "Aktuell position f\u00f6r {entity_name} \u00e4r", + "is_tilt_position": "Aktuell {entity_name} lutningsposition \u00e4r" + }, + "trigger_type": { + "closed": "{entity_name} st\u00e4ngd", + "closing": "{entity_name} st\u00e4nger", + "opened": "{entity_name} \u00f6ppnades", + "opening": "{entity_name} \u00f6ppnas", + "position": "{entity_name} position \u00e4ndras", + "tilt_position": "{entity_name} lutningsposition \u00e4ndras" + } + }, + "state": { + "_": { + "closed": "St\u00e4ngd", + "closing": "St\u00e4nger", + "open": "\u00d6ppen", + "opening": "\u00d6ppnar", + "stopped": "Stoppad" + } + }, + "title": "Rullgardin" +} \ No newline at end of file diff --git a/homeassistant/components/cover/translations/ta.json b/homeassistant/components/cover/translations/ta.json new file mode 100644 index 0000000000000..4107fa9a18f80 --- /dev/null +++ b/homeassistant/components/cover/translations/ta.json @@ -0,0 +1,11 @@ +{ + "state": { + "_": { + "closed": "\u0bae\u0bc2\u0b9f\u0baa\u0bcd\u0baa\u0b9f\u0bcd\u0b9f\u0ba4\u0bc1", + "closing": "\u0bae\u0bc2\u0b9f\u0bc1\u0b95\u0bbf\u0bb1\u0ba4\u0bc1", + "open": "\u0ba4\u0bbf\u0bb1\u0b95\u0bcd\u0b95\u0baa\u0bcd\u0baa\u0b9f\u0bcd\u0b9f\u0bc1\u0bb3\u0bcd\u0bb3\u0ba4\u0bc1", + "opening": "\u0ba4\u0bbf\u0bb1\u0b95\u0bcd\u0b95\u0bbf\u0bb1\u0ba4\u0bc1", + "stopped": "\u0ba8\u0bbf\u0bb1\u0bc1\u0ba4\u0bcd\u0ba4\u0baa\u0bcd\u0baa\u0b9f\u0bcd\u0b9f\u0ba4\u0bc1" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/cover/translations/te.json b/homeassistant/components/cover/translations/te.json new file mode 100644 index 0000000000000..41042d9897728 --- /dev/null +++ b/homeassistant/components/cover/translations/te.json @@ -0,0 +1,12 @@ +{ + "state": { + "_": { + "closed": "\u0c2e\u0c42\u0c38\u0c41\u0c15\u0c41\u0c02\u0c26\u0c3f", + "closing": "\u0c2e\u0c42\u0c38\u0c41\u0c15\u0c41\u0c02\u0c1f\u0c4b\u0c02\u0c26\u0c3f", + "open": "\u0c24\u0c46\u0c30\u0c3f\u0c1a\u0c3f\u0c35\u0c41\u0c02\u0c26\u0c3f", + "opening": "\u0c24\u0c46\u0c30\u0c41\u0c1a\u0c41\u0c15\u0c41\u0c02\u0c1f\u0c4b\u0c02\u0c26\u0c3f", + "stopped": "\u0c06\u0c17\u0c3f\u0c35\u0c41\u0c02\u0c26\u0c3f" + } + }, + "title": "\u0c15\u0c35\u0c30\u0c4d" +} \ No newline at end of file diff --git a/homeassistant/components/cover/translations/th.json b/homeassistant/components/cover/translations/th.json new file mode 100644 index 0000000000000..8213c7c1e12b9 --- /dev/null +++ b/homeassistant/components/cover/translations/th.json @@ -0,0 +1,12 @@ +{ + "state": { + "_": { + "closed": "\u0e1b\u0e34\u0e14", + "closing": "\u0e01\u0e33\u0e25\u0e31\u0e07\u0e1b\u0e34\u0e14", + "open": "\u0e40\u0e1b\u0e34\u0e14", + "opening": "\u0e01\u0e33\u0e25\u0e31\u0e07\u0e40\u0e1b\u0e34\u0e14", + "stopped": "\u0e2b\u0e22\u0e38\u0e14" + } + }, + "title": "\u0e21\u0e48\u0e32\u0e19" +} \ No newline at end of file diff --git a/homeassistant/components/cover/translations/tr.json b/homeassistant/components/cover/translations/tr.json new file mode 100644 index 0000000000000..98bc8cdb18d7f --- /dev/null +++ b/homeassistant/components/cover/translations/tr.json @@ -0,0 +1,12 @@ +{ + "state": { + "_": { + "closed": "Kapal\u0131", + "closing": "Kapan\u0131yor", + "open": "A\u00e7\u0131k", + "opening": "A\u00e7\u0131l\u0131yor", + "stopped": "Durduruldu" + } + }, + "title": "Panjur" +} \ No newline at end of file diff --git a/homeassistant/components/cover/translations/uk.json b/homeassistant/components/cover/translations/uk.json new file mode 100644 index 0000000000000..0e0917177e6d0 --- /dev/null +++ b/homeassistant/components/cover/translations/uk.json @@ -0,0 +1,12 @@ +{ + "state": { + "_": { + "closed": "\u0417\u0430\u0447\u0438\u043d\u0435\u043d\u043e", + "closing": "\u0417\u0430\u043a\u0440\u0438\u0432\u0430\u0454\u0442\u044c\u0441\u044f", + "open": "\u0412\u0456\u0434\u0447\u0438\u043d\u0435\u043d\u043e", + "opening": "\u0412\u0456\u0434\u043a\u0440\u0438\u0432\u0430\u0454\u0442\u044c\u0441\u044f", + "stopped": "\u041f\u0440\u0438\u0437\u0443\u043f\u0438\u043d\u0435\u043d\u043e" + } + }, + "title": "\u0417\u0430\u0433\u043e\u043b\u043e\u0432\u043e\u043a" +} \ No newline at end of file diff --git a/homeassistant/components/cover/translations/vi.json b/homeassistant/components/cover/translations/vi.json new file mode 100644 index 0000000000000..4cdf974d0b6e9 --- /dev/null +++ b/homeassistant/components/cover/translations/vi.json @@ -0,0 +1,12 @@ +{ + "state": { + "_": { + "closed": "\u0110\u00e3 \u0111\u00f3ng", + "closing": "\u0110ang \u0111\u00f3ng", + "open": "M\u1edf", + "opening": "\u0110ang m\u1edf", + "stopped": "\u0110\u00e3 d\u1eebng" + } + }, + "title": "R\u00e8m, c\u1eeda cu\u1ed1n" +} \ No newline at end of file diff --git a/homeassistant/components/cover/translations/zh-Hans.json b/homeassistant/components/cover/translations/zh-Hans.json new file mode 100644 index 0000000000000..2929a2cd33e60 --- /dev/null +++ b/homeassistant/components/cover/translations/zh-Hans.json @@ -0,0 +1,12 @@ +{ + "state": { + "_": { + "closed": "\u5df2\u5173\u95ed", + "closing": "\u6b63\u5728\u5173\u95ed", + "open": "\u5df2\u6253\u5f00", + "opening": "\u6b63\u5728\u6253\u5f00", + "stopped": "\u5df2\u505c\u6b62" + } + }, + "title": "\u5377\u5e18" +} \ No newline at end of file diff --git a/homeassistant/components/cover/translations/zh-Hant.json b/homeassistant/components/cover/translations/zh-Hant.json new file mode 100644 index 0000000000000..31c0900af9a1f --- /dev/null +++ b/homeassistant/components/cover/translations/zh-Hant.json @@ -0,0 +1,38 @@ +{ + "device_automation": { + "action_type": { + "close": "\u95dc\u9589{entity_name}", + "close_tilt": "\u95dc\u9589{entity_name}\u7a97\u7c3e", + "open": "\u958b\u555f{entity_name}", + "open_tilt": "\u958b\u555f{entity_name}\u7a97\u7c3e", + "set_position": "\u8a2d\u5b9a{entity_name}\u4f4d\u7f6e", + "set_tilt_position": "\u8a2d\u5b9a{entity_name}\u5e8a\u7c3e\u4f4d\u7f6e" + }, + "condition_type": { + "is_closed": "{entity_name}\u5df2\u95dc\u9589", + "is_closing": "{entity_name}\u6b63\u5728\u95dc\u9589", + "is_open": "{entity_name}\u5df2\u958b\u555f", + "is_opening": "{entity_name}\u6b63\u5728\u958b\u555f", + "is_position": "\u76ee\u524d{entity_name}\u4f4d\u7f6e\u70ba", + "is_tilt_position": "\u76ee\u524d{entity_name}\u6a19\u984c\u4f4d\u7f6e\u70ba" + }, + "trigger_type": { + "closed": "{entity_name}\u5df2\u95dc\u9589", + "closing": "{entity_name}\u6b63\u5728\u95dc\u9589", + "opened": "{entity_name}\u5df2\u958b\u555f", + "opening": "{entity_name}\u6b63\u5728\u958b\u555f", + "position": "{entity_name}\u4f4d\u7f6e\u8b8a\u66f4", + "tilt_position": "{entity_name}\u6a19\u984c\u4f4d\u7f6e\u8b8a\u66f4" + } + }, + "state": { + "_": { + "closed": "\u95dc\u9589", + "closing": "\u95dc\u9589\u4e2d", + "open": "\u958b\u555f", + "opening": "\u958b\u555f\u4e2d", + "stopped": "\u505c\u6b62" + } + }, + "title": "\u6372\u7c3e/\u9580" +} \ No newline at end of file diff --git a/homeassistant/components/cppm_tracker/device_tracker.py b/homeassistant/components/cppm_tracker/device_tracker.py old mode 100755 new mode 100644 index 608ce6dad6bca..1bb723091d446 --- a/homeassistant/components/cppm_tracker/device_tracker.py +++ b/homeassistant/components/cppm_tracker/device_tracker.py @@ -1,39 +1,43 @@ """Support for ClearPass Policy Manager.""" -import logging from datetime import timedelta +import logging +from clearpasspy import ClearPass 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 + DOMAIN, + PLATFORM_SCHEMA, + DeviceScanner, ) +from homeassistant.const import CONF_API_KEY, CONF_HOST +import homeassistant.helpers.config_validation as cv SCAN_INTERVAL = timedelta(seconds=120) -CLIENT_ID = 'client_id' +CLIENT_ID = "client_id" -GRANT_TYPE = 'client_credentials' +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, -}) +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] + "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: @@ -53,25 +57,22 @@ def __init__(self, cppm): def scan_devices(self): """Initialize scanner.""" self.get_cppm_data() - return [device['mac'] for device in self.results] + 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) + 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'] + 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'] - } + if self._cppm.online_status(item["mac_address"]): + device = {"mac": item["mac_address"], "name": item["mac_address"]} devices.append(device) else: continue diff --git a/homeassistant/components/cppm_tracker/manifest.json b/homeassistant/components/cppm_tracker/manifest.json index 5a1bdbf5a452e..053e0ea0ba113 100644 --- a/homeassistant/components/cppm_tracker/manifest.json +++ b/homeassistant/components/cppm_tracker/manifest.json @@ -1,10 +1,7 @@ { "domain": "cppm_tracker", - "name": "Cppm tracker", - "documentation": "https://www.home-assistant.io/components/cppm_tracker", - "requirements": [ - "clearpasspy==1.0.2" - ], - "dependencies": [], + "name": "Aruba ClearPass", + "documentation": "https://www.home-assistant.io/integrations/cppm_tracker", + "requirements": ["clearpasspy==1.0.2"], "codeowners": [] } diff --git a/homeassistant/components/cpuspeed/manifest.json b/homeassistant/components/cpuspeed/manifest.json index 9034cb7740d0f..3cd4be6f9d3a5 100644 --- a/homeassistant/components/cpuspeed/manifest.json +++ b/homeassistant/components/cpuspeed/manifest.json @@ -1,12 +1,7 @@ { "domain": "cpuspeed", - "name": "Cpuspeed", - "documentation": "https://www.home-assistant.io/components/cpuspeed", - "requirements": [ - "py-cpuinfo==5.0.0" - ], - "dependencies": [], - "codeowners": [ - "@fabaff" - ] + "name": "CPU Speed", + "documentation": "https://www.home-assistant.io/integrations/cpuspeed", + "requirements": ["py-cpuinfo==5.0.0"], + "codeowners": ["@fabaff"] } diff --git a/homeassistant/components/cpuspeed/sensor.py b/homeassistant/components/cpuspeed/sensor.py index ef9cb218cd79b..34e9c5fee2526 100644 --- a/homeassistant/components/cpuspeed/sensor.py +++ b/homeassistant/components/cpuspeed/sensor.py @@ -1,34 +1,35 @@ """Support for displaying the current CPU speed.""" import logging +from cpuinfo import cpuinfo import voluptuous as vol -import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import CONF_NAME +from homeassistant.const import CONF_NAME, FREQUENCY_GIGAHERTZ +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) -ATTR_BRAND = 'Brand' -ATTR_HZ = 'GHz Advertised' -ATTR_ARCH = 'arch' +ATTR_BRAND = "Brand" +ATTR_HZ = "GHz Advertised" +ATTR_ARCH = "arch" -HZ_ACTUAL_RAW = 'hz_actual_raw' -HZ_ADVERTISED_RAW = 'hz_advertised_raw' +HZ_ACTUAL_RAW = "hz_actual_raw" +HZ_ADVERTISED_RAW = "hz_advertised_raw" -DEFAULT_NAME = 'CPU speed' +DEFAULT_NAME = "CPU speed" -ICON = 'mdi:pulse' +ICON = "mdi:pulse" -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, -}) +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) + name = config[CONF_NAME] add_entities([CpuSpeedSensor(name)], True) @@ -41,7 +42,6 @@ def __init__(self, name): self._name = name self._state = None self.info = None - self._unit_of_measurement = 'GHz' @property def name(self): @@ -56,21 +56,16 @@ def state(self): @property def unit_of_measurement(self): """Return the unit the value is expressed in.""" - return self._unit_of_measurement + return FREQUENCY_GIGAHERTZ @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'], - } + 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 - ) + attrs[ATTR_HZ] = round(self.info[HZ_ADVERTISED_RAW][0] / 10 ** 9, 2) return attrs @property @@ -80,12 +75,9 @@ def icon(self): 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 - ) + self._state = round(float(self.info[HZ_ACTUAL_RAW][0]) / 10 ** 9, 2) else: self._state = None diff --git a/homeassistant/components/crimereports/manifest.json b/homeassistant/components/crimereports/manifest.json index 0f74216b9b215..624d812f5f334 100644 --- a/homeassistant/components/crimereports/manifest.json +++ b/homeassistant/components/crimereports/manifest.json @@ -1,10 +1,7 @@ { "domain": "crimereports", - "name": "Crimereports", - "documentation": "https://www.home-assistant.io/components/crimereports", - "requirements": [ - "crimereports==1.0.1" - ], - "dependencies": [], + "name": "Crime Reports", + "documentation": "https://www.home-assistant.io/integrations/crimereports", + "requirements": ["crimereports==1.0.1"], "codeowners": [] } diff --git a/homeassistant/components/crimereports/sensor.py b/homeassistant/components/crimereports/sensor.py index 5e25d800247b7..ff65658073fc2 100644 --- a/homeassistant/components/crimereports/sensor.py +++ b/homeassistant/components/crimereports/sensor.py @@ -3,64 +3,77 @@ from datetime import timedelta import logging +import crimereports 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) + ATTR_ATTRIBUTION, + ATTR_LATITUDE, + ATTR_LONGITUDE, + CONF_EXCLUDE, + CONF_INCLUDE, + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_NAME, + CONF_RADIUS, + LENGTH_KILOMETERS, + LENGTH_METERS, +) +import homeassistant.helpers.config_validation as cv 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' +DOMAIN = "crimereports" -EVENT_INCIDENT = '{}_incident'.format(DOMAIN) +EVENT_INCIDENT = f"{DOMAIN}_incident" 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]) -}) +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) + name = config[CONF_NAME] + radius = config[CONF_RADIUS] include = config.get(CONF_INCLUDE) exclude = config.get(CONF_EXCLUDE) - add_entities([CrimeReportsSensor( - hass, name, latitude, longitude, radius, include, exclude)], True) + 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): + 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) + (latitude, longitude), radius_kilometers + ) self._attributes = None self._state = None self._previous_incidents = set() @@ -83,36 +96,35 @@ def device_state_attributes(self): 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') + "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] - }) + 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) + 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_type = slugify(incident.get("type")) incident_counts[incident_type] += 1 - if (fire_events and incident.get('id') - not in self._previous_incidents): + 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._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/manifest.json b/homeassistant/components/cups/manifest.json index def2846c4ca3b..5f63e7c6a5066 100644 --- a/homeassistant/components/cups/manifest.json +++ b/homeassistant/components/cups/manifest.json @@ -1,12 +1,7 @@ { "domain": "cups", - "name": "Cups", - "documentation": "https://www.home-assistant.io/components/cups", - "requirements": [ - "pycups==1.9.73" - ], - "dependencies": [], - "codeowners": [ - "@fabaff" - ] + "name": "CUPS", + "documentation": "https://www.home-assistant.io/integrations/cups", + "requirements": ["pycups==1.9.73"], + "codeowners": ["@fabaff"] } diff --git a/homeassistant/components/cups/sensor.py b/homeassistant/components/cups/sensor.py index cf0ba5f7f8d3e..bc6cdbe8ba197 100644 --- a/homeassistant/components/cups/sensor.py +++ b/homeassistant/components/cups/sensor.py @@ -1,69 +1,97 @@ """Details about printers which are connected to CUPS.""" +from datetime import timedelta 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.const import CONF_HOST, CONF_PORT, UNIT_PERCENTAGE +from homeassistant.exceptions import PlatformNotReady +import homeassistant.helpers.config_validation as cv 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' +ATTR_MARKER_TYPE = "marker_type" +ATTR_MARKER_LOW_LEVEL = "marker_low_level" +ATTR_MARKER_HIGH_LEVEL = "marker_high_level" +ATTR_PRINTER_NAME = "printer_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' +CONF_PRINTERS = "printers" +CONF_IS_CUPS_SERVER = "is_cups_server" -DEFAULT_HOST = '127.0.0.1' +DEFAULT_HOST = "127.0.0.1" DEFAULT_PORT = 631 +DEFAULT_IS_CUPS_SERVER = True -ICON = 'mdi:printer' +ICON_PRINTER = "mdi:printer" +ICON_MARKER = "mdi:water" SCAN_INTERVAL = timedelta(minutes=1) -PRINTER_STATES = { - 3: 'idle', - 4: 'printing', - 5: 'stopped', -} +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, -}) +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_PRINTERS): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_IS_CUPS_SERVER, default=DEFAULT_IS_CUPS_SERVER): cv.boolean, + 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) + host = config[CONF_HOST] + port = config[CONF_PORT] + printers = config[CONF_PRINTERS] + is_cups = config[CONF_IS_CUPS_SERVER] - try: - data = CupsData(host, port) + if is_cups: + data = CupsData(host, port, None) data.update() - except RuntimeError: - _LOGGER.error("Unable to connect to CUPS server: %s:%s", host, port) - return False + if data.available is False: + _LOGGER.error("Unable to connect to CUPS server: %s:%s", host, port) + raise PlatformNotReady() + + dev = [] + for printer in printers: + if printer not in data.printers: + _LOGGER.error("Printer is not present: %s", printer) + continue + dev.append(CupsSensor(data, printer)) + + if "marker-names" in data.attributes[printer]: + for marker in data.attributes[printer]["marker-names"]: + dev.append(MarkerSensor(data, printer, marker, True)) + + add_entities(dev, True) + return + + data = CupsData(host, port, printers) + data.update() + if data.available is False: + _LOGGER.error("Unable to connect to IPP printer: %s:%s", host, port) + raise PlatformNotReady() 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 + dev.append(IPPSensor(data, printer)) + + if "marker-names" in data.attributes[printer]: + for marker in data.attributes[printer]["marker-names"]: + dev.append(MarkerSensor(data, printer, marker, False)) add_entities(dev, True) @@ -76,6 +104,7 @@ def __init__(self, data, printer): self.data = data self._name = printer self._printer = None + self._available = False @property def name(self): @@ -85,56 +114,227 @@ def name(self): @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'] + if self._printer is None: + return None + + key = self._printer["printer-state"] + return PRINTER_STATES.get(key, key) + + @property + def available(self): + """Return True if entity is available.""" + return self._available @property def icon(self): """Return the icon to use in the frontend, if any.""" - return ICON + return ICON_PRINTER @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'], - } + if self._printer is None: + return 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) + self._available = self.data.available + + +class IPPSensor(Entity): + """Implementation of the IPPSensor. + + This sensor represents the status of the printer. + """ + + def __init__(self, data, name): + """Initialize the sensor.""" + self.data = data + self._name = name + self._attributes = None + self._available = False + + @property + def name(self): + """Return the name of the sensor.""" + return self._attributes["printer-make-and-model"] + + @property + def icon(self): + """Return the icon to use in the frontend.""" + return ICON_PRINTER + + @property + def available(self): + """Return True if entity is available.""" + return self._available + + @property + def state(self): + """Return the state of the sensor.""" + if self._attributes is None: + return None + + key = self._attributes["printer-state"] + return PRINTER_STATES.get(key, key) + + @property + def device_state_attributes(self): + """Return the state attributes of the sensor.""" + if self._attributes is None: + return None + + state_attributes = {} + + if "printer-info" in self._attributes: + state_attributes[ATTR_PRINTER_INFO] = self._attributes["printer-info"] + + if "printer-location" in self._attributes: + state_attributes[ATTR_PRINTER_LOCATION] = self._attributes[ + "printer-location" + ] + + if "printer-state-message" in self._attributes: + state_attributes[ATTR_PRINTER_STATE_MESSAGE] = self._attributes[ + "printer-state-message" + ] + + if "printer-state-reasons" in self._attributes: + state_attributes[ATTR_PRINTER_STATE_REASON] = self._attributes[ + "printer-state-reasons" + ] + + if "printer-uri-supported" in self._attributes: + state_attributes[ATTR_PRINTER_URI_SUPPORTED] = self._attributes[ + "printer-uri-supported" + ] + + return state_attributes + + def update(self): + """Fetch new state data for the sensor.""" + self.data.update() + self._attributes = self.data.attributes.get(self._name) + self._available = self.data.available + + +class MarkerSensor(Entity): + """Implementation of the MarkerSensor. + + This sensor represents the percentage of ink or toner. + """ + + def __init__(self, data, printer, name, is_cups): + """Initialize the sensor.""" + self.data = data + self._name = name + self._printer = printer + self._index = data.attributes[printer]["marker-names"].index(name) + self._is_cups = is_cups + self._attributes = 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.""" + return ICON_MARKER + + @property + def state(self): + """Return the state of the sensor.""" + if self._attributes is None: + return None + + return self._attributes[self._printer]["marker-levels"][self._index] + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return UNIT_PERCENTAGE + + @property + def device_state_attributes(self): + """Return the state attributes of the sensor.""" + if self._attributes is None: + return None + + high_level = self._attributes[self._printer].get("marker-high-levels") + if isinstance(high_level, list): + high_level = high_level[self._index] + + low_level = self._attributes[self._printer].get("marker-low-levels") + if isinstance(low_level, list): + low_level = low_level[self._index] + + marker_types = self._attributes[self._printer]["marker-types"] + if isinstance(marker_types, list): + marker_types = marker_types[self._index] + + if self._is_cups: + printer_name = self._printer + else: + printer_name = self._attributes[self._printer]["printer-make-and-model"] + + return { + ATTR_MARKER_HIGH_LEVEL: high_level, + ATTR_MARKER_LOW_LEVEL: low_level, + ATTR_MARKER_TYPE: marker_types, + ATTR_PRINTER_NAME: printer_name, + } + + def update(self): + """Update the state of the sensor.""" + # Data fetching is done by CupsSensor/IPPSensor + self._attributes = self.data.attributes -# pylint: disable=no-name-in-module class CupsData: """Get the latest data from CUPS and update the state.""" - def __init__(self, host, port): + def __init__(self, host, port, ipp_printers): """Initialize the data object.""" self._host = host self._port = port + self._ipp_printers = ipp_printers + self.is_cups = ipp_printers is None self.printers = None + self.attributes = {} + self.available = False def update(self): """Get the latest data from CUPS.""" - cups = importlib.import_module('cups') + cups = importlib.import_module("cups") + + try: + conn = cups.Connection(host=self._host, port=self._port) + if self.is_cups: + self.printers = conn.getPrinters() + for printer in self.printers: + self.attributes[printer] = conn.getPrinterAttributes(name=printer) + else: + for ipp_printer in self._ipp_printers: + self.attributes[ipp_printer] = conn.getPrinterAttributes( + uri=f"ipp://{self._host}:{self._port}/{ipp_printer}" + ) - conn = cups.Connection(host=self._host, port=self._port) - self.printers = conn.getPrinters() + self.available = True + except RuntimeError: + self.available = False diff --git a/homeassistant/components/currencylayer/manifest.json b/homeassistant/components/currencylayer/manifest.json index 7064590bf2587..508483732fca8 100644 --- a/homeassistant/components/currencylayer/manifest.json +++ b/homeassistant/components/currencylayer/manifest.json @@ -1,8 +1,6 @@ { "domain": "currencylayer", - "name": "Currencylayer", - "documentation": "https://www.home-assistant.io/components/currencylayer", - "requirements": [], - "dependencies": [], + "name": "currencylayer", + "documentation": "https://www.home-assistant.io/integrations/currencylayer", "codeowners": [] } diff --git a/homeassistant/components/currencylayer/sensor.py b/homeassistant/components/currencylayer/sensor.py index bedd5f079ce43..f2cb29515b038 100644 --- a/homeassistant/components/currencylayer/sensor.py +++ b/homeassistant/components/currencylayer/sensor.py @@ -5,49 +5,52 @@ 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) + ATTR_ATTRIBUTION, + CONF_API_KEY, + CONF_BASE, + CONF_NAME, + CONF_QUOTE, +) +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) -_RESOURCE = 'http://apilayer.net/api/live' +_RESOURCE = "http://apilayer.net/api/live" ATTRIBUTION = "Data provided by currencylayer.com" -DEFAULT_BASE = 'USD' -DEFAULT_NAME = 'CurrencyLayer Sensor' +DEFAULT_BASE = "USD" +DEFAULT_NAME = "CurrencyLayer Sensor" -ICON = 'mdi:currency' +ICON = "mdi:currency" -SCAN_INTERVAL = timedelta(hours=2) +SCAN_INTERVAL = timedelta(hours=4) -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, -}) +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, - } + base = config[CONF_BASE] + api_key = config[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']: + for variable in config[CONF_QUOTE]: sensors.append(CurrencylayerSensor(rest, base, variable)) - if 'error' in response.json(): + if "error" in response.json(): return False add_entities(sensors, True) @@ -85,17 +88,14 @@ def state(self): @property def device_state_attributes(self): """Return the state attributes of the sensor.""" - return { - ATTR_ATTRIBUTION: ATTRIBUTION, - } + 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) + self._state = round(value[f"{self._base}{self._quote}"], 4) class CurrencylayerData: @@ -110,13 +110,11 @@ def __init__(self, resource, parameters): 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']) + 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 deleted file mode 100644 index beb1bc0d6e696..0000000000000 --- a/homeassistant/components/daikin/.translations/bg.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "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 deleted file mode 100644 index 2fa60015ca33c..0000000000000 --- a/homeassistant/components/daikin/.translations/ca.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "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 deleted file mode 100644 index 856bb1445c71b..0000000000000 --- a/homeassistant/components/daikin/.translations/da.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "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 deleted file mode 100644 index 0a09c7b5cfabd..0000000000000 --- a/homeassistant/components/daikin/.translations/de.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "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 deleted file mode 100644 index 1605e1dc8f695..0000000000000 --- a/homeassistant/components/daikin/.translations/en.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "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 deleted file mode 100644 index 6fa2b664a3016..0000000000000 --- a/homeassistant/components/daikin/.translations/es-419.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "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 deleted file mode 100644 index d3a733a3f9be1..0000000000000 --- a/homeassistant/components/daikin/.translations/es.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "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 deleted file mode 100644 index cfd4b7442d6cd..0000000000000 --- a/homeassistant/components/daikin/.translations/fr.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "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 deleted file mode 100644 index f433a6215b85a..0000000000000 --- a/homeassistant/components/daikin/.translations/hu.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "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 deleted file mode 100644 index 0b8151d23f658..0000000000000 --- a/homeassistant/components/daikin/.translations/it.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "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 deleted file mode 100644 index 2291d46800d84..0000000000000 --- a/homeassistant/components/daikin/.translations/ko.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "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 deleted file mode 100644 index cdf98f5e597ee..0000000000000 --- a/homeassistant/components/daikin/.translations/lb.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "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 deleted file mode 100644 index 683bb61dd44ac..0000000000000 --- a/homeassistant/components/daikin/.translations/nl.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "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 deleted file mode 100644 index 806106c5e52e5..0000000000000 --- a/homeassistant/components/daikin/.translations/no.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "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 deleted file mode 100644 index 49c5a4976673b..0000000000000 --- a/homeassistant/components/daikin/.translations/pl.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "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 deleted file mode 100644 index 58c5a9c77b27a..0000000000000 --- a/homeassistant/components/daikin/.translations/pt-BR.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "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 deleted file mode 100644 index 34b4c86e77d33..0000000000000 --- a/homeassistant/components/daikin/.translations/pt.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "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 deleted file mode 100644 index ce1f1ab3caa97..0000000000000 --- a/homeassistant/components/daikin/.translations/ru.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "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 deleted file mode 100644 index 088b354fbb1bf..0000000000000 --- a/homeassistant/components/daikin/.translations/sl.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "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 deleted file mode 100644 index 0f1247197aa93..0000000000000 --- a/homeassistant/components/daikin/.translations/sv.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "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 deleted file mode 100644 index 8f0fdda371168..0000000000000 --- a/homeassistant/components/daikin/.translations/th.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "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 deleted file mode 100644 index 5123dc2366b98..0000000000000 --- a/homeassistant/components/daikin/.translations/zh-Hans.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "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 deleted file mode 100644 index 1699bcad8f08b..0000000000000 --- a/homeassistant/components/daikin/.translations/zh-Hant.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "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 index edc447fe72143..ad4d30358c2fe 100644 --- a/homeassistant/components/daikin/__init__.py +++ b/homeassistant/components/daikin/__init__.py @@ -5,6 +5,7 @@ from aiohttp import ClientConnectionError from async_timeout import timeout +from pydaikin.appliance import Appliance import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry @@ -15,24 +16,26 @@ from homeassistant.helpers.typing import HomeAssistantType from homeassistant.util import Throttle -from . import config_flow # noqa pylint_disable=unused-import +from . import config_flow # noqa: F401 +from .const import TIMEOUT _LOGGER = logging.getLogger(__name__) -DOMAIN = 'daikin' +DOMAIN = "daikin" PARALLEL_UPDATES = 0 MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) -COMPONENT_TYPES = ['climate', 'sensor', 'switch'] +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) +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): @@ -40,19 +43,19 @@ async def async_setup(hass, config): if DOMAIN not in config: return True - hosts = config[DOMAIN].get(CONF_HOSTS) + hosts = config[DOMAIN][CONF_HOSTS] if not hosts: hass.async_create_task( hass.config_entries.flow.async_init( - DOMAIN, context={'source': SOURCE_IMPORT})) + 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, - })) + DOMAIN, context={"source": SOURCE_IMPORT}, data={CONF_HOST: host} + ) + ) return True @@ -65,17 +68,19 @@ async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry): 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)) + 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 - ]) + 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) @@ -84,10 +89,10 @@ async def async_unload_entry(hass, config_entry): 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): + with timeout(TIMEOUT): device = Appliance(host, session) await device.init() except asyncio.TimeoutError: @@ -111,7 +116,7 @@ class DaikinApi: def __init__(self, device): """Initialize the Daikin Handle.""" self.device = device - self.name = device.values['name'] + self.name = device.values["name"] self.ip_address = device.ip self._available = True @@ -122,9 +127,7 @@ async def async_update(self, **kwargs): await self.device.update_status() self._available = True except ClientConnectionError: - _LOGGER.warning( - "Connection failed for %s", self.ip_address - ) + _LOGGER.warning("Connection failed for %s", self.ip_address) self._available = False @property @@ -142,10 +145,10 @@ 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('_', '.'), + "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 index 7ea4e1177436d..ebf909dcbdac8 100644 --- a/homeassistant/components/daikin/climate.py +++ b/homeassistant/components/daikin/climate.py @@ -1,169 +1,132 @@ """Support for the Daikin HVAC.""" import logging -import re +from pydaikin import appliance import voluptuous as vol -from homeassistant.components.climate import PLATFORM_SCHEMA, ClimateDevice +from homeassistant.components.climate import PLATFORM_SCHEMA, ClimateEntity 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) + ATTR_FAN_MODE, + ATTR_HVAC_MODE, + ATTR_PRESET_MODE, + ATTR_SWING_MODE, + HVAC_MODE_COOL, + HVAC_MODE_DRY, + HVAC_MODE_FAN_ONLY, + HVAC_MODE_HEAT, + HVAC_MODE_HEAT_COOL, + HVAC_MODE_OFF, + PRESET_AWAY, + PRESET_NONE, + SUPPORT_FAN_MODE, + SUPPORT_PRESET_MODE, + SUPPORT_SWING_MODE, + SUPPORT_TARGET_TEMPERATURE, +) +from homeassistant.const import ATTR_TEMPERATURE, CONF_HOST, CONF_NAME, 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) + ATTR_INSIDE_TEMPERATURE, + ATTR_OUTSIDE_TEMPERATURE, + ATTR_STATE_OFF, + ATTR_STATE_ON, + ATTR_TARGET_TEMPERATURE, +) _LOGGER = logging.getLogger(__name__) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_NAME): cv.string, -}) +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', + HVAC_MODE_FAN_ONLY: "fan", + HVAC_MODE_DRY: "dry", + HVAC_MODE_COOL: "cool", + HVAC_MODE_HEAT: "hot", + HVAC_MODE_HEAT_COOL: "auto", + HVAC_MODE_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, + "fan": HVAC_MODE_FAN_ONLY, + "dry": HVAC_MODE_DRY, + "cool": HVAC_MODE_COOL, + "hot": HVAC_MODE_HEAT, + "auto": HVAC_MODE_HEAT_COOL, + "off": HVAC_MODE_OFF, } +HA_PRESET_TO_DAIKIN = {PRESET_AWAY: "on", PRESET_NONE: "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' + ATTR_PRESET_MODE: "en_hol", + ATTR_HVAC_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): +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)]) + async_add_entities([DaikinClimate(daikin_api)], update_before_add=True) -class DaikinClimate(ClimateDevice): +class DaikinClimate(ClimateEntity): """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: list( - map( - str.title, - appliance.daikin_values(HA_ATTR_TO_DAIKIN[ATTR_FAN_MODE]) - ) - ), + ATTR_HVAC_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]) + appliance.daikin_values(HA_ATTR_TO_DAIKIN[ATTR_SWING_MODE]), ) ), } - self._supported_features = (SUPPORT_AWAY_MODE | SUPPORT_ON_OFF - | SUPPORT_OPERATION_MODE - | SUPPORT_TARGET_TEMPERATURE) + self._supported_features = SUPPORT_TARGET_TEMPERATURE + + if self._api.device.support_away_mode: + self._supported_features |= SUPPORT_PRESET_MODE - if self._api.device.support_fan_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]: + for attr in [ATTR_TEMPERATURE, ATTR_FAN_MODE, ATTR_SWING_MODE, ATTR_HVAC_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: + if attr == ATTR_HVAC_MODE: values[daikin_attr] = HA_STATE_TO_DAIKIN[value] elif value in self._list[attr]: values[daikin_attr] = value.lower() @@ -173,7 +136,7 @@ async def _set(self, settings): # temperature elif attr == ATTR_TEMPERATURE: try: - values['stemp'] = str(int(value)) + values[HA_ATTR_TO_DAIKIN[ATTR_TARGET_TEMPERATURE]] = str(int(value)) except ValueError: _LOGGER.error("Invalid temperature %s", value) @@ -203,12 +166,12 @@ def temperature_unit(self): @property def current_temperature(self): """Return the current temperature.""" - return self.get(ATTR_CURRENT_TEMPERATURE) + return self._api.device.inside_temperature @property def target_temperature(self): """Return the temperature we try to reach.""" - return self.get(ATTR_TARGET_TEMPERATURE) + return self._api.device.target_temperature @property def target_temperature_step(self): @@ -220,62 +183,73 @@ async def async_set_temperature(self, **kwargs): await self._set(kwargs) @property - def current_operation(self): + def hvac_mode(self): """Return current operation ie. heat, cool, idle.""" - return self.get(ATTR_OPERATION_MODE) + daikin_mode = self._api.device.represent(HA_ATTR_TO_DAIKIN[ATTR_HVAC_MODE])[1] + return DAIKIN_TO_HA_STATE.get(daikin_mode, HVAC_MODE_HEAT_COOL) @property - def operation_list(self): + def hvac_modes(self): """Return the list of available operation modes.""" - return self._list.get(ATTR_OPERATION_MODE) + return self._list.get(ATTR_HVAC_MODE) - async def async_set_operation_mode(self, operation_mode): + async def async_set_hvac_mode(self, hvac_mode): """Set HVAC mode.""" - await self._set({ATTR_OPERATION_MODE: operation_mode}) + await self._set({ATTR_HVAC_MODE: hvac_mode}) @property - def current_fan_mode(self): + def fan_mode(self): """Return the fan setting.""" - return self.get(ATTR_FAN_MODE) + return self._api.device.represent(HA_ATTR_TO_DAIKIN[ATTR_FAN_MODE])[1].title() 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): + def fan_modes(self): """List of available fan modes.""" return self._list.get(ATTR_FAN_MODE) @property - def current_swing_mode(self): + def swing_mode(self): """Return the fan setting.""" - return self.get(ATTR_SWING_MODE) + return self._api.device.represent(HA_ATTR_TO_DAIKIN[ATTR_SWING_MODE])[1].title() 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): + def swing_modes(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 + def preset_mode(self): + """Return the preset_mode.""" + if ( + self._api.device.represent(HA_ATTR_TO_DAIKIN[ATTR_PRESET_MODE])[1] + == HA_PRESET_TO_DAIKIN[PRESET_AWAY] + ): + return PRESET_AWAY + return PRESET_NONE + + async def async_set_preset_mode(self, preset_mode): + """Set preset mode.""" + if preset_mode == PRESET_AWAY: + await self._api.device.set_holiday(ATTR_STATE_ON) + else: + await self._api.device.set_holiday(ATTR_STATE_OFF) @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] + def preset_modes(self): + """List of available preset modes.""" + return list(HA_PRESET_TO_DAIKIN) + + async def async_update(self): + """Retrieve latest state.""" + await self._api.async_update() async def async_turn_on(self): """Turn device on.""" @@ -283,22 +257,11 @@ async def async_turn_on(self): 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] - }) + await self._api.device.set( + {HA_ATTR_TO_DAIKIN[ATTR_HVAC_MODE]: HA_STATE_TO_DAIKIN[HVAC_MODE_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'}) + def device_info(self): + """Return a device description for device registry.""" + return self._api.device_info diff --git a/homeassistant/components/daikin/config_flow.py b/homeassistant/components/daikin/config_flow.py index 7c214e77050f0..35f21ef3e0d70 100644 --- a/homeassistant/components/daikin/config_flow.py +++ b/homeassistant/components/daikin/config_flow.py @@ -4,17 +4,18 @@ from aiohttp import ClientError from async_timeout import timeout +from pydaikin.appliance import Appliance import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_HOST -from .const import KEY_IP, KEY_MAC +from .const import KEY_IP, KEY_MAC, TIMEOUT _LOGGER = logging.getLogger(__name__) -@config_entries.HANDLERS.register('daikin') +@config_entries.HANDLERS.register("daikin") class FlowHandler(config_entries.ConfigFlow): """Handle a config flow.""" @@ -26,45 +27,36 @@ async def _create_entry(self, host, mac): # 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_abort(reason="already_configured") - return self.async_create_entry( - title=host, - data={ - CONF_HOST: host, - KEY_MAC: mac - }) + 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(), + host, self.hass.helpers.aiohttp_client.async_get_clientsession() ) - with timeout(10): + with timeout(TIMEOUT): await device.init() except asyncio.TimeoutError: - return self.async_abort(reason='device_timeout') + return self.async_abort(reason="device_timeout") except ClientError: _LOGGER.exception("ClientError") - return self.async_abort(reason='device_fail') + 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') + return self.async_abort(reason="device_fail") - mac = device.values.get('mac') + 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 - }) + step_id="user", data_schema=vol.Schema({vol.Required(CONF_HOST): str}) ) return await self._create_device(user_input[CONF_HOST]) @@ -78,5 +70,4 @@ async def async_step_import(self, user_input): 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]) + 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 index 90967904579c7..15ae5321bf364 100644 --- a/homeassistant/components/daikin/const.py +++ b/homeassistant/components/daikin/const.py @@ -1,24 +1,29 @@ """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' +ATTR_TARGET_TEMPERATURE = "target_temperature" +ATTR_INSIDE_TEMPERATURE = "inside_temperature" +ATTR_OUTSIDE_TEMPERATURE = "outside_temperature" -SENSOR_TYPE_TEMPERATURE = 'temperature' +ATTR_STATE_ON = "on" +ATTR_STATE_OFF = "off" + +SENSOR_TYPE_TEMPERATURE = "temperature" SENSOR_TYPES = { ATTR_INSIDE_TEMPERATURE: { - CONF_NAME: 'Inside Temperature', - CONF_ICON: 'mdi:thermometer', - CONF_TYPE: SENSOR_TYPE_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 - } + CONF_NAME: "Outside Temperature", + CONF_ICON: "mdi:thermometer", + CONF_TYPE: SENSOR_TYPE_TEMPERATURE, + }, } -KEY_MAC = 'mac' -KEY_IP = 'ip' +KEY_MAC = "mac" +KEY_IP = "ip" + +TIMEOUT = 60 diff --git a/homeassistant/components/daikin/manifest.json b/homeassistant/components/daikin/manifest.json index ab842950e2488..c501fa7c1202c 100644 --- a/homeassistant/components/daikin/manifest.json +++ b/homeassistant/components/daikin/manifest.json @@ -1,13 +1,9 @@ { "domain": "daikin", - "name": "Daikin", - "documentation": "https://www.home-assistant.io/components/daikin", - "requirements": [ - "pydaikin==1.4.0" - ], - "dependencies": [], - "codeowners": [ - "@fredrike", - "@rofrantz" - ] + "name": "Daikin AC", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/daikin", + "requirements": ["pydaikin==1.6.3"], + "codeowners": ["@fredrike"], + "quality_scale": "platinum" } diff --git a/homeassistant/components/daikin/sensor.py b/homeassistant/components/daikin/sensor.py index c4f885f5081d6..d0d8e4b0fdafe 100644 --- a/homeassistant/components/daikin/sensor.py +++ b/homeassistant/components/daikin/sensor.py @@ -1,82 +1,46 @@ """Support for Daikin AC sensors.""" import logging -from homeassistant.const import CONF_ICON, CONF_NAME, CONF_TYPE +from homeassistant.const import CONF_ICON, CONF_NAME, TEMP_CELSIUS 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) +from .const import ATTR_INSIDE_TEMPERATURE, ATTR_OUTSIDE_TEMPERATURE, SENSOR_TYPES _LOGGER = logging.getLogger(__name__) -async def async_setup_platform( - hass, config, async_add_entities, discovery_info=None): +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) - async_add_entities([ - DaikinClimateSensor(daikin_api, sensor, hass.config.units) - for sensor in SENSOR_TYPES - ]) + sensors = [ATTR_INSIDE_TEMPERATURE] + if daikin_api.device.support_outside_temperature: + sensors.append(ATTR_OUTSIDE_TEMPERATURE) + async_add_entities([DaikinClimateSensor(daikin_api, sensor) for sensor in sensors]) class DaikinClimateSensor(Entity): """Representation of a Sensor.""" - def __init__(self, api, monitored_state, units: UnitSystem, - name=None) -> None: + def __init__(self, api, monitored_state) -> 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._sensor = SENSOR_TYPES[monitored_state] + self._name = f"{api.name} {self._sensor[CONF_NAME]}" 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 + return f"{self._api.mac}-{self._device_attribute}" @property def icon(self): @@ -91,12 +55,16 @@ def name(self): @property def state(self): """Return the state of the sensor.""" - return self.get(self._device_attribute) + if self._device_attribute == ATTR_INSIDE_TEMPERATURE: + return self._api.device.inside_temperature + if self._device_attribute == ATTR_OUTSIDE_TEMPERATURE: + return self._api.device.outside_temperature + return None @property def unit_of_measurement(self): """Return the unit of measurement.""" - return self._unit_of_measurement + return TEMP_CELSIUS async def async_update(self): """Retrieve latest state.""" diff --git a/homeassistant/components/daikin/strings.json b/homeassistant/components/daikin/strings.json index 4badc8b72d789..1e82d285eee0d 100644 --- a/homeassistant/components/daikin/strings.json +++ b/homeassistant/components/daikin/strings.json @@ -1,19 +1,16 @@ { - "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" - } + "config": { + "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 index 3106a7e801392..b7131c29bdd4d 100644 --- a/homeassistant/components/daikin/switch.py +++ b/homeassistant/components/daikin/switch.py @@ -7,17 +7,15 @@ _LOGGER = logging.getLogger(__name__) -ZONE_ICON = 'mdi:home-circle' +ZONE_ICON = "mdi:home-circle" -async def async_setup_platform( - hass, config, async_add_entities, discovery_info=None): +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): @@ -25,11 +23,13 @@ async def async_setup_entry(hass, entry, async_add_entities): 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, name in enumerate(zones) - if name != '-' - ]) + async_add_entities( + [ + DaikinZoneSwitch(daikin_api, zone_id) + for zone_id, zone in enumerate(zones) + if zone != ("-", "0") + ] + ) class DaikinZoneSwitch(ToggleEntity): @@ -43,7 +43,7 @@ def __init__(self, daikin_api, zone_id): @property def unique_id(self): """Return a unique ID.""" - return "{}-zone{}".format(self._api.mac, self._zone_id) + return f"{self._api.mac}-zone{self._zone_id}" @property def icon(self): @@ -53,13 +53,12 @@ def icon(self): @property def name(self): """Return the name of the sensor.""" - return "{} {}".format(self._api.name, - self._api.device.zones[self._zone_id][0]) + return f"{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' + return self._api.device.zones[self._zone_id][1] == "1" @property def device_info(self): @@ -72,8 +71,8 @@ async def async_update(self): async def async_turn_on(self, **kwargs): """Turn the zone on.""" - await self._api.device.set_zone(self._zone_id, '1') + 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') + await self._api.device.set_zone(self._zone_id, "0") diff --git a/homeassistant/components/daikin/translations/bg.json b/homeassistant/components/daikin/translations/bg.json new file mode 100644 index 0000000000000..dd80874adaa0f --- /dev/null +++ b/homeassistant/components/daikin/translations/bg.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e", + "device_fail": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430 \u043f\u0440\u0438 \u0441\u044a\u0437\u0434\u0430\u0432\u0430\u043d\u0435\u0442\u043e \u043d\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e.", + "device_timeout": "\u041d\u0435\u0432\u044a\u0437\u043c\u043e\u0436\u043d\u043e\u0441\u0442 \u0437\u0430 \u0441\u0432\u043e\u0435\u0432\u0440\u0435\u043c\u0435\u043d\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435 \u0441 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e." + }, + "step": { + "user": { + "data": { + "host": "\u0410\u0434\u0440\u0435\u0441" + }, + "description": "\u0412\u044a\u0432\u0435\u0434\u0435\u0442\u0435 IP \u0430\u0434\u0440\u0435\u0441 \u043d\u0430 \u0432\u0430\u0448\u0438\u044f \u043a\u043b\u0438\u043c\u0430\u0442\u0438\u043a Daikin.", + "title": "\u041a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u0435 \u043d\u0430 \u043a\u043b\u0438\u043c\u0430\u0442\u0438\u043a Daikin" + } + } + } +} \ 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..35d2dafd338d1 --- /dev/null +++ b/homeassistant/components/daikin/translations/ca.json @@ -0,0 +1,18 @@ +{ + "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" + } + } + } +} \ 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..230bd7ecbd8f8 --- /dev/null +++ b/homeassistant/components/daikin/translations/da.json @@ -0,0 +1,18 @@ +{ + "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" + } + } + } +} \ 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..ac7df0863bfcf --- /dev/null +++ b/homeassistant/components/daikin/translations/de.json @@ -0,0 +1,18 @@ +{ + "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": "Gib die IP-Adresse deiner Daikin AC ein.", + "title": "Daikin AC konfigurieren" + } + } + } +} \ 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..f66f360d096ef --- /dev/null +++ b/homeassistant/components/daikin/translations/en.json @@ -0,0 +1,18 @@ +{ + "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" + } + } + } +} \ 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..3facdce66d4d2 --- /dev/null +++ b/homeassistant/components/daikin/translations/es-419.json @@ -0,0 +1,18 @@ +{ + "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" + } + } + } +} \ 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..b774ac67ed3b0 --- /dev/null +++ b/homeassistant/components/daikin/translations/es.json @@ -0,0 +1,18 @@ +{ + "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" + } + } + } +} \ 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..b15a9fae2624d --- /dev/null +++ b/homeassistant/components/daikin/translations/fr.json @@ -0,0 +1,18 @@ +{ + "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" + } + } + } +} \ 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..eef3afdc5ee25 --- /dev/null +++ b/homeassistant/components/daikin/translations/hu.json @@ -0,0 +1,18 @@ +{ + "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" + } + } + } +} \ 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..72d5acd97a872 --- /dev/null +++ b/homeassistant/components/daikin/translations/it.json @@ -0,0 +1,18 @@ +{ + "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" + } + } + } +} \ 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..3a45e0bd7b399 --- /dev/null +++ b/homeassistant/components/daikin/translations/ko.json @@ -0,0 +1,18 @@ +{ + "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": "\ub2e4\uc774\ud0a8 \uc5d0\uc5b4\ucee8\uc758 IP \uc8fc\uc18c\ub97c \uc785\ub825\ud574\uc8fc\uc138\uc694.", + "title": "\ub2e4\uc774\ud0a8 \uc5d0\uc5b4\ucee8 \uad6c\uc131\ud558\uae30" + } + } + } +} \ 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..4fab38c911551 --- /dev/null +++ b/homeassistant/components/daikin/translations/lb.json @@ -0,0 +1,18 @@ +{ + "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" + } + } + } +} \ 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..0e0db0c907c40 --- /dev/null +++ b/homeassistant/components/daikin/translations/nl.json @@ -0,0 +1,18 @@ +{ + "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" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/daikin/translations/nn.json b/homeassistant/components/daikin/translations/nn.json new file mode 100644 index 0000000000000..fb8f82824c243 --- /dev/null +++ b/homeassistant/components/daikin/translations/nn.json @@ -0,0 +1,3 @@ +{ + "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..42d13cf6844c7 --- /dev/null +++ b/homeassistant/components/daikin/translations/no.json @@ -0,0 +1,18 @@ +{ + "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" + } + } + } +} \ 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..2e2f65bc00810 --- /dev/null +++ b/homeassistant/components/daikin/translations/pl.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane.", + "device_fail": "Nieoczekiwany b\u0142\u0105d tworzenia urz\u0105dzenia.", + "device_timeout": "Przekroczono limit czasu \u0142\u0105czenia z urz\u0105dzeniem." + }, + "step": { + "user": { + "data": { + "host": "Nazwa hosta lub adres IP" + }, + "description": "Wprowad\u017a adres IP Daikin AC.", + "title": "Konfiguracja 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..294e14b107198 --- /dev/null +++ b/homeassistant/components/daikin/translations/pt-BR.json @@ -0,0 +1,18 @@ +{ + "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": { + "data": { + "host": "Host" + }, + "description": "Digite o endere\u00e7o IP do seu AC Daikin.", + "title": "Configurar o 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..7e8f008619465 --- /dev/null +++ b/homeassistant/components/daikin/translations/pt.json @@ -0,0 +1,18 @@ +{ + "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" + } + } + } +} \ 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..a7b57fcb75729 --- /dev/null +++ b/homeassistant/components/daikin/translations/ru.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u044d\u0442\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\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" + } + } + } +} \ 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..f48d729b83c9d --- /dev/null +++ b/homeassistant/components/daikin/translations/sl.json @@ -0,0 +1,18 @@ +{ + "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" + } + } + } +} \ 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..0825d6ed3963c --- /dev/null +++ b/homeassistant/components/daikin/translations/sv.json @@ -0,0 +1,18 @@ +{ + "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" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tellduslive/.translations/th.json b/homeassistant/components/daikin/translations/th.json similarity index 100% rename from homeassistant/components/tellduslive/.translations/th.json rename to homeassistant/components/daikin/translations/th.json diff --git a/homeassistant/components/daikin/translations/zh-Hans.json b/homeassistant/components/daikin/translations/zh-Hans.json new file mode 100644 index 0000000000000..57b891d1adfa7 --- /dev/null +++ b/homeassistant/components/daikin/translations/zh-Hans.json @@ -0,0 +1,18 @@ +{ + "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" + } + } + } +} \ 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..bab5411868740 --- /dev/null +++ b/homeassistant/components/daikin/translations/zh-Hant.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "\u8a2d\u5099\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "device_fail": "\u5275\u5efa\u8a2d\u5099\u6642\u767c\u751f\u672a\u77e5\u932f\u8aa4\u3002", + "device_timeout": "\u9023\u7dda\u81f3\u8a2d\u5099\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" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/danfoss_air/__init__.py b/homeassistant/components/danfoss_air/__init__.py index 6e86b16c02d8c..b1dbf890eb970 100644 --- a/homeassistant/components/danfoss_air/__init__.py +++ b/homeassistant/components/danfoss_air/__init__.py @@ -2,6 +2,8 @@ from datetime import timedelta import logging +from pydanfossair.commands import ReadCommand +from pydanfossair.danfossclient import DanfossClient import voluptuous as vol from homeassistant.const import CONF_HOST @@ -11,16 +13,14 @@ _LOGGER = logging.getLogger(__name__) -DANFOSS_AIR_PLATFORMS = ['sensor', 'binary_sensor', 'switch'] -DOMAIN = 'danfoss_air' +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) +CONFIG_SCHEMA = vol.Schema( + {DOMAIN: vol.Schema({vol.Required(CONF_HOST): cv.string})}, extra=vol.ALLOW_EXTRA +) def setup(hass, config): @@ -42,8 +42,6 @@ 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): @@ -58,36 +56,41 @@ def update_state(self, command, state_command): 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) + + 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 index 723b0d0880154..7f6876a709be4 100644 --- a/homeassistant/components/danfoss_air/binary_sensor.py +++ b/homeassistant/components/danfoss_air/binary_sensor.py @@ -1,12 +1,13 @@ """Support for the for Danfoss Air HRV binary sensors.""" -from homeassistant.components.binary_sensor import BinarySensorDevice +from pydanfossair.commands import ReadCommand + +from homeassistant.components.binary_sensor import BinarySensorEntity 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 = [ @@ -17,13 +18,12 @@ def setup_platform(hass, config, add_entities, discovery_info=None): dev = [] for sensor in sensors: - dev.append(DanfossAirBinarySensor( - data, sensor[0], sensor[1], sensor[2])) + dev.append(DanfossAirBinarySensor(data, sensor[0], sensor[1], sensor[2])) add_entities(dev, True) -class DanfossAirBinarySensor(BinarySensorDevice): +class DanfossAirBinarySensor(BinarySensorEntity): """Representation of a Danfoss Air binary sensor.""" def __init__(self, data, name, sensor_type, device_class): diff --git a/homeassistant/components/danfoss_air/manifest.json b/homeassistant/components/danfoss_air/manifest.json index a210b5a78d1d5..bbecccf2a919d 100644 --- a/homeassistant/components/danfoss_air/manifest.json +++ b/homeassistant/components/danfoss_air/manifest.json @@ -1,10 +1,7 @@ { "domain": "danfoss_air", - "name": "Danfoss air", - "documentation": "https://www.home-assistant.io/components/danfoss_air", - "requirements": [ - "pydanfossair==0.1.0" - ], - "dependencies": [], + "name": "Danfoss Air", + "documentation": "https://www.home-assistant.io/integrations/danfoss_air", + "requirements": ["pydanfossair==0.1.0"], "codeowners": [] } diff --git a/homeassistant/components/danfoss_air/sensor.py b/homeassistant/components/danfoss_air/sensor.py index a5dc2a2eb097b..f03c74ae78b88 100644 --- a/homeassistant/components/danfoss_air/sensor.py +++ b/homeassistant/components/danfoss_air/sensor.py @@ -1,9 +1,15 @@ """Support for the for Danfoss Air HRV sensors.""" import logging +from pydanfossair.commands import ReadCommand + from homeassistant.const import ( - DEVICE_CLASS_BATTERY, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, - TEMP_CELSIUS) + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_TEMPERATURE, + TEMP_CELSIUS, + UNIT_PERCENTAGE, +) from homeassistant.helpers.entity import Entity from . import DOMAIN as DANFOSS_AIR_DOMAIN @@ -13,38 +19,60 @@ 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] - ] + [ + "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", + UNIT_PERCENTAGE, + ReadCommand.filterPercent, + None, + ], + [ + "Danfoss Air Humidity", + UNIT_PERCENTAGE, + ReadCommand.humidity, + DEVICE_CLASS_HUMIDITY, + ], + ["Danfoss Air Fan Step", UNIT_PERCENTAGE, ReadCommand.fan_step, None], + ["Danfoss Air Exhaust Fan Speed", "RPM", ReadCommand.exhaust_fan_speed, None], + ["Danfoss Air Supply Fan Speed", "RPM", ReadCommand.supply_fan_speed, None], + [ + "Danfoss Air Dial Battery", + UNIT_PERCENTAGE, + ReadCommand.battery_percent, + DEVICE_CLASS_BATTERY, + ], + ] dev = [] for sensor in sensors: - dev.append(DanfossAir( - data, sensor[0], sensor[1], sensor[2], sensor[3])) + dev.append(DanfossAir(data, sensor[0], sensor[1], sensor[2], sensor[3])) add_entities(dev, True) diff --git a/homeassistant/components/danfoss_air/switch.py b/homeassistant/components/danfoss_air/switch.py index 4e7fce28dc7cd..dc4c79ed10f54 100644 --- a/homeassistant/components/danfoss_air/switch.py +++ b/homeassistant/components/danfoss_air/switch.py @@ -1,7 +1,9 @@ """Support for the for Danfoss Air HRV sswitches.""" import logging -from homeassistant.components.switch import SwitchDevice +from pydanfossair.commands import ReadCommand, UpdateCommand + +from homeassistant.components.switch import SwitchEntity from . import DOMAIN as DANFOSS_AIR_DOMAIN @@ -10,35 +12,38 @@ 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], + [ + "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])) + dev.append(DanfossAir(data, switch[0], switch[1], switch[2], switch[3])) add_entities(dev) -class DanfossAir(SwitchDevice): +class DanfossAir(SwitchEntity): """Representation of a Danfoss Air HRV Switch.""" def __init__(self, data, name, state_command, on_command, off_command): diff --git a/homeassistant/components/darksky/manifest.json b/homeassistant/components/darksky/manifest.json index e4e6482484cd2..53f0538881776 100644 --- a/homeassistant/components/darksky/manifest.json +++ b/homeassistant/components/darksky/manifest.json @@ -1,12 +1,7 @@ { "domain": "darksky", - "name": "Darksky", - "documentation": "https://www.home-assistant.io/components/darksky", - "requirements": [ - "python-forecastio==1.4.0" - ], - "dependencies": [], - "codeowners": [ - "@fabaff" - ] + "name": "Dark Sky", + "documentation": "https://www.home-assistant.io/integrations/darksky", + "requirements": ["python-forecastio==1.4.0"], + "codeowners": ["@fabaff"] } diff --git a/homeassistant/components/darksky/sensor.py b/homeassistant/components/darksky/sensor.py index 63c2f782d17e0..e3742231e1e96 100644 --- a/homeassistant/components/darksky/sensor.py +++ b/homeassistant/components/darksky/sensor.py @@ -1,16 +1,33 @@ """Support for Dark Sky weather service.""" -import logging from datetime import timedelta +import logging +import forecastio +from requests.exceptions import ConnectionError as ConnectError, HTTPError, Timeout 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) + ATTR_ATTRIBUTION, + CONF_API_KEY, + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_MONITORED_CONDITIONS, + CONF_NAME, + CONF_SCAN_INTERVAL, + DEGREE, + LENGTH_CENTIMETERS, + LENGTH_KILOMETERS, + SPEED_KILOMETERS_PER_HOUR, + SPEED_METERS_PER_SECOND, + SPEED_MILES_PER_HOUR, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, + TIME_HOURS, + UNIT_PERCENTAGE, + UV_INDEX, +) +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle @@ -18,175 +35,451 @@ ATTRIBUTION = "Powered by Dark Sky" -CONF_FORECAST = 'forecast' -CONF_HOURLY_FORECAST = 'hourly_forecast' -CONF_LANGUAGE = 'language' -CONF_UNITS = 'units' +CONF_FORECAST = "forecast" +CONF_HOURLY_FORECAST = "hourly_forecast" +CONF_LANGUAGE = "language" +CONF_UNITS = "units" -DEFAULT_LANGUAGE = 'en' -DEFAULT_NAME = 'Dark Sky' +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', + "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']], + "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", + LENGTH_KILOMETERS, + "mi", + LENGTH_KILOMETERS, + LENGTH_KILOMETERS, + "mi", + "mdi:weather-lightning", + ["currently"], + ], + "nearest_storm_bearing": [ + "Nearest Storm Bearing", + DEGREE, + DEGREE, + DEGREE, + DEGREE, + DEGREE, + "mdi:weather-lightning", + ["currently"], + ], + "precip_type": [ + "Precip", + None, + None, + None, + None, + None, + "mdi:weather-pouring", + ["currently", "minutely", "hourly", "daily"], + ], + "precip_intensity": [ + "Precip Intensity", + f"mm/{TIME_HOURS}", + "in", + f"mm/{TIME_HOURS}", + f"mm/{TIME_HOURS}", + f"mm/{TIME_HOURS}", + "mdi:weather-rainy", + ["currently", "minutely", "hourly", "daily"], + ], + "precip_probability": [ + "Precip Probability", + UNIT_PERCENTAGE, + UNIT_PERCENTAGE, + UNIT_PERCENTAGE, + UNIT_PERCENTAGE, + UNIT_PERCENTAGE, + "mdi:water-percent", + ["currently", "minutely", "hourly", "daily"], + ], + "precip_accumulation": [ + "Precip Accumulation", + LENGTH_CENTIMETERS, + "in", + LENGTH_CENTIMETERS, + LENGTH_CENTIMETERS, + LENGTH_CENTIMETERS, + "mdi:weather-snowy", + ["hourly", "daily"], + ], + "temperature": [ + "Temperature", + TEMP_CELSIUS, + TEMP_FAHRENHEIT, + TEMP_CELSIUS, + TEMP_CELSIUS, + TEMP_CELSIUS, + "mdi:thermometer", + ["currently", "hourly"], + ], + "apparent_temperature": [ + "Apparent Temperature", + TEMP_CELSIUS, + TEMP_FAHRENHEIT, + TEMP_CELSIUS, + TEMP_CELSIUS, + TEMP_CELSIUS, + "mdi:thermometer", + ["currently", "hourly"], + ], + "dew_point": [ + "Dew Point", + TEMP_CELSIUS, + TEMP_FAHRENHEIT, + TEMP_CELSIUS, + TEMP_CELSIUS, + TEMP_CELSIUS, + "mdi:thermometer", + ["currently", "hourly", "daily"], + ], + "wind_speed": [ + "Wind Speed", + SPEED_METERS_PER_SECOND, + SPEED_MILES_PER_HOUR, + SPEED_KILOMETERS_PER_HOUR, + SPEED_MILES_PER_HOUR, + SPEED_MILES_PER_HOUR, + "mdi:weather-windy", + ["currently", "hourly", "daily"], + ], + "wind_bearing": [ + "Wind Bearing", + DEGREE, + DEGREE, + DEGREE, + DEGREE, + DEGREE, + "mdi:compass", + ["currently", "hourly", "daily"], + ], + "wind_gust": [ + "Wind Gust", + SPEED_METERS_PER_SECOND, + SPEED_MILES_PER_HOUR, + SPEED_KILOMETERS_PER_HOUR, + SPEED_MILES_PER_HOUR, + SPEED_MILES_PER_HOUR, + "mdi:weather-windy-variant", + ["currently", "hourly", "daily"], + ], + "cloud_cover": [ + "Cloud Coverage", + UNIT_PERCENTAGE, + UNIT_PERCENTAGE, + UNIT_PERCENTAGE, + UNIT_PERCENTAGE, + UNIT_PERCENTAGE, + "mdi:weather-partly-cloudy", + ["currently", "hourly", "daily"], + ], + "humidity": [ + "Humidity", + UNIT_PERCENTAGE, + UNIT_PERCENTAGE, + UNIT_PERCENTAGE, + UNIT_PERCENTAGE, + UNIT_PERCENTAGE, + "mdi:water-percent", + ["currently", "hourly", "daily"], + ], + "pressure": [ + "Pressure", + "mbar", + "mbar", + "mbar", + "mbar", + "mbar", + "mdi:gauge", + ["currently", "hourly", "daily"], + ], + "visibility": [ + "Visibility", + LENGTH_KILOMETERS, + "mi", + LENGTH_KILOMETERS, + LENGTH_KILOMETERS, + "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", + TEMP_CELSIUS, + TEMP_FAHRENHEIT, + TEMP_CELSIUS, + TEMP_CELSIUS, + TEMP_CELSIUS, + "mdi:thermometer", + ["daily"], + ], + "apparent_temperature_high": [ + "Daytime High Apparent Temperature", + TEMP_CELSIUS, + TEMP_FAHRENHEIT, + TEMP_CELSIUS, + TEMP_CELSIUS, + TEMP_CELSIUS, + "mdi:thermometer", + ["daily"], + ], + "apparent_temperature_min": [ + "Daily Low Apparent Temperature", + TEMP_CELSIUS, + TEMP_FAHRENHEIT, + TEMP_CELSIUS, + TEMP_CELSIUS, + TEMP_CELSIUS, + "mdi:thermometer", + ["daily"], + ], + "apparent_temperature_low": [ + "Overnight Low Apparent Temperature", + TEMP_CELSIUS, + TEMP_FAHRENHEIT, + TEMP_CELSIUS, + TEMP_CELSIUS, + TEMP_CELSIUS, + "mdi:thermometer", + ["daily"], + ], + "temperature_max": [ + "Daily High Temperature", + TEMP_CELSIUS, + TEMP_FAHRENHEIT, + TEMP_CELSIUS, + TEMP_CELSIUS, + TEMP_CELSIUS, + "mdi:thermometer", + ["daily"], + ], + "temperature_high": [ + "Daytime High Temperature", + TEMP_CELSIUS, + TEMP_FAHRENHEIT, + TEMP_CELSIUS, + TEMP_CELSIUS, + TEMP_CELSIUS, + "mdi:thermometer", + ["daily"], + ], + "temperature_min": [ + "Daily Low Temperature", + TEMP_CELSIUS, + TEMP_FAHRENHEIT, + TEMP_CELSIUS, + TEMP_CELSIUS, + TEMP_CELSIUS, + "mdi:thermometer", + ["daily"], + ], + "temperature_low": [ + "Overnight Low Temperature", + TEMP_CELSIUS, + TEMP_FAHRENHEIT, + TEMP_CELSIUS, + TEMP_CELSIUS, + TEMP_CELSIUS, + "mdi:thermometer", + ["daily"], + ], + "precip_intensity_max": [ + "Daily Max Precip Intensity", + f"mm/{TIME_HOURS}", + "in", + f"mm/{TIME_HOURS}", + f"mm/{TIME_HOURS}", + f"mm/{TIME_HOURS}", + "mdi:thermometer", + ["daily"], + ], + "uv_index": [ + "UV Index", + UV_INDEX, + UV_INDEX, + UV_INDEX, + UV_INDEX, + 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"], + ], + "alerts": ["Alerts", None, None, None, None, None, "mdi:alert-circle-outline", []], } 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'], + "clear-day": ["/static/images/darksky/weather-sunny.svg", "mdi:weather-sunny"], + "clear-night": ["/static/images/darksky/weather-night.svg", "mdi:weather-night"], + "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-partly-cloudy", + ], + "partly-cloudy-night": [ + "/static/images/darksky/weather-cloudy.svg", + "mdi:weather-night-partly-cloudy", + ], } # 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', + "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)]), -}) +ALLOWED_UNITS = ["auto", "si", "us", "ca", "uk", "uk2"] + +ALERTS_ATTRS = ["time", "description", "expires", "severity", "uri", "regions", "title"] + +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): @@ -199,13 +492,18 @@ def setup_platform(hass, config, add_entities, discovery_info=None): if CONF_UNITS in config: units = config[CONF_UNITS] elif hass.config.units.is_metric: - units = 'si' + units = "si" else: - units = 'us' + units = "us" forecast_data = DarkSkyData( - api_key=config.get(CONF_API_KEY, None), latitude=latitude, - longitude=longitude, units=units, language=language, interval=interval) + api_key=config.get(CONF_API_KEY), + latitude=latitude, + longitude=longitude, + units=units, + language=language, + interval=interval, + ) forecast_data.update() forecast_data.update_currently() @@ -221,17 +519,26 @@ def setup_platform(hass, config, add_entities, discovery_info=None): 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]: + if not SENSOR_TYPES[variable][7] or "currently" in SENSOR_TYPES[variable][7]: + if variable == "alerts": + sensors.append(DarkSkyAlertSensor(forecast_data, variable, name)) + else: + 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]: + 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)) + sensors.append( + DarkSkySensor( + forecast_data, variable, name, forecast_hour=forecast_h + ) + ) add_entities(sensors, True) @@ -239,8 +546,9 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class DarkSkySensor(Entity): """Implementation of a Dark Sky sensor.""" - def __init__(self, forecast_data, sensor_type, name, - forecast_day=None, forecast_hour=None): + 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] @@ -256,13 +564,10 @@ def __init__(self, forecast_data, sensor_type, name, 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) + return f"{self.client_name} {self._name} {self.forecast_day}d" 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) + return f"{self.client_name} {self._name} {self.forecast_hour}h" + return f"{self.client_name} {self._name}" @property def state(self): @@ -282,7 +587,7 @@ def unit_system(self): @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: + if self._icon is None or "summary" not in self.type: return None if self._icon in CONDITION_PICTURES: @@ -292,19 +597,15 @@ def entity_picture(self): 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) + 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: + if "summary" in self.type and self._icon in CONDITION_PICTURES: return CONDITION_PICTURES[self._icon][1] return SENSOR_TYPES[self.type][6] @@ -312,9 +613,7 @@ def icon(self): @property def device_state_attributes(self): """Return the state attributes.""" - return { - ATTR_ATTRIBUTION: ATTRIBUTION, - } + return {ATTR_ATTRIBUTION: ATTRIBUTION} def update(self): """Get the latest data from Dark Sky and updates the states.""" @@ -325,32 +624,32 @@ def update(self): self.forecast_data.update() self.update_unit_of_measurement() - if self.type == 'minutely_summary': + 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._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', '') + 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'): + if hasattr(hourly, "data"): self._state = self.get_state(hourly.data[self.forecast_hour]) else: self._state = 0 - elif self.type == 'daily_summary': + 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', '') + 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'): + if hasattr(daily, "data"): self._state = self.get_state(daily.data[self.forecast_day]) else: self._state = 0 @@ -371,40 +670,120 @@ def get_state(self, data): if state is None: return state - if 'summary' in self.type: - self._icon = getattr(data, 'icon', '') + 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']: + 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']: + 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 +class DarkSkyAlertSensor(Entity): + """Implementation of a Dark Sky sensor.""" + + def __init__(self, forecast_data, sensor_type, name): + """Initialize the sensor.""" + self.client_name = name + self._name = SENSOR_TYPES[sensor_type][0] + self.forecast_data = forecast_data + self.type = sensor_type + self._state = None + self._icon = None + self._alerts = None + + @property + def name(self): + """Return the name of the sensor.""" + return f"{self.client_name} {self._name}" + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + if self._state is not None and self._state > 0: + return "mdi:alert-circle" + return "mdi:alert-circle-outline" + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return self._alerts + + 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.forecast_data.update_alerts() + alerts = self.forecast_data.data_alerts + self._state = self.get_state(alerts) + + def get_state(self, data): + """ + Return a new state based on the type. + + If the sensor type is unknown, the current state is returned. + """ + alerts = {} + if data is None: + self._alerts = alerts + return data + + multiple_alerts = len(data) > 1 + for i, alert in enumerate(data): + for attr in ALERTS_ATTRS: + if multiple_alerts: + dkey = f"{attr}_{i!s}" + else: + dkey = attr + alerts[dkey] = getattr(alert, attr) + self._alerts = alerts + + return len(data) + + 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:]) + components = data.split("_") + capital_components = "".join(x.title() for x in components[1:]) + return f"{components[0]}{capital_components}" class DarkSkyData: """Get the latest data from Darksky.""" - def __init__( - self, api_key, latitude, longitude, units, language, interval): + def __init__(self, api_key, latitude, longitude, units, language, interval): """Initialize the data object.""" self._api_key = api_key self.latitude = latitude @@ -418,6 +797,7 @@ def __init__( self.data_minutely = None self.data_hourly = None self.data_daily = None + self.data_alerts = None # Apply throttling to methods using configured interval self.update = Throttle(interval)(self._update) @@ -425,19 +805,22 @@ def __init__( self.update_minutely = Throttle(interval)(self._update_minutely) self.update_hourly = Throttle(interval)(self._update_hourly) self.update_daily = Throttle(interval)(self._update_daily) + self.update_alerts = Throttle(interval)(self._update_alerts) 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) + 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'] + self.unit_system = self.data and self.data.json["flags"]["units"] def _update_currently(self): """Update currently data.""" @@ -454,3 +837,7 @@ def _update_hourly(self): def _update_daily(self): """Update daily data.""" self.data_daily = self.data and self.data.daily() + + def _update_alerts(self): + """Update alerts data.""" + self.data_alerts = self.data and self.data.alerts() diff --git a/homeassistant/components/darksky/weather.py b/homeassistant/components/darksky/weather.py index dd945e7b01c9b..41f063399c1a3 100644 --- a/homeassistant/components/darksky/weather.py +++ b/homeassistant/components/darksky/weather.py @@ -1,55 +1,74 @@ """Support for retrieving meteorological data from Dark Sky.""" -from datetime import datetime, timedelta +from datetime import timedelta import logging -from requests.exceptions import ( - ConnectionError as ConnectError, HTTPError, Timeout) +import forecastio +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) + 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) + 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.dt import utc_from_timestamp from homeassistant.util.pressure import convert as convert_pressure + _LOGGER = logging.getLogger(__name__) ATTRIBUTION = "Powered by Dark Sky" -FORECAST_MODE = ['hourly', 'daily'] +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, + "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' +CONF_UNITS = "units" -DEFAULT_NAME = 'Dark Sky' +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, -}) +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) @@ -63,10 +82,9 @@ def setup_platform(hass, config, add_entities, discovery_info=None): units = config.get(CONF_UNITS) if not units: - units = 'ca' if hass.config.units.is_metric else 'us' + units = "ca" if hass.config.units.is_metric else "us" - dark_sky = DarkSkyData( - config.get(CONF_API_KEY), latitude, longitude, units) + dark_sky = DarkSkyData(config.get(CONF_API_KEY), latitude, longitude, units) add_entities([DarkSkyWeather(name, dark_sky, mode)], True) @@ -85,6 +103,11 @@ def __init__(self, name, dark_sky, mode): self._ds_hourly = None self._ds_daily = None + @property + def available(self): + """Return if weather data is available from Dark Sky.""" + return self._ds_data is not None + @property def attribution(self): """Return the attribution.""" @@ -98,52 +121,52 @@ def name(self): @property def temperature(self): """Return the temperature.""" - return self._ds_currently.get('temperature') + return self._ds_currently.get("temperature") @property def temperature_unit(self): """Return the unit of measurement.""" - return TEMP_FAHRENHEIT if 'us' in self._dark_sky.units \ - else TEMP_CELSIUS + 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) + 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') + return self._ds_currently.get("windSpeed") @property def wind_bearing(self): """Return the wind bearing.""" - return self._ds_currently.get('windBearing') + return self._ds_currently.get("windBearing") @property def ozone(self): """Return the ozone level.""" - return self._ds_currently.get('ozone') + 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) + 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') + return self._ds_currently.get("visibility") @property def condition(self): """Return the weather condition.""" - return MAP_CONDITION.get(self._ds_currently.get('icon')) + return MAP_CONDITION.get(self._ds_currently.get("icon")) @property def forecast(self): @@ -159,34 +182,37 @@ def calc_precipitation(intensity, hours): 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] + if self._mode == "daily": + data = [ + { + ATTR_FORECAST_TIME: utc_from_timestamp( + 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] + data = [ + { + ATTR_FORECAST_TIME: utc_from_timestamp( + 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 @@ -195,7 +221,8 @@ def update(self): self._dark_sky.update() self._ds_data = self._dark_sky.data - self._ds_currently = self._dark_sky.currently.d + currently = self._dark_sky.currently + self._ds_currently = currently.d if currently else {} self._ds_hourly = self._dark_sky.hourly self._ds_daily = self._dark_sky.daily @@ -218,12 +245,10 @@ def __init__(self, api_key, latitude, longitude, units): @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._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() @@ -236,4 +261,4 @@ 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') + return self.data.json.get("flags").get("units") diff --git a/homeassistant/components/datadog/__init__.py b/homeassistant/components/datadog/__init__.py index a59d828301c20..36b4037f70adb 100644 --- a/homeassistant/components/datadog/__init__.py +++ b/homeassistant/components/datadog/__init__.py @@ -1,95 +1,103 @@ """Support for sending data to Datadog.""" import logging +from datadog import initialize, statsd import voluptuous as vol from homeassistant.const import ( - CONF_HOST, CONF_PORT, CONF_PREFIX, EVENT_LOGBOOK_ENTRY, - EVENT_STATE_CHANGED, STATE_UNKNOWN) + 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' +CONF_RATE = "rate" +DEFAULT_HOST = "localhost" DEFAULT_PORT = 8125 -DEFAULT_PREFIX = 'hass' +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) +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) + host = conf[CONF_HOST] + port = conf[CONF_PORT] + sample_rate = conf[CONF_RATE] + prefix = conf[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') + name = event.data.get("name") + message = event.data.get("message") statsd.event( title="Home Assistant", - text="%%% \n **{}** {} \n %%%".format(name, message), + text=f"%%% \n **{name}** {message} \n %%%", tags=[ - "entity:{}".format(event.data.get('entity_id')), - "domain:{}".format(event.data.get('domain')) - ] + f"entity:{event.data.get('entity_id')}", + f"domain:{event.data.get('domain')}", + ], ) - _LOGGER.debug('Sent event %s', event.data.get('entity_id')) + _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') + state = event.data.get("new_state") if state is None or state.state == STATE_UNKNOWN: return - if state.attributes.get('hidden') is True: + if state.attributes.get("hidden") is True: return states = dict(state.attributes) - metric = "{}.{}".format(prefix, state.domain) - tags = ["entity:{}".format(state.entity_id)] + metric = f"{prefix}.{state.domain}" + tags = [f"entity:{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) + attribute = f"{metric}.{key.replace(' ', '_')}" + statsd.gauge(attribute, value, sample_rate=sample_rate, tags=tags) - _LOGGER.debug( - "Sent metric %s: %s (tags: %s)", attribute, value, 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) + _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) + _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) diff --git a/homeassistant/components/datadog/manifest.json b/homeassistant/components/datadog/manifest.json index 40a2e82d53ac3..7394c60804af8 100644 --- a/homeassistant/components/datadog/manifest.json +++ b/homeassistant/components/datadog/manifest.json @@ -1,10 +1,7 @@ { "domain": "datadog", "name": "Datadog", - "documentation": "https://www.home-assistant.io/components/datadog", - "requirements": [ - "datadog==0.15.0" - ], - "dependencies": [], + "documentation": "https://www.home-assistant.io/integrations/datadog", + "requirements": ["datadog==0.15.0"], "codeowners": [] } diff --git a/homeassistant/components/ddwrt/device_tracker.py b/homeassistant/components/ddwrt/device_tracker.py index a97fe340f927e..27f6895fc43d3 100644 --- a/homeassistant/components/ddwrt/device_tracker.py +++ b/homeassistant/components/ddwrt/device_tracker.py @@ -6,26 +6,40 @@ import voluptuous as vol from homeassistant.components.device_tracker import ( - DOMAIN, PLATFORM_SCHEMA, DeviceScanner) + DOMAIN, + PLATFORM_SCHEMA, + DeviceScanner, +) from homeassistant.const import ( - CONF_HOST, CONF_PASSWORD, CONF_SSL, CONF_USERNAME, CONF_VERIFY_SSL) + CONF_HOST, + CONF_PASSWORD, + CONF_SSL, + CONF_USERNAME, + CONF_VERIFY_SSL, + HTTP_OK, +) 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})') +_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, -}) +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): @@ -41,21 +55,21 @@ class DdWrtDeviceScanner(DeviceScanner): def __init__(self, config): """Initialize the DD-WRT scanner.""" - self.protocol = 'https' if config[CONF_SSL] else 'http' + 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) + url = f"{self.protocol}://{self.host}/Status_Wireless.live.asp" data = self.get_ddwrt_data(url) if not data: - raise ConnectionError('Cannot connect to DD-Wrt router') + raise ConnectionError("Cannot connect to DD-Wrt router") def scan_devices(self): """Scan for new devices and return a list with found device IDs.""" @@ -67,22 +81,20 @@ 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) + url = f"{self.protocol}://{self.host}/Status_Lan.live.asp" data = self.get_ddwrt_data(url) if not data: return None - dhcp_leases = data.get('dhcp_leases', None) + dhcp_leases = data.get("dhcp_leases") if not dhcp_leases: return None # Remove leading and trailing quotes and spaces - cleaned_str = dhcp_leases.replace( - "\"", "").replace("\'", "").replace(" ", "") - elements = cleaned_str.split(',') + 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): @@ -103,8 +115,8 @@ def _update_info(self): """ _LOGGER.info("Checking ARP") - url = '{}://{}/Status_Wireless.live.asp'.format( - self.protocol, self.host) + endpoint = "Wireless" if self.wireless_only else "Lan" + url = f"{self.protocol}://{self.host}/Status_{endpoint}.live.asp" data = self.get_ddwrt_data(url) if not data: @@ -112,7 +124,10 @@ def _update_info(self): self.last_results = [] - active_clients = data.get('active_wireless', None) + if self.wireless_only: + active_clients = data.get("active_wireless") + else: + active_clients = data.get("arp_table") if not active_clients: return False @@ -122,8 +137,7 @@ def _update_info(self): clean_str = active_clients.strip().strip("'") elements = clean_str.split("','") - self.last_results.extend(item for item in elements - if _MAC_REGEX.match(item)) + self.last_results.extend(item for item in elements if _MAC_REGEX.match(item)) return True @@ -131,22 +145,25 @@ 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) + 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: + if response.status_code == HTTP_OK: return _parse_ddwrt_response(response.text) if response.status_code == 401: # Authentication error _LOGGER.exception( - "Failed to authenticate, check your username and password") + "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)} + return dict(_DDWRT_DATA_REGEX.findall(data_str)) diff --git a/homeassistant/components/ddwrt/manifest.json b/homeassistant/components/ddwrt/manifest.json index 3c877a3484147..4c716929a86e5 100644 --- a/homeassistant/components/ddwrt/manifest.json +++ b/homeassistant/components/ddwrt/manifest.json @@ -1,8 +1,6 @@ { "domain": "ddwrt", - "name": "Ddwrt", - "documentation": "https://www.home-assistant.io/components/ddwrt", - "requirements": [], - "dependencies": [], + "name": "DD-WRT", + "documentation": "https://www.home-assistant.io/integrations/ddwrt", "codeowners": [] } diff --git a/homeassistant/components/deconz/.translations/bg.json b/homeassistant/components/deconz/.translations/bg.json deleted file mode 100644 index c2cc8f97a8901..0000000000000 --- a/homeassistant/components/deconz/.translations/bg.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "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 deleted file mode 100644 index 5f1ae46b48e88..0000000000000 --- a/homeassistant/components/deconz/.translations/ca.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "L'enlla\u00e7 ja est\u00e0 configurat", - "no_bridges": "No s'han descobert enlla\u00e7os amb 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 per a que es connecti amb la passarel\u00b7la deCONZ proporcionada per l\u2019add-on {addon} de hass.io?", - "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 deleted file mode 100644 index 0f4bdf98ac14d..0000000000000 --- a/homeassistant/components/deconz/.translations/cs.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "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 deleted file mode 100644 index fff54bb3f6cfd..0000000000000 --- a/homeassistant/components/deconz/.translations/cy.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "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 deleted file mode 100644 index e4e5f098a4d88..0000000000000 --- a/homeassistant/components/deconz/.translations/da.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "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 deleted file mode 100644 index 8ce199b426225..0000000000000 --- a/homeassistant/components/deconz/.translations/de.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "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 deleted file mode 100644 index 981f579f09f44..0000000000000 --- a/homeassistant/components/deconz/.translations/en.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Bridge is already configured", - "no_bridges": "No deCONZ bridges discovered", - "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 deleted file mode 100644 index 4ae633ef16573..0000000000000 --- a/homeassistant/components/deconz/.translations/es-419.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "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 deleted file mode 100644 index ca38deb28fe5b..0000000000000 --- a/homeassistant/components/deconz/.translations/es.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "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 deleted file mode 100644 index 93c54b3915cb7..0000000000000 --- a/homeassistant/components/deconz/.translations/et.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "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 deleted file mode 100644 index d18df13701e97..0000000000000 --- a/homeassistant/components/deconz/.translations/fr.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "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" - }, - "error": { - "no_key": "Impossible d'obtenir une cl\u00e9 d'API" - }, - "step": { - "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 deleted file mode 100644 index 89a2d69950e41..0000000000000 --- a/homeassistant/components/deconz/.translations/he.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "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 deleted file mode 100644 index 5bf8db4684190..0000000000000 --- a/homeassistant/components/deconz/.translations/hu.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "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 deleted file mode 100644 index 7d0b3163a40b8..0000000000000 --- a/homeassistant/components/deconz/.translations/id.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "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 deleted file mode 100644 index dfff5743df7aa..0000000000000 --- a/homeassistant/components/deconz/.translations/it.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "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 deleted file mode 100644 index 5148ebeaa86b2..0000000000000 --- a/homeassistant/components/deconz/.translations/ja.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "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 deleted file mode 100644 index f68b4dc10e9a5..0000000000000 --- a/homeassistant/components/deconz/.translations/ko.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "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 deleted file mode 100644 index 3308a557d5dfb..0000000000000 --- a/homeassistant/components/deconz/.translations/lb.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "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 deleted file mode 100644 index d4b65f16552a8..0000000000000 --- a/homeassistant/components/deconz/.translations/nl.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "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 deleted file mode 100644 index 46933ced42761..0000000000000 --- a/homeassistant/components/deconz/.translations/nn.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "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 deleted file mode 100644 index 7934d20ec5327..0000000000000 --- a/homeassistant/components/deconz/.translations/no.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Broen er allerede konfigurert", - "no_bridges": "Ingen deCONZ broer oppdaget", - "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 deleted file mode 100644 index c3eded4334116..0000000000000 --- a/homeassistant/components/deconz/.translations/pl.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "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 deleted file mode 100644 index be79e7e461ae0..0000000000000 --- a/homeassistant/components/deconz/.translations/pt-BR.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "A ponte j\u00e1 est\u00e1 configurada", - "no_bridges": "N\u00e3o h\u00e1 pontes de deCONZ descobertas", - "one_instance_only": "Componente suporta apenas uma inst\u00e2ncia deCONZ" - }, - "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 deleted file mode 100644 index 47f5bb7db59a0..0000000000000 --- a/homeassistant/components/deconz/.translations/pt.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "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 deleted file mode 100644 index c4f2b2c4fab99..0000000000000 --- a/homeassistant/components/deconz/.translations/ru.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "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", - "no_bridges": "\u0428\u043b\u044e\u0437\u044b deCONZ \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u044b", - "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 deleted file mode 100644 index 1a8550ca08fba..0000000000000 --- a/homeassistant/components/deconz/.translations/sl.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "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 deleted file mode 100644 index a5efd2a36d980..0000000000000 --- a/homeassistant/components/deconz/.translations/sv.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Bryggan \u00e4r redan konfigurerad", - "no_bridges": "Inga deCONZ-bryggor uppt\u00e4cktes", - "one_instance_only": "Komponenten st\u00f6djer endast en deCONZ-instans" - }, - "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" - }, - "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 deleted file mode 100644 index e40765e8220a6..0000000000000 --- a/homeassistant/components/deconz/.translations/th.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "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 deleted file mode 100644 index 00f1d9be57f07..0000000000000 --- a/homeassistant/components/deconz/.translations/vi.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "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 deleted file mode 100644 index 2e5a216c77dde..0000000000000 --- a/homeassistant/components/deconz/.translations/zh-Hans.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "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 deleted file mode 100644 index 06b174f27f53b..0000000000000 --- a/homeassistant/components/deconz/.translations/zh-Hant.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "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 index 153e654f3fb32..507b48da9dba2 100644 --- a/homeassistant/components/deconz/__init__.py +++ b/homeassistant/components/deconz/__init__.py @@ -1,57 +1,21 @@ """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 +from homeassistant.config_entries import _UNDEF +from homeassistant.const import EVENT_HOMEASSISTANT_STOP -# 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 .const import CONF_BRIDGE_ID, CONF_GROUP_ID_BASE, CONF_MASTER_GATEWAY, DOMAIN from .gateway import DeconzGateway +from .services import async_setup_services, async_unload_services -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 -})) +CONFIG_SCHEMA = vol.Schema( + {DOMAIN: vol.Schema({}, extra=vol.ALLOW_EXTRA)}, extra=vol.ALLOW_EXTRA +) 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 - )) + """Old way of setting up deCONZ integrations.""" return True @@ -65,127 +29,58 @@ async def async_setup_entry(hass, config_entry): hass.data[DOMAIN] = {} if not config_entry.options: - await async_populate_options(hass, config_entry) + await async_update_master_gateway(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() + # 0.104 introduced config entry unique id, this makes upgrading possible + if config_entry.unique_id is None: - 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] - ) + new_data = _UNDEF + if CONF_BRIDGE_ID in config_entry.data: + new_data = dict(config_entry.data) + new_data[CONF_GROUP_ID_BASE] = config_entry.data[CONF_BRIDGE_ID] - gateway.async_add_device_callback( - 'light', [light - for light_id, light in gateway.api.lights.items() - if light_id not in lights] + hass.config_entries.async_update_entry( + config_entry, unique_id=gateway.api.config.bridgeid, data=new_data ) - gateway.async_add_device_callback( - 'scene', [scene - for scene_id, scene in gateway.api.scenes.items() - if scene_id not in scenes] - ) + hass.data[DOMAIN][config_entry.unique_id] = gateway - gateway.async_add_device_callback( - 'sensor', [sensor - for sensor_id, sensor in gateway.api.sensors.items() - if sensor_id not in sensors] - ) + await gateway.async_update_device_registry() - hass.services.async_register( - DOMAIN, SERVICE_DEVICE_REFRESH, async_refresh_devices, - schema=SERVICE_DEVICE_REFRESCH_SCHEMA) + await async_setup_services(hass) 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]) + gateway = hass.data[DOMAIN].pop(config_entry.unique_id) if not hass.data[DOMAIN]: - hass.services.async_remove(DOMAIN, SERVICE_DECONZ) - hass.services.async_remove(DOMAIN, SERVICE_DEVICE_REFRESH) + await async_unload_services(hass) + elif gateway.master: - await async_populate_options(hass, config_entry) + await async_update_master_gateway(hass, config_entry) new_master_gateway = next(iter(hass.data[DOMAIN].values())) - await async_populate_options(hass, new_master_gateway.config_entry) + await async_update_master_gateway(hass, new_master_gateway.config_entry) return await gateway.async_reset() -async def async_populate_options(hass, config_entry): - """Populate default options for gateway. +async def async_update_master_gateway(hass, config_entry): + """Update master gateway boolean. 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) - } + options = {**config_entry.options, CONF_MASTER_GATEWAY: master} 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 index fbb15abc744ad..95fa223c6979e 100644 --- a/homeassistant/components/deconz/binary_sensor.py +++ b/homeassistant/components/deconz/binary_sensor.py @@ -1,6 +1,8 @@ """Support for deCONZ binary sensors.""" -from homeassistant.components.binary_sensor import BinarySensorDevice -from homeassistant.const import ATTR_BATTERY_LEVEL +from pydeconz.sensor import Presence, Vibration + +from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.const import ATTR_TEMPERATURE from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -8,15 +10,13 @@ from .deconz_device import DeconzDevice from .gateway import get_gateway_from_config_entry -ATTR_ORIENTATION = 'orientation' -ATTR_TILTANGLE = 'tiltangle' -ATTR_VIBRATIONSTRENGTH = 'vibrationstrength' +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 binary sensors.""" - pass +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Old way of setting up deCONZ platforms.""" async def async_setup_entry(hass, config_entry, async_add_entities): @@ -24,42 +24,47 @@ async def async_setup_entry(hass, config_entry, async_add_entities): gateway = get_gateway_from_config_entry(hass, config_entry) @callback - def async_add_sensor(sensors): + def async_add_sensor(sensors, new=True): """Add binary sensor from deCONZ.""" - from pydeconz.sensor import DECONZ_BINARY_SENSOR entities = [] for sensor in sensors: - if sensor.type in DECONZ_BINARY_SENSOR and \ - not (not gateway.allow_clip_sensor and - sensor.type.startswith('CLIP')): - + if ( + new + and sensor.BINARY + and ( + gateway.option_allow_clip_sensor + or not 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)) + gateway.listeners.append( + async_dispatcher_connect( + hass, gateway.async_signal_new_device(NEW_SENSOR), async_add_sensor + ) + ) - async_add_sensor(gateway.api.sensors.values()) + async_add_sensor( + [gateway.api.sensors[key] for key in sorted(gateway.api.sensors, key=int)] + ) -class DeconzBinarySensor(DeconzDevice, BinarySensorDevice): +class DeconzBinarySensor(DeconzDevice, BinarySensorEntity): """Representation of a deCONZ binary sensor.""" @callback - def async_update_callback(self, reason): - """Update the sensor's state. - - If reason is that state is updated, - or reachable has changed or battery has changed. - """ - if reason['state'] or \ - 'reachable' in reason['attr'] or \ - 'battery' in reason['attr'] or \ - 'on' in reason['attr']: - self.async_schedule_update_ha_state() + def async_update_callback(self, force_update=False, ignore_update=False): + """Update the sensor's state.""" + if ignore_update: + return + + keys = {"on", "reachable", "state"} + if force_update or self._device.changed_keys.intersection(keys): + self.async_write_ha_state() @property def is_on(self): @@ -69,26 +74,32 @@ def is_on(self): @property def device_class(self): """Return the class of the sensor.""" - return self._device.sensor_class + return self._device.SENSOR_CLASS @property def icon(self): """Return the icon to use in the frontend.""" - return self._device.sensor_icon + return self._device.SENSOR_ICON @property def device_state_attributes(self): """Return the state attributes of the sensor.""" - from pydeconz.sensor import PRESENCE, VIBRATION 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.type in PRESENCE and self._device.dark is not None: - attr[ATTR_DARK] = self._device.dark - elif self._device.type in VIBRATION: + + if self._device.secondary_temperature is not None: + attr[ATTR_TEMPERATURE] = self._device.secondary_temperature + + if self._device.type in Presence.ZHATYPE: + + if 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 index c4a021a80c223..424693505ca47 100644 --- a/homeassistant/components/deconz/climate.py +++ b/homeassistant/components/deconz/climate.py @@ -1,9 +1,14 @@ """Support for deCONZ climate devices.""" -from homeassistant.components.climate import ClimateDevice +from pydeconz.sensor import Thermostat + +from homeassistant.components.climate import ClimateEntity from homeassistant.components.climate.const import ( - SUPPORT_ON_OFF, SUPPORT_TARGET_TEMPERATURE) -from homeassistant.const import ( - ATTR_BATTERY_LEVEL, ATTR_TEMPERATURE, TEMP_CELSIUS) + HVAC_MODE_AUTO, + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + SUPPORT_TARGET_TEMPERATURE, +) +from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -11,6 +16,12 @@ from .deconz_device import DeconzDevice from .gateway import get_gateway_from_config_entry +SUPPORT_HVAC = [HVAC_MODE_AUTO, HVAC_MODE_HEAT, HVAC_MODE_OFF] + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Old way of setting up deCONZ platforms.""" + async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the deCONZ climate devices. @@ -20,56 +31,60 @@ async def async_setup_entry(hass, config_entry, async_add_entities): gateway = get_gateway_from_config_entry(hass, config_entry) @callback - def async_add_climate(sensors): + def async_add_climate(sensors, new=True): """Add climate devices from deCONZ.""" - from pydeconz.sensor import THERMOSTAT entities = [] for sensor in sensors: - if sensor.type in THERMOSTAT and \ - not (not gateway.allow_clip_sensor and - sensor.type.startswith('CLIP')): - + if ( + new + and sensor.type in Thermostat.ZHATYPE + and ( + gateway.option_allow_clip_sensor + or not 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)) + gateway.listeners.append( + async_dispatcher_connect( + hass, gateway.async_signal_new_device(NEW_SENSOR), async_add_climate + ) + ) async_add_climate(gateway.api.sensors.values()) -class DeconzThermostat(DeconzDevice, ClimateDevice): +class DeconzThermostat(DeconzDevice, ClimateEntity): """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 + return SUPPORT_TARGET_TEMPERATURE @property - def is_on(self): - """Return true if on.""" - return self._device.on + def hvac_mode(self): + """Return hvac operation ie. heat, cool mode. - async def async_turn_on(self): - """Turn on switch.""" - data = {'mode': 'auto'} - await self._device.async_set_config(data) + Need to be one of HVAC_MODE_*. + """ + if self._device.mode in SUPPORT_HVAC: + return self._device.mode + if self._device.state_on: + return HVAC_MODE_HEAT + return HVAC_MODE_OFF - async def async_turn_off(self): - """Turn off switch.""" - data = {'mode': 'off'} - await self._device.async_set_config(data) + @property + def hvac_modes(self): + """Return the list of available hvac operation modes. + + Need to be a subset of HVAC_MODES. + """ + return SUPPORT_HVAC @property def current_temperature(self): @@ -86,7 +101,18 @@ async def async_set_temperature(self, **kwargs): data = {} if ATTR_TEMPERATURE in kwargs: - data['heatsetpoint'] = kwargs[ATTR_TEMPERATURE] * 100 + data["heatsetpoint"] = kwargs[ATTR_TEMPERATURE] * 100 + + await self._device.async_set_config(data) + + async def async_set_hvac_mode(self, hvac_mode): + """Set new target hvac mode.""" + if hvac_mode == HVAC_MODE_AUTO: + data = {"mode": "auto"} + elif hvac_mode == HVAC_MODE_HEAT: + data = {"mode": "heat"} + elif hvac_mode == HVAC_MODE_OFF: + data = {"mode": "off"} await self._device.async_set_config(data) @@ -100,9 +126,6 @@ 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 diff --git a/homeassistant/components/deconz/config_flow.py b/homeassistant/components/deconz/config_flow.py index d9065ad2727fc..f52a18bbd0745 100644 --- a/homeassistant/components/deconz/config_flow.py +++ b/homeassistant/components/deconz/config_flow.py @@ -1,24 +1,38 @@ """Config flow to configure deCONZ component.""" import asyncio +from pprint import pformat +from urllib.parse import urlparse import async_timeout +from pydeconz.errors import RequestError, ResponseError +from pydeconz.utils import ( + async_discovery, + async_get_api_key, + async_get_bridge_id, + normalize_bridge_id, +) import voluptuous as vol from homeassistant import config_entries +from homeassistant.components import ssdp 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 +from .const import ( + CONF_ALLOW_CLIP_SENSOR, + CONF_ALLOW_DECONZ_GROUPS, + CONF_BRIDGE_ID, + DEFAULT_ALLOW_CLIP_SENSOR, + DEFAULT_ALLOW_DECONZ_GROUPS, + DEFAULT_PORT, + DOMAIN, + LOGGER, +) -CONF_SERIAL = 'serial' - - -@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)} +DECONZ_MANUFACTURERURL = "http://www.dresden-elektronik.de" +CONF_SERIAL = "serial" +CONF_MANUAL_INPUT = "Manually define gateway" @callback @@ -29,8 +43,7 @@ def get_master_gateway(hass): return gateway -@config_entries.HANDLERS.register(DOMAIN) -class DeconzFlowHandler(config_entries.ConfigFlow): +class DeconzFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle a deCONZ config flow.""" VERSION = 1 @@ -38,171 +51,177 @@ class DeconzFlowHandler(config_entries.ConfigFlow): _hassio_discovery = None + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get the options flow for this handler.""" + return DeconzOptionsFlowHandler(config_entry) + def __init__(self): """Initialize the deCONZ config flow.""" + self.bridge_id = None 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. + Let user choose between discovered bridges and manual configuration. If no bridge is found allow user to manually input configuration. """ - from pydeconz.utils import async_discovery - if user_input is not None: + + if CONF_MANUAL_INPUT == user_input[CONF_HOST]: + return await self.async_step_manual_input() + for bridge in self.bridges: if bridge[CONF_HOST] == user_input[CONF_HOST]: - self.deconz_config = bridge + self.bridge_id = bridge[CONF_BRIDGE_ID] + self.deconz_config = { + CONF_HOST: bridge[CONF_HOST], + CONF_PORT: bridge[CONF_PORT], + } 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: + except (asyncio.TimeoutError, ResponseError): self.bridges = [] - if len(self.bridges) == 1: - self.deconz_config = self.bridges[0] - return await self.async_step_link() + LOGGER.debug("Discovered deCONZ gateways %s", pformat(self.bridges)) - if len(self.bridges) > 1: + if self.bridges: hosts = [] for bridge in self.bridges: hosts.append(bridge[CONF_HOST]) + hosts.append(CONF_MANUAL_INPUT) + return self.async_show_form( - step_id='init', - data_schema=vol.Schema({ - vol.Required(CONF_HOST): vol.In(hosts) - }) + step_id="user", + data_schema=vol.Schema({vol.Optional(CONF_HOST): vol.In(hosts)}), ) + return await self.async_step_manual_input() + + async def async_step_manual_input(self, user_input=None): + """Manual configuration.""" + if user_input: + self.deconz_config = user_input + return await self.async_step_link() + 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, - }), + step_id="manual_input", + 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.""" - from pydeconz.errors import ResponseError, RequestError - from pydeconz.utils import async_get_api_key errors = {} + LOGGER.debug( + "Preparing linking with deCONZ gateway %s", pformat(self.deconz_config) + ) + 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) + api_key = await async_get_api_key(session, **self.deconz_config) except (ResponseError, RequestError, asyncio.TimeoutError): - errors['base'] = 'no_key' + 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, - ) + return self.async_show_form(step_id="link", errors=errors) async def _create_entry(self): """Create entry for gateway.""" - from pydeconz.utils import async_get_bridgeid - - if CONF_BRIDGEID not in self.deconz_config: + if not self.bridge_id: 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) + self.bridge_id = await async_get_bridge_id( + session, **self.deconz_config + ) + await self.async_set_unique_id(self.bridge_id) + + self._abort_if_unique_id_configured( + updates={ + CONF_HOST: self.deconz_config[CONF_HOST], + CONF_PORT: self.deconz_config[CONF_PORT], + CONF_API_KEY: self.deconz_config[CONF_API_KEY], + } + ) except asyncio.TimeoutError: - return self.async_abort(reason='no_bridges') + 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) + return self.async_create_entry(title=self.bridge_id, data=self.deconz_config) - async def async_step_discovery(self, discovery_info): - """Prepare configuration for a discovered deCONZ bridge. + async def async_step_ssdp(self, discovery_info): + """Handle a discovered deCONZ bridge.""" + if ( + discovery_info.get(ssdp.ATTR_UPNP_MANUFACTURER_URL) + != DECONZ_MANUFACTURERURL + ): + return self.async_abort(reason="not_deconz_bridge") - This flow is triggered by the discovery component. - """ - bridgeid = discovery_info[CONF_SERIAL] - gateway_entries = configured_gateways(self.hass) - - if bridgeid in gateway_entries: - entry = gateway_entries[bridgeid] - await self._update_entry(entry, discovery_info[CONF_HOST]) - return self.async_abort(reason='updated_instance') - - deconz_config = { - CONF_HOST: discovery_info[CONF_HOST], - CONF_PORT: discovery_info[CONF_PORT], - CONF_BRIDGEID: discovery_info[CONF_SERIAL] - } + LOGGER.debug("deCONZ SSDP discovery %s", pformat(discovery_info)) - return await self.async_step_import(deconz_config) + self.bridge_id = normalize_bridge_id(discovery_info[ssdp.ATTR_UPNP_SERIAL]) + parsed_url = urlparse(discovery_info[ssdp.ATTR_SSDP_LOCATION]) - async def async_step_import(self, import_config): - """Import a deCONZ bridge as a config entry. + entry = await self.async_set_unique_id(self.bridge_id) + if entry and entry.source == "hassio": + return self.async_abort(reason="already_configured") - This flow is triggered by `async_setup` for configured bridges. - This flow is also triggered by `async_step_discovery`. + self._abort_if_unique_id_configured( + updates={CONF_HOST: parsed_url.hostname, CONF_PORT: parsed_url.port} + ) - This will execute for any bridge that does not have a - config entry yet (based on host). + # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 + self.context["title_placeholders"] = {"host": parsed_url.hostname} - 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() + self.deconz_config = { + CONF_HOST: parsed_url.hostname, + CONF_PORT: parsed_url.port, + } - return await self._create_entry() + return await self.async_step_link() 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) + LOGGER.debug("deCONZ HASSIO discovery %s", pformat(user_input)) - 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.bridge_id = normalize_bridge_id(user_input[CONF_SERIAL]) + await self.async_set_unique_id(self.bridge_id) + + self._abort_if_unique_id_configured( + updates={ + CONF_HOST: user_input[CONF_HOST], + CONF_PORT: user_input[CONF_PORT], + CONF_API_KEY: user_input[CONF_API_KEY], + } + ) self._hassio_discovery = user_input @@ -214,15 +233,54 @@ async def async_step_hassio_confirm(self, user_input=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] + 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'] - } + step_id="hassio_confirm", + description_placeholders={"addon": self._hassio_discovery["addon"]}, + ) + + +class DeconzOptionsFlowHandler(config_entries.OptionsFlow): + """Handle deCONZ options.""" + + def __init__(self, config_entry): + """Initialize deCONZ options flow.""" + self.config_entry = config_entry + self.options = dict(config_entry.options) + + async def async_step_init(self, user_input=None): + """Manage the deCONZ options.""" + return await self.async_step_deconz_devices() + + async def async_step_deconz_devices(self, user_input=None): + """Manage the deconz devices options.""" + if user_input is not None: + self.options[CONF_ALLOW_CLIP_SENSOR] = user_input[CONF_ALLOW_CLIP_SENSOR] + self.options[CONF_ALLOW_DECONZ_GROUPS] = user_input[ + CONF_ALLOW_DECONZ_GROUPS + ] + return self.async_create_entry(title="", data=self.options) + + return self.async_show_form( + step_id="deconz_devices", + data_schema=vol.Schema( + { + vol.Optional( + CONF_ALLOW_CLIP_SENSOR, + default=self.config_entry.options.get( + CONF_ALLOW_CLIP_SENSOR, DEFAULT_ALLOW_CLIP_SENSOR + ), + ): bool, + vol.Optional( + CONF_ALLOW_DECONZ_GROUPS, + default=self.config_entry.options.get( + CONF_ALLOW_DECONZ_GROUPS, DEFAULT_ALLOW_DECONZ_GROUPS + ), + ): bool, + } + ), ) diff --git a/homeassistant/components/deconz/const.py b/homeassistant/components/deconz/const.py index bf0f5884073c5..cd125613f21f2 100644 --- a/homeassistant/components/deconz/const.py +++ b/homeassistant/components/deconz/const.py @@ -1,43 +1,47 @@ """Constants for the deCONZ component.""" import logging -_LOGGER = logging.getLogger('.') +LOGGER = logging.getLogger(__package__) -DOMAIN = 'deconz' +DOMAIN = "deconz" + +CONF_BRIDGE_ID = "bridgeid" +CONF_GROUP_ID_BASE = "group_id_base" 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' +DEFAULT_ALLOW_DECONZ_GROUPS = True + +CONF_ALLOW_CLIP_SENSOR = "allow_clip_sensor" +CONF_ALLOW_DECONZ_GROUPS = "allow_deconz_groups" +CONF_MASTER_GATEWAY = "master" + +SUPPORTED_PLATFORMS = [ + "binary_sensor", + "climate", + "cover", + "light", + "scene", + "sensor", + "switch", +] + +NEW_GROUP = "groups" +NEW_LIGHT = "lights" +NEW_SCENE = "scenes" +NEW_SENSOR = "sensors" + +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"] +POWER_PLUGS = ["On/Off light", "On/Off plug-in unit", "Smart plug"] SIRENS = ["Warning device"] SWITCH_TYPES = POWER_PLUGS + SIRENS + +CONF_GESTURE = "gesture" diff --git a/homeassistant/components/deconz/cover.py b/homeassistant/components/deconz/cover.py index aa29e8c6b58b1..e01cfdbe5f896 100644 --- a/homeassistant/components/deconz/cover.py +++ b/homeassistant/components/deconz/cover.py @@ -1,7 +1,12 @@ """Support for deCONZ covers.""" from homeassistant.components.cover import ( - ATTR_POSITION, CoverDevice, SUPPORT_CLOSE, SUPPORT_OPEN, SUPPORT_STOP, - SUPPORT_SET_POSITION) + ATTR_POSITION, + SUPPORT_CLOSE, + SUPPORT_OPEN, + SUPPORT_SET_POSITION, + SUPPORT_STOP, + CoverEntity, +) from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -9,13 +14,9 @@ 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): - """Unsupported way of setting up deCONZ covers.""" - pass +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Old way of setting up deCONZ platforms.""" async def async_setup_entry(hass, config_entry, async_add_entities): @@ -31,23 +32,21 @@ def async_add_cover(lights): 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)) + 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)) + gateway.listeners.append( + async_dispatcher_connect( + hass, gateway.async_signal_new_device(NEW_LIGHT), async_add_cover + ) + ) async_add_cover(gateway.api.lights.values()) -class DeconzCover(DeconzDevice, CoverDevice): +class DeconzCover(DeconzDevice, CoverEntity): """Representation of a deCONZ cover.""" def __init__(self, device, gateway): @@ -62,22 +61,20 @@ def __init__(self, device, gateway): @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) + return 100 - int(self._device.brightness / 254 * 100) @property def is_closed(self): """Return if the cover is closed.""" - return not self._device.state + return self._device.state @property def device_class(self): """Return the class of the cover.""" if self._device.type in DAMPERS: - return 'damper' + return "damper" if self._device.type in WINDOW_COVERS: - return 'window' + return "window" @property def supported_features(self): @@ -87,11 +84,11 @@ def supported_features(self): async def async_set_cover_position(self, **kwargs): """Move the cover to a specific position.""" position = kwargs[ATTR_POSITION] - data = {'on': False} + data = {"on": False} - if position > 0: - data['on'] = True - data['bri'] = int(position / 100 * 255) + if position < 100: + data["on"] = True + data["bri"] = 254 - int(position / 100 * 254) await self._device.async_set_state(data) @@ -107,30 +104,5 @@ async def async_close_cover(self, **kwargs): 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) - + data = {"bri_inc": 0} await self._device.async_set_state(data) diff --git a/homeassistant/components/deconz/deconz_device.py b/homeassistant/components/deconz/deconz_device.py index 73ac2499cd3ad..80557caeca68f 100644 --- a/homeassistant/components/deconz/deconz_device.py +++ b/homeassistant/components/deconz/deconz_device.py @@ -7,70 +7,118 @@ from .const import DOMAIN as DECONZ_DOMAIN -class DeconzDevice(Entity): - """Representation of a deCONZ device.""" +class DeconzBase: + """Common base for deconz entities and events.""" def __init__(self, device, gateway): """Set up device and add update callback to get data from websocket.""" self._device = device self.gateway = gateway + self.listeners = [] + + @property + def unique_id(self): + """Return a unique identifier for this device.""" + return self._device.uniqueid + + @property + def serial(self): + """Return a serial number for this device.""" + if self._device.uniqueid is None or self._device.uniqueid.count(":") != 7: + return None + + return self._device.uniqueid.split("-", 1)[0] + + @property + def device_info(self): + """Return a device description for device registry.""" + if self.serial is None: + return None + + bridgeid = self.gateway.api.config.bridgeid + + return { + "connections": {(CONNECTION_ZIGBEE, self.serial)}, + "identifiers": {(DECONZ_DOMAIN, self.serial)}, + "manufacturer": self._device.manufacturer, + "model": self._device.modelid, + "name": self._device.name, + "sw_version": self._device.swversion, + "via_device": (DECONZ_DOMAIN, bridgeid), + } + + +class DeconzDevice(DeconzBase, Entity): + """Representation of a deCONZ device.""" + + def __init__(self, device, gateway): + """Set up device and add update callback to get data from websocket.""" + super().__init__(device, gateway) + self.unsub_dispatcher = None + @property + def entity_registry_enabled_default(self): + """Return if the entity should be enabled when first added to the entity registry. + + Daylight is a virtual sensor from deCONZ that should never be enabled by default. + """ + if self._device.type == "Daylight": + return False + + return True + async def async_added_to_hass(self): """Subscribe to device events.""" - self._device.register_async_callback(self.async_update_callback) + self._device.register_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) + self.listeners.append( + async_dispatcher_connect( + self.hass, self.gateway.signal_reachable, self.async_update_callback + ) + ) + self.listeners.append( + async_dispatcher_connect( + self.hass, self.gateway.signal_remove_entity, self.async_remove_self + ) + ) 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() + if self.entity_id in self.gateway.deconz_ids: + del self.gateway.deconz_ids[self.entity_id] + for unsub_dispatcher in self.listeners: + unsub_dispatcher() + + async def async_remove_self(self, deconz_ids: list) -> None: + """Schedule removal of this entity. + + Called by signal_remove_entity scheduled by async_added_to_hass. + """ + if self._device.deconz_id not in deconz_ids: + return + await self.async_remove() @callback - def async_update_callback(self, reason): + def async_update_callback(self, force_update=False, ignore_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 + if ignore_update: + return - @property - def unique_id(self): - """Return a unique identifier for this device.""" - return self._device.uniqueid + self.async_write_ha_state() @property def available(self): """Return True if device is available.""" return self.gateway.available and self._device.reachable + @property + def name(self): + """Return the name of the device.""" + return self._device.name + @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_hub': (DECONZ_DOMAIN, bridgeid), - } diff --git a/homeassistant/components/deconz/deconz_event.py b/homeassistant/components/deconz/deconz_event.py new file mode 100644 index 0000000000000..1009ae4e54c4d --- /dev/null +++ b/homeassistant/components/deconz/deconz_event.py @@ -0,0 +1,67 @@ +"""Representation of a deCONZ remote.""" +from homeassistant.const import CONF_EVENT, CONF_ID +from homeassistant.core import callback +from homeassistant.util import slugify + +from .const import CONF_GESTURE, LOGGER +from .deconz_device import DeconzBase + +CONF_DECONZ_EVENT = "deconz_event" +CONF_UNIQUE_ID = "unique_id" + + +class DeconzEvent(DeconzBase): + """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, device, gateway): + """Register callback that will be used for signals.""" + super().__init__(device, gateway) + + self._device.register_callback(self.async_update_callback) + + self.device_id = None + self.event_id = slugify(self._device.name) + LOGGER.debug("deCONZ event created: %s", self.event_id) + + @property + def device(self): + """Return Event device.""" + return self._device + + @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, ignore_update=False): + """Fire the event if reason is that state is updated.""" + if ignore_update or "state" not in self._device.changed_keys: + return + + data = { + CONF_ID: self.event_id, + CONF_UNIQUE_ID: self.serial, + CONF_EVENT: self._device.state, + } + + if self._device.gesture is not None: + data[CONF_GESTURE] = self._device.gesture + + self.gateway.hass.bus.async_fire(CONF_DECONZ_EVENT, data) + + async def async_update_device_registry(self): + """Update device registry.""" + device_registry = ( + await self.gateway.hass.helpers.device_registry.async_get_registry() + ) + + entry = device_registry.async_get_or_create( + config_entry_id=self.gateway.config_entry.entry_id, **self.device_info + ) + self.device_id = entry.id diff --git a/homeassistant/components/deconz/device_trigger.py b/homeassistant/components/deconz/device_trigger.py new file mode 100644 index 0000000000000..b0486a99dc823 --- /dev/null +++ b/homeassistant/components/deconz/device_trigger.py @@ -0,0 +1,459 @@ +"""Provides device automations for deconz events.""" +import voluptuous as vol + +import homeassistant.components.automation.event as event +from homeassistant.components.device_automation import TRIGGER_BASE_SCHEMA +from homeassistant.components.device_automation.exceptions import ( + InvalidDeviceAutomationConfig, +) +from homeassistant.const import ( + CONF_DEVICE_ID, + CONF_DOMAIN, + CONF_EVENT, + CONF_PLATFORM, + CONF_TYPE, +) + +from . import DOMAIN +from .deconz_event import CONF_DECONZ_EVENT, CONF_GESTURE, CONF_UNIQUE_ID + +CONF_SUBTYPE = "subtype" + +CONF_SHORT_PRESS = "remote_button_short_press" +CONF_SHORT_RELEASE = "remote_button_short_release" +CONF_LONG_PRESS = "remote_button_long_press" +CONF_LONG_RELEASE = "remote_button_long_release" +CONF_DOUBLE_PRESS = "remote_button_double_press" +CONF_TRIPLE_PRESS = "remote_button_triple_press" +CONF_QUADRUPLE_PRESS = "remote_button_quadruple_press" +CONF_QUINTUPLE_PRESS = "remote_button_quintuple_press" +CONF_ROTATED = "remote_button_rotated" +CONF_ROTATION_STOPPED = "remote_button_rotation_stopped" +CONF_AWAKE = "remote_awakened" +CONF_MOVE = "remote_moved" +CONF_DOUBLE_TAP = "remote_double_tap" +CONF_SHAKE = "remote_gyro_activated" +CONF_FREE_FALL = "remote_falling" +CONF_FLIP_90 = "remote_flip_90_degrees" +CONF_FLIP_180 = "remote_flip_180_degrees" +CONF_MOVE_ANY = "remote_moved_any_side" +CONF_DOUBLE_TAP_ANY = "remote_double_tap_any_side" +CONF_TURN_CW = "remote_turned_clockwise" +CONF_TURN_CCW = "remote_turned_counter_clockwise" +CONF_ROTATE_FROM_SIDE_1 = "remote_rotate_from_side_1" +CONF_ROTATE_FROM_SIDE_2 = "remote_rotate_from_side_2" +CONF_ROTATE_FROM_SIDE_3 = "remote_rotate_from_side_3" +CONF_ROTATE_FROM_SIDE_4 = "remote_rotate_from_side_4" +CONF_ROTATE_FROM_SIDE_5 = "remote_rotate_from_side_5" +CONF_ROTATE_FROM_SIDE_6 = "remote_rotate_from_side_6" + +CONF_TURN_ON = "turn_on" +CONF_TURN_OFF = "turn_off" +CONF_DIM_UP = "dim_up" +CONF_DIM_DOWN = "dim_down" +CONF_LEFT = "left" +CONF_RIGHT = "right" +CONF_OPEN = "open" +CONF_CLOSE = "close" +CONF_BOTH_BUTTONS = "both_buttons" +CONF_TOP_BUTTONS = "top_buttons" +CONF_BOTTOM_BUTTONS = "bottom_buttons" +CONF_BUTTON_1 = "button_1" +CONF_BUTTON_2 = "button_2" +CONF_BUTTON_3 = "button_3" +CONF_BUTTON_4 = "button_4" +CONF_SIDE_1 = "side_1" +CONF_SIDE_2 = "side_2" +CONF_SIDE_3 = "side_3" +CONF_SIDE_4 = "side_4" +CONF_SIDE_5 = "side_5" +CONF_SIDE_6 = "side_6" + + +HUE_DIMMER_REMOTE_MODEL_GEN1 = "RWL020" +HUE_DIMMER_REMOTE_MODEL_GEN2 = "RWL021" +HUE_DIMMER_REMOTE = { + (CONF_SHORT_PRESS, CONF_TURN_ON): {CONF_EVENT: 1000}, + (CONF_SHORT_RELEASE, CONF_TURN_ON): {CONF_EVENT: 1002}, + (CONF_LONG_PRESS, CONF_TURN_ON): {CONF_EVENT: 1001}, + (CONF_LONG_RELEASE, CONF_TURN_ON): {CONF_EVENT: 1003}, + (CONF_SHORT_PRESS, CONF_DIM_UP): {CONF_EVENT: 2000}, + (CONF_SHORT_RELEASE, CONF_DIM_UP): {CONF_EVENT: 2002}, + (CONF_LONG_PRESS, CONF_DIM_UP): {CONF_EVENT: 2001}, + (CONF_LONG_RELEASE, CONF_DIM_UP): {CONF_EVENT: 2003}, + (CONF_SHORT_PRESS, CONF_DIM_DOWN): {CONF_EVENT: 3000}, + (CONF_SHORT_RELEASE, CONF_DIM_DOWN): {CONF_EVENT: 3002}, + (CONF_LONG_PRESS, CONF_DIM_DOWN): {CONF_EVENT: 3001}, + (CONF_LONG_RELEASE, CONF_DIM_DOWN): {CONF_EVENT: 3003}, + (CONF_SHORT_PRESS, CONF_TURN_OFF): {CONF_EVENT: 4000}, + (CONF_SHORT_RELEASE, CONF_TURN_OFF): {CONF_EVENT: 4002}, + (CONF_LONG_PRESS, CONF_TURN_OFF): {CONF_EVENT: 4001}, + (CONF_LONG_RELEASE, CONF_TURN_OFF): {CONF_EVENT: 4003}, +} + +HUE_TAP_REMOTE_MODEL = "ZGPSWITCH" +HUE_TAP_REMOTE = { + (CONF_SHORT_PRESS, CONF_BUTTON_1): {CONF_EVENT: 34}, + (CONF_SHORT_PRESS, CONF_BUTTON_2): {CONF_EVENT: 16}, + (CONF_SHORT_PRESS, CONF_BUTTON_3): {CONF_EVENT: 17}, + (CONF_SHORT_PRESS, CONF_BUTTON_4): {CONF_EVENT: 18}, +} + +FRIENDS_OF_HUE_SWITCH_MODEL = "FOHSWITCH" +FRIENDS_OF_HUE_SWITCH = { + (CONF_SHORT_PRESS, CONF_BUTTON_1): {CONF_EVENT: 1000}, + (CONF_SHORT_RELEASE, CONF_BUTTON_1): {CONF_EVENT: 1002}, + (CONF_LONG_PRESS, CONF_BUTTON_1): {CONF_EVENT: 1001}, + (CONF_LONG_RELEASE, CONF_BUTTON_1): {CONF_EVENT: 1003}, + (CONF_SHORT_PRESS, CONF_BUTTON_2): {CONF_EVENT: 2000}, + (CONF_SHORT_RELEASE, CONF_BUTTON_2): {CONF_EVENT: 2002}, + (CONF_LONG_PRESS, CONF_BUTTON_2): {CONF_EVENT: 2001}, + (CONF_LONG_RELEASE, CONF_BUTTON_2): {CONF_EVENT: 2003}, + (CONF_SHORT_PRESS, CONF_BUTTON_3): {CONF_EVENT: 3000}, + (CONF_SHORT_RELEASE, CONF_BUTTON_3): {CONF_EVENT: 3002}, + (CONF_LONG_PRESS, CONF_BUTTON_3): {CONF_EVENT: 3001}, + (CONF_LONG_RELEASE, CONF_BUTTON_3): {CONF_EVENT: 3003}, + (CONF_SHORT_PRESS, CONF_BUTTON_4): {CONF_EVENT: 4000}, + (CONF_SHORT_RELEASE, CONF_BUTTON_4): {CONF_EVENT: 4002}, + (CONF_LONG_PRESS, CONF_BUTTON_4): {CONF_EVENT: 4001}, + (CONF_LONG_RELEASE, CONF_BUTTON_4): {CONF_EVENT: 4003}, + (CONF_SHORT_PRESS, CONF_TOP_BUTTONS): {CONF_EVENT: 5000}, + (CONF_SHORT_RELEASE, CONF_TOP_BUTTONS): {CONF_EVENT: 5002}, + (CONF_LONG_PRESS, CONF_TOP_BUTTONS): {CONF_EVENT: 5001}, + (CONF_LONG_RELEASE, CONF_TOP_BUTTONS): {CONF_EVENT: 5003}, + (CONF_SHORT_PRESS, CONF_BOTTOM_BUTTONS): {CONF_EVENT: 6000}, + (CONF_SHORT_RELEASE, CONF_BOTTOM_BUTTONS): {CONF_EVENT: 6002}, + (CONF_LONG_PRESS, CONF_BOTTOM_BUTTONS): {CONF_EVENT: 6001}, + (CONF_LONG_RELEASE, CONF_BOTTOM_BUTTONS): {CONF_EVENT: 6003}, +} + +SYMFONISK_SOUND_CONTROLLER_MODEL = "SYMFONISK Sound Controller" +SYMFONISK_SOUND_CONTROLLER = { + (CONF_SHORT_PRESS, CONF_TURN_ON): {CONF_EVENT: 1002}, + (CONF_DOUBLE_PRESS, CONF_TURN_ON): {CONF_EVENT: 1004}, + (CONF_TRIPLE_PRESS, CONF_TURN_ON): {CONF_EVENT: 1005}, + (CONF_ROTATED, CONF_LEFT): {CONF_EVENT: 2001}, + (CONF_ROTATION_STOPPED, CONF_LEFT): {CONF_EVENT: 2003}, + (CONF_ROTATED, CONF_RIGHT): {CONF_EVENT: 3001}, + (CONF_ROTATION_STOPPED, CONF_RIGHT): {CONF_EVENT: 3003}, +} + +TRADFRI_ON_OFF_SWITCH_MODEL = "TRADFRI on/off switch" +TRADFRI_ON_OFF_SWITCH = { + (CONF_SHORT_PRESS, CONF_TURN_ON): {CONF_EVENT: 1002}, + (CONF_LONG_PRESS, CONF_TURN_ON): {CONF_EVENT: 1001}, + (CONF_LONG_RELEASE, CONF_TURN_ON): {CONF_EVENT: 1003}, + (CONF_SHORT_PRESS, CONF_TURN_OFF): {CONF_EVENT: 2002}, + (CONF_LONG_PRESS, CONF_TURN_OFF): {CONF_EVENT: 2001}, + (CONF_LONG_RELEASE, CONF_TURN_OFF): {CONF_EVENT: 2003}, +} + +TRADFRI_OPEN_CLOSE_REMOTE_MODEL = "TRADFRI open/close remote" +TRADFRI_OPEN_CLOSE_REMOTE = { + (CONF_SHORT_PRESS, CONF_OPEN): {CONF_EVENT: 1002}, + (CONF_LONG_PRESS, CONF_OPEN): {CONF_EVENT: 1003}, + (CONF_SHORT_PRESS, CONF_CLOSE): {CONF_EVENT: 2002}, + (CONF_LONG_PRESS, CONF_CLOSE): {CONF_EVENT: 2003}, +} + +TRADFRI_REMOTE_MODEL = "TRADFRI remote control" +TRADFRI_REMOTE = { + (CONF_SHORT_PRESS, CONF_TURN_ON): {CONF_EVENT: 1002}, + (CONF_LONG_PRESS, CONF_TURN_ON): {CONF_EVENT: 1001}, + (CONF_SHORT_PRESS, CONF_DIM_UP): {CONF_EVENT: 2002}, + (CONF_LONG_PRESS, CONF_DIM_UP): {CONF_EVENT: 2001}, + (CONF_LONG_RELEASE, CONF_DIM_UP): {CONF_EVENT: 2003}, + (CONF_SHORT_PRESS, CONF_DIM_DOWN): {CONF_EVENT: 3002}, + (CONF_LONG_PRESS, CONF_DIM_DOWN): {CONF_EVENT: 3001}, + (CONF_LONG_RELEASE, CONF_DIM_DOWN): {CONF_EVENT: 3003}, + (CONF_SHORT_PRESS, CONF_LEFT): {CONF_EVENT: 4002}, + (CONF_LONG_PRESS, CONF_LEFT): {CONF_EVENT: 4001}, + (CONF_LONG_RELEASE, CONF_LEFT): {CONF_EVENT: 4003}, + (CONF_SHORT_PRESS, CONF_RIGHT): {CONF_EVENT: 5002}, + (CONF_LONG_PRESS, CONF_RIGHT): {CONF_EVENT: 5001}, + (CONF_LONG_RELEASE, CONF_RIGHT): {CONF_EVENT: 5003}, +} + +TRADFRI_WIRELESS_DIMMER_MODEL = "TRADFRI wireless dimmer" +TRADFRI_WIRELESS_DIMMER = { + (CONF_ROTATED, CONF_LEFT): {CONF_EVENT: 3002}, + (CONF_ROTATED, CONF_RIGHT): {CONF_EVENT: 2002}, +} + +AQARA_CUBE_MODEL = "lumi.sensor_cube" +AQARA_CUBE_MODEL_ALT1 = "lumi.sensor_cube.aqgl01" +AQARA_CUBE = { + (CONF_ROTATE_FROM_SIDE_1, CONF_SIDE_2): {CONF_EVENT: 2001}, + (CONF_ROTATE_FROM_SIDE_1, CONF_SIDE_3): {CONF_EVENT: 3001}, + (CONF_ROTATE_FROM_SIDE_1, CONF_SIDE_4): {CONF_EVENT: 4001}, + (CONF_ROTATE_FROM_SIDE_1, CONF_SIDE_5): {CONF_EVENT: 5001}, + (CONF_ROTATE_FROM_SIDE_1, CONF_SIDE_6): {CONF_EVENT: 6001}, + (CONF_ROTATE_FROM_SIDE_2, CONF_SIDE_1): {CONF_EVENT: 1002}, + (CONF_ROTATE_FROM_SIDE_2, CONF_SIDE_3): {CONF_EVENT: 3002}, + (CONF_ROTATE_FROM_SIDE_2, CONF_SIDE_4): {CONF_EVENT: 4002}, + (CONF_ROTATE_FROM_SIDE_2, CONF_SIDE_5): {CONF_EVENT: 5002}, + (CONF_ROTATE_FROM_SIDE_2, CONF_SIDE_6): {CONF_EVENT: 6002}, + (CONF_ROTATE_FROM_SIDE_3, CONF_SIDE_1): {CONF_EVENT: 1003}, + (CONF_ROTATE_FROM_SIDE_3, CONF_SIDE_2): {CONF_EVENT: 2003}, + (CONF_ROTATE_FROM_SIDE_3, CONF_SIDE_4): {CONF_EVENT: 4003}, + (CONF_ROTATE_FROM_SIDE_3, CONF_SIDE_5): {CONF_EVENT: 5003}, + (CONF_ROTATE_FROM_SIDE_3, CONF_SIDE_6): {CONF_EVENT: 6003}, + (CONF_ROTATE_FROM_SIDE_4, CONF_SIDE_1): {CONF_EVENT: 1004}, + (CONF_ROTATE_FROM_SIDE_4, CONF_SIDE_2): {CONF_EVENT: 2004}, + (CONF_ROTATE_FROM_SIDE_4, CONF_SIDE_3): {CONF_EVENT: 3004}, + (CONF_ROTATE_FROM_SIDE_4, CONF_SIDE_5): {CONF_EVENT: 5004}, + (CONF_ROTATE_FROM_SIDE_4, CONF_SIDE_6): {CONF_EVENT: 6004}, + (CONF_ROTATE_FROM_SIDE_5, CONF_SIDE_1): {CONF_EVENT: 1005}, + (CONF_ROTATE_FROM_SIDE_5, CONF_SIDE_2): {CONF_EVENT: 2005}, + (CONF_ROTATE_FROM_SIDE_5, CONF_SIDE_3): {CONF_EVENT: 3005}, + (CONF_ROTATE_FROM_SIDE_5, CONF_SIDE_4): {CONF_EVENT: 4005}, + (CONF_ROTATE_FROM_SIDE_5, CONF_SIDE_6): {CONF_EVENT: 6005}, + (CONF_ROTATE_FROM_SIDE_6, CONF_SIDE_1): {CONF_EVENT: 1006}, + (CONF_ROTATE_FROM_SIDE_6, CONF_SIDE_2): {CONF_EVENT: 2006}, + (CONF_ROTATE_FROM_SIDE_6, CONF_SIDE_3): {CONF_EVENT: 3006}, + (CONF_ROTATE_FROM_SIDE_6, CONF_SIDE_4): {CONF_EVENT: 4006}, + (CONF_ROTATE_FROM_SIDE_6, CONF_SIDE_5): {CONF_EVENT: 5006}, + (CONF_MOVE, CONF_SIDE_1): {CONF_EVENT: 1000}, + (CONF_MOVE, CONF_SIDE_2): {CONF_EVENT: 2000}, + (CONF_MOVE, CONF_SIDE_3): {CONF_EVENT: 3000}, + (CONF_MOVE, CONF_SIDE_4): {CONF_EVENT: 4000}, + (CONF_MOVE, CONF_SIDE_5): {CONF_EVENT: 5000}, + (CONF_MOVE, CONF_SIDE_6): {CONF_EVENT: 6000}, + (CONF_DOUBLE_TAP, CONF_SIDE_1): {CONF_EVENT: 1001}, + (CONF_DOUBLE_TAP, CONF_SIDE_2): {CONF_EVENT: 2002}, + (CONF_DOUBLE_TAP, CONF_SIDE_3): {CONF_EVENT: 3003}, + (CONF_DOUBLE_TAP, CONF_SIDE_4): {CONF_EVENT: 4004}, + (CONF_DOUBLE_TAP, CONF_SIDE_5): {CONF_EVENT: 5005}, + (CONF_DOUBLE_TAP, CONF_SIDE_6): {CONF_EVENT: 6006}, + (CONF_AWAKE, ""): {CONF_GESTURE: 0}, + (CONF_SHAKE, ""): {CONF_GESTURE: 1}, + (CONF_FREE_FALL, ""): {CONF_GESTURE: 2}, + (CONF_FLIP_90, ""): {CONF_GESTURE: 3}, + (CONF_FLIP_180, ""): {CONF_GESTURE: 4}, + (CONF_MOVE_ANY, ""): {CONF_GESTURE: 5}, + (CONF_DOUBLE_TAP_ANY, ""): {CONF_GESTURE: 6}, + (CONF_TURN_CW, ""): {CONF_GESTURE: 7}, + (CONF_TURN_CCW, ""): {CONF_GESTURE: 8}, +} + +AQARA_DOUBLE_WALL_SWITCH_MODEL = "lumi.remote.b286acn01" +AQARA_DOUBLE_WALL_SWITCH = { + (CONF_SHORT_PRESS, CONF_LEFT): {CONF_EVENT: 1002}, + (CONF_LONG_PRESS, CONF_LEFT): {CONF_EVENT: 1001}, + (CONF_DOUBLE_PRESS, CONF_LEFT): {CONF_EVENT: 1004}, + (CONF_SHORT_PRESS, CONF_RIGHT): {CONF_EVENT: 2002}, + (CONF_LONG_PRESS, CONF_RIGHT): {CONF_EVENT: 2001}, + (CONF_DOUBLE_PRESS, CONF_RIGHT): {CONF_EVENT: 2004}, + (CONF_SHORT_PRESS, CONF_BOTH_BUTTONS): {CONF_EVENT: 3002}, + (CONF_LONG_PRESS, CONF_BOTH_BUTTONS): {CONF_EVENT: 3001}, + (CONF_DOUBLE_PRESS, CONF_BOTH_BUTTONS): {CONF_EVENT: 3004}, +} + +AQARA_DOUBLE_WALL_SWITCH_WXKG02LM_MODEL = "lumi.sensor_86sw2" +AQARA_DOUBLE_WALL_SWITCH_WXKG02LM = { + (CONF_SHORT_PRESS, CONF_LEFT): {CONF_EVENT: 1002}, + (CONF_SHORT_PRESS, CONF_RIGHT): {CONF_EVENT: 2002}, + (CONF_SHORT_PRESS, CONF_BOTH_BUTTONS): {CONF_EVENT: 3002}, +} + +AQARA_SINGLE_WALL_SWITCH_WXKG03LM_MODEL = "lumi.remote.b186acn01" +AQARA_SINGLE_WALL_SWITCH_WXKG03LM = { + (CONF_SHORT_PRESS, CONF_TURN_ON): {CONF_EVENT: 1002}, + (CONF_LONG_PRESS, CONF_TURN_ON): {CONF_EVENT: 1001}, + (CONF_DOUBLE_PRESS, CONF_TURN_ON): {CONF_EVENT: 1004}, +} + +AQARA_MINI_SWITCH_MODEL = "lumi.remote.b1acn01" +AQARA_MINI_SWITCH = { + (CONF_SHORT_PRESS, CONF_TURN_ON): {CONF_EVENT: 1002}, + (CONF_DOUBLE_PRESS, CONF_TURN_ON): {CONF_EVENT: 1004}, + (CONF_LONG_PRESS, CONF_TURN_ON): {CONF_EVENT: 1001}, + (CONF_LONG_RELEASE, CONF_TURN_ON): {CONF_EVENT: 1003}, +} + +AQARA_ROUND_SWITCH_MODEL = "lumi.sensor_switch" +AQARA_ROUND_SWITCH = { + (CONF_SHORT_PRESS, CONF_TURN_ON): {CONF_EVENT: 1000}, + (CONF_SHORT_RELEASE, CONF_TURN_ON): {CONF_EVENT: 1002}, + (CONF_DOUBLE_PRESS, CONF_TURN_ON): {CONF_EVENT: 1004}, + (CONF_TRIPLE_PRESS, CONF_TURN_ON): {CONF_EVENT: 1005}, + (CONF_QUADRUPLE_PRESS, CONF_TURN_ON): {CONF_EVENT: 1006}, + (CONF_QUINTUPLE_PRESS, CONF_TURN_ON): {CONF_EVENT: 1010}, + (CONF_LONG_PRESS, CONF_TURN_ON): {CONF_EVENT: 1001}, + (CONF_LONG_RELEASE, CONF_TURN_ON): {CONF_EVENT: 1003}, +} + +AQARA_SQUARE_SWITCH_MODEL = "lumi.sensor_switch.aq3" +AQARA_SQUARE_SWITCH = { + (CONF_SHORT_PRESS, CONF_TURN_ON): {CONF_EVENT: 1002}, + (CONF_DOUBLE_PRESS, CONF_TURN_ON): {CONF_EVENT: 1004}, + (CONF_LONG_PRESS, CONF_TURN_ON): {CONF_EVENT: 1001}, + (CONF_LONG_RELEASE, CONF_TURN_ON): {CONF_EVENT: 1003}, + (CONF_SHAKE, ""): {CONF_EVENT: 1007}, +} + +AQARA_SQUARE_SWITCH_WXKG11LM_2016_MODEL = "lumi.sensor_switch.aq2" +AQARA_SQUARE_SWITCH_WXKG11LM_2016 = { + (CONF_SHORT_PRESS, CONF_TURN_ON): {CONF_EVENT: 1002}, + (CONF_DOUBLE_PRESS, CONF_TURN_ON): {CONF_EVENT: 1004}, + (CONF_TRIPLE_PRESS, CONF_TURN_ON): {CONF_EVENT: 1005}, + (CONF_QUADRUPLE_PRESS, CONF_TURN_ON): {CONF_EVENT: 1006}, +} + +AQARA_OPPLE_2_BUTTONS_MODEL = "lumi.remote.b286opcn01" +AQARA_OPPLE_2_BUTTONS = { + (CONF_LONG_PRESS, CONF_TURN_OFF): {CONF_EVENT: 1001}, + (CONF_SHORT_RELEASE, CONF_TURN_OFF): {CONF_EVENT: 1002}, + (CONF_LONG_RELEASE, CONF_TURN_OFF): {CONF_EVENT: 1003}, + (CONF_DOUBLE_PRESS, CONF_TURN_OFF): {CONF_EVENT: 1004}, + (CONF_TRIPLE_PRESS, CONF_TURN_OFF): {CONF_EVENT: 1005}, + (CONF_LONG_PRESS, CONF_TURN_ON): {CONF_EVENT: 2001}, + (CONF_SHORT_RELEASE, CONF_TURN_ON): {CONF_EVENT: 2002}, + (CONF_LONG_RELEASE, CONF_TURN_ON): {CONF_EVENT: 2003}, + (CONF_DOUBLE_PRESS, CONF_TURN_ON): {CONF_EVENT: 2004}, + (CONF_TRIPLE_PRESS, CONF_TURN_ON): {CONF_EVENT: 2005}, +} + +AQARA_OPPLE_4_BUTTONS_MODEL = "lumi.remote.b486opcn01" +AQARA_OPPLE_4_BUTTONS = { + **AQARA_OPPLE_2_BUTTONS, + (CONF_LONG_PRESS, CONF_DIM_DOWN): {CONF_EVENT: 3001}, + (CONF_SHORT_RELEASE, CONF_DIM_DOWN): {CONF_EVENT: 3002}, + (CONF_LONG_RELEASE, CONF_DIM_DOWN): {CONF_EVENT: 3003}, + (CONF_DOUBLE_PRESS, CONF_DIM_DOWN): {CONF_EVENT: 3004}, + (CONF_TRIPLE_PRESS, CONF_DIM_DOWN): {CONF_EVENT: 3005}, + (CONF_LONG_PRESS, CONF_DIM_UP): {CONF_EVENT: 4001}, + (CONF_SHORT_RELEASE, CONF_DIM_UP): {CONF_EVENT: 4002}, + (CONF_LONG_RELEASE, CONF_DIM_UP): {CONF_EVENT: 4003}, + (CONF_DOUBLE_PRESS, CONF_DIM_UP): {CONF_EVENT: 4004}, + (CONF_TRIPLE_PRESS, CONF_DIM_UP): {CONF_EVENT: 4005}, +} + +AQARA_OPPLE_6_BUTTONS_MODEL = "lumi.remote.b686opcn01" +AQARA_OPPLE_6_BUTTONS = { + **AQARA_OPPLE_4_BUTTONS, + (CONF_LONG_PRESS, CONF_DIM_DOWN): {CONF_EVENT: 5001}, + (CONF_SHORT_RELEASE, CONF_LEFT): {CONF_EVENT: 5002}, + (CONF_LONG_RELEASE, CONF_LEFT): {CONF_EVENT: 5003}, + (CONF_DOUBLE_PRESS, CONF_LEFT): {CONF_EVENT: 5004}, + (CONF_TRIPLE_PRESS, CONF_LEFT): {CONF_EVENT: 5005}, + (CONF_LONG_PRESS, CONF_RIGHT): {CONF_EVENT: 6001}, + (CONF_SHORT_RELEASE, CONF_RIGHT): {CONF_EVENT: 6002}, + (CONF_LONG_RELEASE, CONF_RIGHT): {CONF_EVENT: 6003}, + (CONF_DOUBLE_PRESS, CONF_RIGHT): {CONF_EVENT: 6004}, + (CONF_TRIPLE_PRESS, CONF_RIGHT): {CONF_EVENT: 6005}, +} + +REMOTES = { + HUE_DIMMER_REMOTE_MODEL_GEN1: HUE_DIMMER_REMOTE, + HUE_DIMMER_REMOTE_MODEL_GEN2: HUE_DIMMER_REMOTE, + HUE_TAP_REMOTE_MODEL: HUE_TAP_REMOTE, + FRIENDS_OF_HUE_SWITCH_MODEL: FRIENDS_OF_HUE_SWITCH, + SYMFONISK_SOUND_CONTROLLER_MODEL: SYMFONISK_SOUND_CONTROLLER, + TRADFRI_ON_OFF_SWITCH_MODEL: TRADFRI_ON_OFF_SWITCH, + TRADFRI_OPEN_CLOSE_REMOTE_MODEL: TRADFRI_OPEN_CLOSE_REMOTE, + TRADFRI_REMOTE_MODEL: TRADFRI_REMOTE, + TRADFRI_WIRELESS_DIMMER_MODEL: TRADFRI_WIRELESS_DIMMER, + AQARA_CUBE_MODEL: AQARA_CUBE, + AQARA_CUBE_MODEL_ALT1: AQARA_CUBE, + AQARA_DOUBLE_WALL_SWITCH_MODEL: AQARA_DOUBLE_WALL_SWITCH, + AQARA_DOUBLE_WALL_SWITCH_WXKG02LM_MODEL: AQARA_DOUBLE_WALL_SWITCH_WXKG02LM, + AQARA_SINGLE_WALL_SWITCH_WXKG03LM_MODEL: AQARA_SINGLE_WALL_SWITCH_WXKG03LM, + AQARA_MINI_SWITCH_MODEL: AQARA_MINI_SWITCH, + AQARA_ROUND_SWITCH_MODEL: AQARA_ROUND_SWITCH, + AQARA_SQUARE_SWITCH_MODEL: AQARA_SQUARE_SWITCH, + AQARA_SQUARE_SWITCH_WXKG11LM_2016_MODEL: AQARA_SQUARE_SWITCH_WXKG11LM_2016, + AQARA_OPPLE_2_BUTTONS_MODEL: AQARA_OPPLE_2_BUTTONS, + AQARA_OPPLE_4_BUTTONS_MODEL: AQARA_OPPLE_4_BUTTONS, + AQARA_OPPLE_6_BUTTONS_MODEL: AQARA_OPPLE_6_BUTTONS, +} + +TRIGGER_SCHEMA = TRIGGER_BASE_SCHEMA.extend( + {vol.Required(CONF_TYPE): str, vol.Required(CONF_SUBTYPE): str} +) + + +def _get_deconz_event_from_device_id(hass, device_id): + """Resolve deconz event from device id.""" + for gateway in hass.data.get(DOMAIN, {}).values(): + + for deconz_event in gateway.events: + + if device_id == deconz_event.device_id: + return deconz_event + + return None + + +async def async_validate_trigger_config(hass, config): + """Validate config.""" + config = TRIGGER_SCHEMA(config) + + device_registry = await hass.helpers.device_registry.async_get_registry() + device = device_registry.async_get(config[CONF_DEVICE_ID]) + + trigger = (config[CONF_TYPE], config[CONF_SUBTYPE]) + + if ( + not device + or device.model not in REMOTES + or trigger not in REMOTES[device.model] + ): + raise InvalidDeviceAutomationConfig + + return config + + +async def async_attach_trigger(hass, config, action, automation_info): + """Listen for state changes based on configuration.""" + device_registry = await hass.helpers.device_registry.async_get_registry() + device = device_registry.async_get(config[CONF_DEVICE_ID]) + + trigger = (config[CONF_TYPE], config[CONF_SUBTYPE]) + + trigger = REMOTES[device.model][trigger] + + deconz_event = _get_deconz_event_from_device_id(hass, device.id) + if deconz_event is None: + raise InvalidDeviceAutomationConfig + + event_id = deconz_event.serial + + event_config = { + event.CONF_PLATFORM: "event", + event.CONF_EVENT_TYPE: CONF_DECONZ_EVENT, + event.CONF_EVENT_DATA: {CONF_UNIQUE_ID: event_id, **trigger}, + } + + event_config = event.TRIGGER_SCHEMA(event_config) + return await event.async_attach_trigger( + hass, event_config, action, automation_info, platform_type="device" + ) + + +async def async_get_triggers(hass, device_id): + """List device triggers. + + Make sure device is a supported remote model. + Retrieve the deconz event object matching device entry. + Generate device trigger list. + """ + device_registry = await hass.helpers.device_registry.async_get_registry() + device = device_registry.async_get(device_id) + + if device.model not in REMOTES: + return + + triggers = [] + for trigger, subtype in REMOTES[device.model].keys(): + triggers.append( + { + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_PLATFORM: "device", + CONF_TYPE: trigger, + CONF_SUBTYPE: subtype, + } + ) + + return triggers diff --git a/homeassistant/components/deconz/gateway.py b/homeassistant/components/deconz/gateway.py index 46078ea6648e5..eb83f5c15c5d9 100644 --- a/homeassistant/components/deconz/gateway.py +++ b/homeassistant/components/deconz/gateway.py @@ -1,46 +1,60 @@ """Representation of a deCONZ gateway.""" import asyncio + import async_timeout +from pydeconz import DeconzSession, errors +from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT +from homeassistant.core import callback 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 homeassistant.helpers.dispatcher import async_dispatcher_send from .const import ( - _LOGGER, CONF_ALLOW_CLIP_SENSOR, CONF_ALLOW_DECONZ_GROUPS, CONF_BRIDGEID, - CONF_MASTER_GATEWAY, DOMAIN, NEW_DEVICE, NEW_SENSOR, SUPPORTED_PLATFORMS) + CONF_ALLOW_CLIP_SENSOR, + CONF_ALLOW_DECONZ_GROUPS, + CONF_MASTER_GATEWAY, + DEFAULT_ALLOW_CLIP_SENSOR, + DEFAULT_ALLOW_DECONZ_GROUPS, + DOMAIN, + LOGGER, + NEW_GROUP, + NEW_LIGHT, + NEW_SCENE, + 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]] + return hass.data[DOMAIN].get(config_entry.unique_id) class DeconzGateway: """Manages a single deCONZ gateway.""" - def __init__(self, hass, config_entry): + def __init__(self, hass, config_entry) -> None: """Initialize the system.""" self.hass = hass self.config_entry = config_entry + self.available = True self.api = None - self.deconz_ids = {} self.events = [] self.listeners = [] + self._current_option_allow_clip_sensor = self.option_allow_clip_sensor + self._current_option_allow_deconz_groups = self.option_allow_deconz_groups + @property def bridgeid(self) -> str: """Return the unique identifier of the gateway.""" - return self.config_entry.data[CONF_BRIDGEID] + return self.config_entry.unique_id @property def master(self) -> bool: @@ -48,112 +62,157 @@ def master(self) -> bool: return self.config_entry.options[CONF_MASTER_GATEWAY] @property - def allow_clip_sensor(self) -> bool: + def option_allow_clip_sensor(self) -> bool: """Allow loading clip sensor from gateway.""" - return self.config_entry.data.get(CONF_ALLOW_CLIP_SENSOR, True) + return self.config_entry.options.get( + CONF_ALLOW_CLIP_SENSOR, DEFAULT_ALLOW_CLIP_SENSOR + ) @property - def allow_deconz_groups(self) -> bool: + def option_allow_deconz_groups(self) -> bool: """Allow loading deCONZ groups from gateway.""" - return self.config_entry.data.get(CONF_ALLOW_DECONZ_GROUPS, True) + return self.config_entry.options.get( + CONF_ALLOW_DECONZ_GROUPS, DEFAULT_ALLOW_DECONZ_GROUPS + ) - async def async_update_device_registry(self): + async def async_update_device_registry(self) -> None: """Update device registry.""" - device_registry = await \ - self.hass.helpers.device_registry.async_get_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', + manufacturer="Dresden Elektronik", model=self.api.config.modelid, name=self.api.config.name, - sw_version=self.api.config.swversion + sw_version=self.api.config.swversion, ) - async def async_setup(self): + async def async_setup(self) -> bool: """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 + self.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') + except Exception as err: # pylint: disable=broad-except + LOGGER.error("Error connecting with deCONZ gateway: %s", err) 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.hass.async_create_task( + self.hass.config_entries.async_forward_entry_setup( + self.config_entry, component + ) + ) self.api.start() - self.config_entry.add_update_listener(self.async_new_address_callback) + self.config_entry.add_update_listener(self.async_config_entry_updated) return True @staticmethod - async def async_new_address_callback(hass, entry): - """Handle signals of gateway getting new address. + async def async_config_entry_updated(hass, entry) -> None: + """Handle signals of config entry being updated. - This is a static method because a class method (bound method), - can not be used with weak references. + This is a static method because a class method (bound method), can not be used with weak references. + Causes for this is either discovery updating host address or config entry options changing. """ - gateway = hass.data[DOMAIN][entry.data[CONF_BRIDGEID]] - gateway.api.close() - gateway.api.host = entry.data[CONF_HOST] - gateway.api.start() + gateway = get_gateway_from_config_entry(hass, entry) + if not gateway: + return + if gateway.api.host != entry.data[CONF_HOST]: + gateway.api.close() + gateway.api.host = entry.data[CONF_HOST] + gateway.api.start() + return + + await gateway.options_updated() + + async def options_updated(self): + """Manage entities affected by config entry options.""" + deconz_ids = [] + + if self._current_option_allow_clip_sensor != self.option_allow_clip_sensor: + self._current_option_allow_clip_sensor = self.option_allow_clip_sensor + + sensors = [ + sensor + for sensor in self.api.sensors.values() + if sensor.type.startswith("CLIP") + ] + + if self.option_allow_clip_sensor: + self.async_add_device_callback(NEW_SENSOR, sensors) + else: + deconz_ids += [sensor.deconz_id for sensor in sensors] + + if self._current_option_allow_deconz_groups != self.option_allow_deconz_groups: + self._current_option_allow_deconz_groups = self.option_allow_deconz_groups + + groups = list(self.api.groups.values()) + + if self.option_allow_deconz_groups: + self.async_add_device_callback(NEW_GROUP, groups) + else: + deconz_ids += [group.deconz_id for group in groups] + + if deconz_ids: + async_dispatcher_send(self.hass, self.signal_remove_entity, deconz_ids) + + entity_registry = await self.hass.helpers.entity_registry.async_get_registry() + + for entity_id, deconz_id in self.deconz_ids.items(): + if deconz_id in deconz_ids and entity_registry.async_is_registered( + entity_id + ): + entity_registry.async_remove(entity_id) @property - def event_reachable(self): + def signal_reachable(self) -> str: """Gateway specific event to signal a change in connection status.""" - return 'deconz_reachable_{}'.format(self.bridgeid) + return f"deconz-reachable-{self.bridgeid}" @callback - def async_connection_status_callback(self, available): + def async_connection_status_callback(self, available) -> None: """Handle signals of gateway connection status.""" self.available = available - async_dispatcher_send(self.hass, self.event_reachable, - {'state': True, 'attr': 'reachable'}) + async_dispatcher_send(self.hass, self.signal_reachable, True) @callback - def async_event_new_device(self, device_type): + def async_signal_new_device(self, device_type) -> str: """Gateway specific event to signal new device.""" - return NEW_DEVICE[device_type].format(self.bridgeid) + new_device = { + NEW_GROUP: f"deconz_new_group_{self.bridgeid}", + NEW_LIGHT: f"deconz_new_light_{self.bridgeid}", + NEW_SCENE: f"deconz_new_scene_{self.bridgeid}", + NEW_SENSOR: f"deconz_new_sensor_{self.bridgeid}", + } + return new_device[device_type] + + @property + def signal_remove_entity(self) -> str: + """Gateway specific event to signal removal of entity.""" + return f"deconz-remove-{self.bridgeid}" @callback - def async_add_device_callback(self, device_type, device): + def async_add_device_callback(self, device_type, device) -> None: """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.""" - from pydeconz.sensor import SWITCH as DECONZ_REMOTE - for sensor in sensors: - if sensor.type in DECONZ_REMOTE and \ - not (not self.allow_clip_sensor and - sensor.type.startswith('CLIP')): - self.events.append(DeconzEvent(self.hass, sensor)) + self.hass, self.async_signal_new_device(device_type), device + ) @callback - def shutdown(self, event): + def shutdown(self, event) -> None: """Wrap the call to deconz.close. Used as an argument to EventBus.async_listen_once. @@ -161,16 +220,14 @@ def shutdown(self, event): 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. - """ + """Reset this gateway to default state.""" + self.api.async_connection_status_callback = None self.api.close() for component in SUPPORTED_PLATFORMS: await self.hass.config_entries.async_forward_entry_unload( - self.config_entry, component) + self.config_entry, component + ) for unsub_dispatcher in self.listeners: unsub_dispatcher() @@ -178,62 +235,35 @@ async def async_reset(self): for event in self.events: event.async_will_remove_from_hass() - self.events.remove(event) + self.events.clear() self.deconz_ids = {} return True -async def get_gateway(hass, config, async_add_device_callback, - async_connection_status_callback): +async def get_gateway( + hass, config, async_add_device_callback, async_connection_status_callback +) -> DeconzSession: """Create a gateway object and verify configuration.""" - from pydeconz import DeconzSession, errors - 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) + deconz = DeconzSession( + session, + config[CONF_HOST], + config[CONF_PORT], + config[CONF_API_KEY], + async_add_device=async_add_device_callback, + connection_status=async_connection_status_callback, + ) try: with async_timeout.timeout(10): - await deconz.async_load_parameters() + await deconz.initialize() return deconz except errors.Unauthorized: - _LOGGER.warning("Invalid key for deCONZ at %s", config[CONF_HOST]) + 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]) + 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, reason): - """Fire the event if reason is that state is updated.""" - if reason['state']: - 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 index c195703c36ac8..48d286266e47a 100644 --- a/homeassistant/components/deconz/light.py +++ b/homeassistant/components/deconz/light.py @@ -1,22 +1,40 @@ """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) + 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, + LightEntity, +) 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 .const import ( + CONF_GROUP_ID_BASE, + COVER_TYPES, + DOMAIN as DECONZ_DOMAIN, + 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 lights and group.""" - pass +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Old way of setting up deCONZ platforms.""" async def async_setup_entry(hass, config_entry, async_add_entities): @@ -34,37 +52,49 @@ def async_add_light(lights): async_add_entities(entities, True) - gateway.listeners.append(async_dispatcher_connect( - hass, gateway.async_event_new_device(NEW_LIGHT), async_add_light)) + gateway.listeners.append( + async_dispatcher_connect( + hass, gateway.async_signal_new_device(NEW_LIGHT), async_add_light + ) + ) @callback def async_add_group(groups): """Add group from deCONZ.""" + if not gateway.option_allow_deconz_groups: + return + entities = [] for group in groups: - if group.lights and gateway.allow_deconz_groups: - entities.append(DeconzLight(group, gateway)) + if group.lights: + entities.append(DeconzGroup(group, gateway)) async_add_entities(entities, True) - gateway.listeners.append(async_dispatcher_connect( - hass, gateway.async_event_new_device(NEW_GROUP), async_add_group)) + gateway.listeners.append( + async_dispatcher_connect( + hass, gateway.async_signal_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): +class DeconzLight(DeconzDevice, LightEntity): """Representation of a deCONZ light.""" def __init__(self, device, gateway): - """Set up light and add update callback to get data from websocket.""" + """Set up light.""" super().__init__(device, gateway) - self._features = SUPPORT_BRIGHTNESS - self._features |= SUPPORT_FLASH - self._features |= SUPPORT_TRANSITION + self._features = 0 + + if self._device.brightness is not None: + self._features |= SUPPORT_BRIGHTNESS + self._features |= SUPPORT_FLASH + self._features |= SUPPORT_TRANSITION if self._device.ct is not None: self._features |= SUPPORT_COLOR_TEMP @@ -88,7 +118,7 @@ def effect_list(self): @property def color_temp(self): """Return the CT color value.""" - if self._device.colormode != 'ct': + if self._device.colormode != "ct": return None return self._device.ct @@ -96,7 +126,7 @@ def color_temp(self): @property def hs_color(self): """Return the hs color value.""" - if self._device.colormode in ('xy', 'hs') and self._device.xy: + if self._device.colormode in ("xy", "hs") and self._device.xy: return color_util.color_xy_to_hs(*self._device.xy) return None @@ -112,51 +142,53 @@ def supported_features(self): async def async_turn_on(self, **kwargs): """Turn on light.""" - data = {'on': True} + data = {"on": True} if ATTR_COLOR_TEMP in kwargs: - data['ct'] = kwargs[ATTR_COLOR_TEMP] + data["ct"] = kwargs[ATTR_COLOR_TEMP] if ATTR_HS_COLOR in kwargs: - data['xy'] = color_util.color_hs_to_xy(*kwargs[ATTR_HS_COLOR]) + data["xy"] = color_util.color_hs_to_xy(*kwargs[ATTR_HS_COLOR]) if ATTR_BRIGHTNESS in kwargs: - data['bri'] = kwargs[ATTR_BRIGHTNESS] + data["bri"] = kwargs[ATTR_BRIGHTNESS] if ATTR_TRANSITION in kwargs: - data['transitiontime'] = int(kwargs[ATTR_TRANSITION] * 10) + data["transitiontime"] = int(kwargs[ATTR_TRANSITION] * 10) + elif "IKEA" in self._device.manufacturer: + data["transitiontime"] = 0 if ATTR_FLASH in kwargs: if kwargs[ATTR_FLASH] == FLASH_SHORT: - data['alert'] = 'select' - del data['on'] + data["alert"] = "select" + del data["on"] elif kwargs[ATTR_FLASH] == FLASH_LONG: - data['alert'] = 'lselect' - del data['on'] + data["alert"] = "lselect" + del data["on"] if ATTR_EFFECT in kwargs: if kwargs[ATTR_EFFECT] == EFFECT_COLORLOOP: - data['effect'] = 'colorloop' + data["effect"] = "colorloop" else: - data['effect'] = 'none' + data["effect"] = "none" await self._device.async_set_state(data) async def async_turn_off(self, **kwargs): """Turn off light.""" - data = {'on': False} + data = {"on": False} if ATTR_TRANSITION in kwargs: - data['bri'] = 0 - data['transitiontime'] = int(kwargs[ATTR_TRANSITION] * 10) + 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'] + data["alert"] = "select" + del data["on"] elif kwargs[ATTR_FLASH] == FLASH_LONG: - data['alert'] = 'lselect' - del data['on'] + data["alert"] = "lselect" + del data["on"] await self._device.async_set_state(data) @@ -164,9 +196,46 @@ async def async_turn_off(self, **kwargs): def device_state_attributes(self): """Return the device state attributes.""" attributes = {} - attributes['is_deconz_group'] = self._device.type == 'LightGroup' + attributes["is_deconz_group"] = self._device.type == "LightGroup" + + return attributes + + +class DeconzGroup(DeconzLight): + """Representation of a deCONZ group.""" + + def __init__(self, device, gateway): + """Set up group and create an unique id.""" + super().__init__(device, gateway) - if self._device.type == 'LightGroup': - attributes['all_on'] = self._device.all_on + group_id_base = self.gateway.config_entry.unique_id + if CONF_GROUP_ID_BASE in self.gateway.config_entry.data: + group_id_base = self.gateway.config_entry.data[CONF_GROUP_ID_BASE] + + self._unique_id = f"{group_id_base}-{self._device.deconz_id}" + + @property + def unique_id(self): + """Return a unique identifier for this device.""" + return self._unique_id + + @property + def device_info(self): + """Return a device description for device registry.""" + bridgeid = self.gateway.api.config.bridgeid + + return { + "identifiers": {(DECONZ_DOMAIN, self.unique_id)}, + "manufacturer": "Dresden Elektronik", + "model": "deCONZ group", + "name": self._device.name, + "via_device": (DECONZ_DOMAIN, bridgeid), + } + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + attributes = dict(super().device_state_attributes) + attributes["all_on"] = self._device.all_on return attributes diff --git a/homeassistant/components/deconz/manifest.json b/homeassistant/components/deconz/manifest.json index 22947d40fb141..5ff4a303b0c35 100644 --- a/homeassistant/components/deconz/manifest.json +++ b/homeassistant/components/deconz/manifest.json @@ -1,12 +1,14 @@ { "domain": "deconz", - "name": "Deconz", - "documentation": "https://www.home-assistant.io/components/deconz", - "requirements": [ - "pydeconz==58" + "name": "deCONZ", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/deconz", + "requirements": ["pydeconz==70"], + "ssdp": [ + { + "manufacturer": "Royal Philips Electronics" + } ], - "dependencies": [], - "codeowners": [ - "@kane610" - ] + "codeowners": ["@kane610"], + "quality_scale": "platinum" } diff --git a/homeassistant/components/deconz/scene.py b/homeassistant/components/deconz/scene.py index d2e7f6719e915..fdeb1d43accad 100644 --- a/homeassistant/components/deconz/scene.py +++ b/homeassistant/components/deconz/scene.py @@ -1,4 +1,6 @@ """Support for deCONZ scenes.""" +from typing import Any + from homeassistant.components.scene import Scene from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -7,10 +9,8 @@ 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 scenes.""" - pass +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Old way of setting up deCONZ platforms.""" async def async_setup_entry(hass, config_entry, async_add_entities): @@ -20,15 +20,15 @@ async def async_setup_entry(hass, config_entry, async_add_entities): @callback def async_add_scene(scenes): """Add scene from deCONZ.""" - entities = [] - - for scene in scenes: - entities.append(DeconzScene(scene, gateway)) + entities = [DeconzScene(scene, gateway) for scene in scenes] async_add_entities(entities) - gateway.listeners.append(async_dispatcher_connect( - hass, gateway.async_event_new_device(NEW_SCENE), async_add_scene)) + gateway.listeners.append( + async_dispatcher_connect( + hass, gateway.async_signal_new_device(NEW_SCENE), async_add_scene + ) + ) async_add_scene(gateway.api.scenes.values()) @@ -47,9 +47,10 @@ async def async_added_to_hass(self): async def async_will_remove_from_hass(self) -> None: """Disconnect scene object when removed.""" + del self.gateway.deconz_ids[self.entity_id] self._scene = None - async def async_activate(self): + async def async_activate(self, **kwargs: Any) -> None: """Activate the scene.""" await self._scene.async_set_state({}) diff --git a/homeassistant/components/deconz/sensor.py b/homeassistant/components/deconz/sensor.py index 9f1e87db4ba82..ae0e55ae51f48 100644 --- a/homeassistant/components/deconz/sensor.py +++ b/homeassistant/components/deconz/sensor.py @@ -1,72 +1,115 @@ """Support for deCONZ sensors.""" +from pydeconz.sensor import ( + Battery, + Consumption, + Daylight, + LightLevel, + Power, + Switch, + Thermostat, +) + from homeassistant.const import ( - ATTR_BATTERY_LEVEL, ATTR_VOLTAGE, DEVICE_CLASS_BATTERY) + ATTR_TEMPERATURE, + ATTR_VOLTAGE, + DEVICE_CLASS_BATTERY, + UNIT_PERCENTAGE, +) from homeassistant.core import callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.util import slugify +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) from .const import ATTR_DARK, ATTR_ON, NEW_SENSOR from .deconz_device import DeconzDevice +from .deconz_event import DeconzEvent from .gateway import get_gateway_from_config_entry -ATTR_CURRENT = 'current' -ATTR_DAYLIGHT = 'daylight' -ATTR_EVENT_ID = 'event_id' +ATTR_CURRENT = "current" +ATTR_POWER = "power" +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 sensors.""" - pass +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Old way of setting up deCONZ platforms.""" 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) + batteries = set() + battery_handler = DeconzBatteryHandler(gateway) + @callback - def async_add_sensor(sensors): - """Add sensors from deCONZ.""" - from pydeconz.sensor import ( - DECONZ_SENSOR, SWITCH as DECONZ_REMOTE) + def async_add_sensor(sensors, new=True): + """Add sensors from deCONZ. + + Create DeconzEvent if part of ZHAType list. + Create DeconzSensor if not a ZHAType and not a binary sensor. + Create DeconzBattery if sensor has a battery attribute. + If new is false it means an existing sensor has got a battery state reported. + """ entities = [] for sensor in sensors: - if sensor.type in DECONZ_SENSOR and \ - not (not gateway.allow_clip_sensor and - sensor.type.startswith('CLIP')): - - if sensor.type in DECONZ_REMOTE: - if sensor.battery: - entities.append(DeconzBattery(sensor, gateway)) - - else: - entities.append(DeconzSensor(sensor, gateway)) + if new and sensor.type in Switch.ZHATYPE: + + if gateway.option_allow_clip_sensor or not sensor.type.startswith( + "CLIP" + ): + new_event = DeconzEvent(sensor, gateway) + hass.async_create_task(new_event.async_update_device_registry()) + gateway.events.append(new_event) + + elif ( + new + and sensor.BINARY is False + and sensor.type not in Battery.ZHATYPE + Thermostat.ZHATYPE + and ( + gateway.option_allow_clip_sensor + or not sensor.type.startswith("CLIP") + ) + ): + entities.append(DeconzSensor(sensor, gateway)) + + if sensor.battery is not None: + new_battery = DeconzBattery(sensor, gateway) + if new_battery.unique_id not in batteries: + batteries.add(new_battery.unique_id) + entities.append(new_battery) + battery_handler.remove_tracker(sensor) + else: + battery_handler.create_tracker(sensor) async_add_entities(entities, True) - gateway.listeners.append(async_dispatcher_connect( - hass, gateway.async_event_new_device(NEW_SENSOR), async_add_sensor)) + gateway.listeners.append( + async_dispatcher_connect( + hass, gateway.async_signal_new_device(NEW_SENSOR), async_add_sensor + ) + ) - async_add_sensor(gateway.api.sensors.values()) + async_add_sensor( + [gateway.api.sensors[key] for key in sorted(gateway.api.sensors, key=int)] + ) class DeconzSensor(DeconzDevice): """Representation of a deCONZ sensor.""" @callback - def async_update_callback(self, reason): - """Update the sensor's state. + def async_update_callback(self, force_update=False, ignore_update=False): + """Update the sensor's state.""" + if ignore_update: + return - If reason is that state is updated, - or reachable has changed or battery has changed. - """ - if reason['state'] or \ - 'reachable' in reason['attr'] or \ - 'battery' in reason['attr'] or \ - 'on' in reason['attr']: - self.async_schedule_update_ha_state() + keys = {"on", "reachable", "state"} + if force_update or self._device.changed_keys.intersection(keys): + self.async_write_ha_state() @property def state(self): @@ -76,52 +119,67 @@ def state(self): @property def device_class(self): """Return the class of the sensor.""" - return self._device.sensor_class + return self._device.SENSOR_CLASS @property def icon(self): """Return the icon to use in the frontend.""" - return self._device.sensor_icon + return self._device.SENSOR_ICON @property def unit_of_measurement(self): """Return the unit of measurement of this sensor.""" - return self._device.sensor_unit + return self._device.SENSOR_UNIT @property def device_state_attributes(self): """Return the state attributes of the sensor.""" - from pydeconz.sensor import LIGHTLEVEL 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.type in LIGHTLEVEL and self._device.dark is not None: - attr[ATTR_DARK] = self._device.dark - if self.unit_of_measurement == 'Watts': + + if self._device.secondary_temperature is not None: + attr[ATTR_TEMPERATURE] = self._device.secondary_temperature + + if self._device.type in Consumption.ZHATYPE: + attr[ATTR_POWER] = self._device.power + + elif self._device.type in Daylight.ZHATYPE: + attr[ATTR_DAYLIGHT] = self._device.daylight + + elif self._device.type in LightLevel.ZHATYPE: + + if self._device.dark is not None: + attr[ATTR_DARK] = self._device.dark + + if self._device.daylight is not None: + attr[ATTR_DAYLIGHT] = self._device.daylight + + elif self._device.type in Power.ZHATYPE: 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, reason): + def async_update_callback(self, force_update=False, ignore_update=False): """Update the battery's state, if needed.""" - if 'reachable' in reason['attr'] or 'battery' in reason['attr']: - self.async_schedule_update_ha_state() + if ignore_update: + return + + keys = {"battery", "reachable"} + if force_update or self._device.changed_keys.intersection(keys): + self.async_write_ha_state() + + @property + def unique_id(self): + """Return a unique identifier for this device.""" + return f"{self.serial}-battery" @property def state(self): @@ -131,7 +189,7 @@ def state(self): @property def name(self): """Return the name of the battery.""" - return self._name + return f"{self._device.name} Battery Level" @property def device_class(self): @@ -141,12 +199,70 @@ def device_class(self): @property def unit_of_measurement(self): """Return the unit of measurement of this entity.""" - return self._unit_of_measurement + return UNIT_PERCENTAGE @property def device_state_attributes(self): """Return the state attributes of the battery.""" - attr = { - ATTR_EVENT_ID: slugify(self._device.name), - } + attr = {} + + if self._device.type in Switch.ZHATYPE: + for event in self.gateway.events: + if self._device == event.device: + attr[ATTR_EVENT_ID] = event.event_id + return attr + + +class DeconzSensorStateTracker: + """Track sensors without a battery state and signal when battery state exist.""" + + def __init__(self, sensor, gateway): + """Set up tracker.""" + self.sensor = sensor + self.gateway = gateway + sensor.register_callback(self.async_update_callback) + + @callback + def close(self): + """Clean up tracker.""" + self.sensor.remove_callback(self.async_update_callback) + self.gateway = None + self.sensor = None + + @callback + def async_update_callback(self, ignore_update=False): + """Sensor state updated.""" + if "battery" in self.sensor.changed_keys: + async_dispatcher_send( + self.gateway.hass, + self.gateway.async_signal_new_device(NEW_SENSOR), + [self.sensor], + False, + ) + + +class DeconzBatteryHandler: + """Creates and stores trackers for sensors without a battery state.""" + + def __init__(self, gateway): + """Set up battery handler.""" + self.gateway = gateway + self._trackers = set() + + @callback + def create_tracker(self, sensor): + """Create new tracker for battery state.""" + for tracker in self._trackers: + if sensor == tracker.sensor: + return + self._trackers.add(DeconzSensorStateTracker(sensor, self.gateway)) + + @callback + def remove_tracker(self, sensor): + """Remove tracker of battery state.""" + for tracker in self._trackers: + if sensor == tracker.sensor: + tracker.close() + self._trackers.remove(tracker) + break diff --git a/homeassistant/components/deconz/services.py b/homeassistant/components/deconz/services.py new file mode 100644 index 0000000000000..c85fa8073a335 --- /dev/null +++ b/homeassistant/components/deconz/services.py @@ -0,0 +1,166 @@ +"""deCONZ services.""" +from pydeconz.utils import normalize_bridge_id +import voluptuous as vol + +from homeassistant.helpers import config_validation as cv + +from .config_flow import get_master_gateway +from .const import ( + CONF_BRIDGE_ID, + DOMAIN, + LOGGER, + NEW_GROUP, + NEW_LIGHT, + NEW_SCENE, + NEW_SENSOR, +) + +DECONZ_SERVICES = "deconz_services" + +SERVICE_FIELD = "field" +SERVICE_ENTITY = "entity" +SERVICE_DATA = "data" + +SERVICE_CONFIGURE_DEVICE = "configure" +SERVICE_CONFIGURE_DEVICE_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_BRIDGE_ID): str, + } + ), + cv.has_at_least_one_key(SERVICE_ENTITY, SERVICE_FIELD), +) + +SERVICE_DEVICE_REFRESH = "device_refresh" +SERVICE_DEVICE_REFRESH_SCHEMA = vol.All(vol.Schema({vol.Optional(CONF_BRIDGE_ID): str})) + + +async def async_setup_services(hass): + """Set up services for deCONZ integration.""" + if hass.data.get(DECONZ_SERVICES, False): + return + + hass.data[DECONZ_SERVICES] = True + + async def async_call_deconz_service(service_call): + """Call correct deCONZ service.""" + service = service_call.service + service_data = service_call.data + + if service == SERVICE_CONFIGURE_DEVICE: + await async_configure_service(hass, service_data) + + elif service == SERVICE_DEVICE_REFRESH: + await async_refresh_devices_service(hass, service_data) + + hass.services.async_register( + DOMAIN, + SERVICE_CONFIGURE_DEVICE, + async_call_deconz_service, + schema=SERVICE_CONFIGURE_DEVICE_SCHEMA, + ) + + hass.services.async_register( + DOMAIN, + SERVICE_DEVICE_REFRESH, + async_call_deconz_service, + schema=SERVICE_DEVICE_REFRESH_SCHEMA, + ) + + +async def async_unload_services(hass): + """Unload deCONZ services.""" + if not hass.data.get(DECONZ_SERVICES): + return + + hass.data[DECONZ_SERVICES] = False + + hass.services.async_remove(DOMAIN, SERVICE_CONFIGURE_DEVICE) + hass.services.async_remove(DOMAIN, SERVICE_DEVICE_REFRESH) + + +async def async_configure_service(hass, data): + """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/ + """ + gateway = get_master_gateway(hass) + if CONF_BRIDGE_ID in data: + gateway = hass.data[DOMAIN][normalize_bridge_id(data[CONF_BRIDGE_ID])] + + field = data.get(SERVICE_FIELD, "") + entity_id = data.get(SERVICE_ENTITY) + data = data[SERVICE_DATA] + + 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.request("put", field, json=data) + + +async def async_refresh_devices_service(hass, data): + """Refresh available devices from deCONZ.""" + gateway = get_master_gateway(hass) + if CONF_BRIDGE_ID in data: + gateway = hass.data[DOMAIN][normalize_bridge_id(data[CONF_BRIDGE_ID])] + + 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.refresh_state(ignore_update=True) + + gateway.async_add_device_callback( + NEW_GROUP, + [ + group + for group_id, group in gateway.api.groups.items() + if group_id not in groups + ], + ) + + gateway.async_add_device_callback( + NEW_LIGHT, + [ + light + for light_id, light in gateway.api.lights.items() + if light_id not in lights + ], + ) + + gateway.async_add_device_callback( + NEW_SCENE, + [ + scene + for scene_id, scene in gateway.api.scenes.items() + if scene_id not in scenes + ], + ) + + gateway.async_add_device_callback( + NEW_SENSOR, + [ + sensor + for sensor_id, sensor in gateway.api.sensors.items() + if sensor_id not in sensors + ], + ) diff --git a/homeassistant/components/deconz/services.yaml b/homeassistant/components/deconz/services.yaml index 4d77101cf0dbf..d8bf3e4d9949f 100644 --- a/homeassistant/components/deconz/services.yaml +++ b/homeassistant/components/deconz/services.yaml @@ -1,9 +1,9 @@ configure: - description: Set attribute of device in deCONZ. See https://home-assistant.io/components/deconz/#device-services for details. + description: Set attribute of device in deCONZ. See https://home-assistant.io/integrations/deconz/#device-services for details. fields: entity: description: Entity id representing a specific device in deCONZ. - example: 'light.rgb_light' + example: "light.rgb_light" field: description: >- Field is a string representing a full path to deCONZ endpoint (when @@ -15,11 +15,11 @@ configure: 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' + 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' + example: "00212EFFFF012345" diff --git a/homeassistant/components/deconz/strings.json b/homeassistant/components/deconz/strings.json index 16177dbd3cc1d..2042c36c859e0 100644 --- a/homeassistant/components/deconz/strings.json +++ b/homeassistant/components/deconz/strings.json @@ -1,42 +1,101 @@ { - "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", - "no_bridges": "No deCONZ bridges discovered", - "updated_instance": "Updated deCONZ instance with new host address", - "one_instance_only": "Component only supports one deCONZ instance" + "config": { + "flow_title": "deCONZ Zigbee gateway ({host})", + "step": { + "user": { + "data": { + "host": "Select discovered deCONZ gateway" + } + }, + "manual_input": { + "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" + }, + "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}?" + } + }, + "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" + } + }, + "options": { + "step": { + "deconz_devices": { + "data": { + "allow_clip_sensor": "Allow deCONZ CLIP sensors", + "allow_deconz_groups": "Allow deCONZ light groups" + }, + "description": "Configure visibility of deCONZ device types", + "title": "deCONZ options" + } + } + }, + "device_automation": { + "trigger_type": { + "remote_button_short_press": "\"{subtype}\" button pressed", + "remote_button_short_release": "\"{subtype}\" button released", + "remote_button_long_press": "\"{subtype}\" button continuously pressed", + "remote_button_long_release": "\"{subtype}\" button released after long press", + "remote_button_double_press": "\"{subtype}\" button double clicked", + "remote_button_triple_press": "\"{subtype}\" button triple clicked", + "remote_button_quadruple_press": "\"{subtype}\" button quadruple clicked", + "remote_button_quintuple_press": "\"{subtype}\" button quintuple clicked", + "remote_button_rotated": "Button rotated \"{subtype}\"", + "remote_button_rotation_stopped": "Button rotation \"{subtype}\" stopped", + "remote_falling": "Device in free fall", + "remote_awakened": "Device awakened", + "remote_moved": "Device moved with \"{subtype}\" up", + "remote_double_tap": "Device \"{subtype}\" double tapped", + "remote_gyro_activated": "Device shaken", + "remote_flip_90_degrees": "Device flipped 90 degrees", + "remote_flip_180_degrees": "Device flipped 180 degrees", + "remote_moved_any_side": "Device moved with any side up", + "remote_double_tap_any_side": "Device double tapped on any side", + "remote_turned_clockwise": "Device turned clockwise", + "remote_turned_counter_clockwise": "Device turned counter clockwise", + "remote_rotate_from_side_1": "Device rotated from \"side 1\" to \"{subtype}\"", + "remote_rotate_from_side_2": "Device rotated from \"side 2\" to \"{subtype}\"", + "remote_rotate_from_side_3": "Device rotated from \"side 3\" to \"{subtype}\"", + "remote_rotate_from_side_4": "Device rotated from \"side 4\" to \"{subtype}\"", + "remote_rotate_from_side_5": "Device rotated from \"side 5\" to \"{subtype}\"", + "remote_rotate_from_side_6": "Device rotated from \"side 6\" to \"{subtype}\"" + }, + "trigger_subtype": { + "turn_on": "Turn on", + "turn_off": "Turn off", + "dim_up": "Dim up", + "dim_down": "Dim down", + "left": "Left", + "right": "Right", + "open": "Open", + "close": "Close", + "both_buttons": "Both buttons", + "top_buttons": "Top buttons", + "bottom_buttons": "Bottom buttons", + "button_1": "First button", + "button_2": "Second button", + "button_3": "Third button", + "button_4": "Fourth button", + "side_1": "Side 1", + "side_2": "Side 2", + "side_3": "Side 3", + "side_4": "Side 4", + "side_5": "Side 5", + "side_6": "Side 6" } + } } diff --git a/homeassistant/components/deconz/switch.py b/homeassistant/components/deconz/switch.py index c399f5da128d6..d7b6b55fbb884 100644 --- a/homeassistant/components/deconz/switch.py +++ b/homeassistant/components/deconz/switch.py @@ -1,5 +1,5 @@ """Support for deCONZ switches.""" -from homeassistant.components.switch import SwitchDevice +from homeassistant.components.switch import SwitchEntity from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -8,10 +8,8 @@ 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 switches.""" - pass +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Old way of setting up deCONZ platforms.""" async def async_setup_entry(hass, config_entry, async_add_entities): @@ -36,13 +34,16 @@ def async_add_switch(lights): async_add_entities(entities, True) - gateway.listeners.append(async_dispatcher_connect( - hass, gateway.async_event_new_device(NEW_LIGHT), async_add_switch)) + gateway.listeners.append( + async_dispatcher_connect( + hass, gateway.async_signal_new_device(NEW_LIGHT), async_add_switch + ) + ) async_add_switch(gateway.api.lights.values()) -class DeconzPowerPlug(DeconzDevice, SwitchDevice): +class DeconzPowerPlug(DeconzDevice, SwitchEntity): """Representation of a deCONZ power plug.""" @property @@ -52,29 +53,29 @@ def is_on(self): async def async_turn_on(self, **kwargs): """Turn on switch.""" - data = {'on': True} + data = {"on": True} await self._device.async_set_state(data) async def async_turn_off(self, **kwargs): """Turn off switch.""" - data = {'on': False} + data = {"on": False} await self._device.async_set_state(data) -class DeconzSiren(DeconzDevice, SwitchDevice): +class DeconzSiren(DeconzDevice, SwitchEntity): """Representation of a deCONZ siren.""" @property def is_on(self): """Return true if switch is on.""" - return self._device.alert == 'lselect' + return self._device.alert == "lselect" async def async_turn_on(self, **kwargs): """Turn on switch.""" - data = {'alert': 'lselect'} + data = {"alert": "lselect"} await self._device.async_set_state(data) async def async_turn_off(self, **kwargs): """Turn off switch.""" - data = {'alert': 'none'} + data = {"alert": "none"} await self._device.async_set_state(data) diff --git a/homeassistant/components/deconz/translations/bg.json b/homeassistant/components/deconz/translations/bg.json new file mode 100644 index 0000000000000..ad79cb9d58459 --- /dev/null +++ b/homeassistant/components/deconz/translations/bg.json @@ -0,0 +1,92 @@ +{ + "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", + "already_in_progress": "\u0412 \u043c\u043e\u043c\u0435\u043d\u0442\u0430 \u0442\u0435\u0447\u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u0435 \u043d\u0430 \u0431\u0430\u0437\u043e\u0432\u0430 \u0441\u0442\u0430\u043d\u0446\u0438\u044f.", + "no_bridges": "\u041d\u0435 \u0441\u0430 \u043e\u0442\u043a\u0440\u0438\u0442\u0438 \u043c\u043e\u0441\u0442\u043e\u0432\u0435 deCONZ", + "not_deconz_bridge": "\u041d\u0435 \u0435 deCONZ \u0431\u0430\u0437\u043e\u0432\u0430 \u0441\u0442\u0430\u043d\u0446\u0438\u044f", + "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", + "updated_instance": "\u041e\u0431\u043d\u043e\u0432\u044f\u0432\u0430\u043d\u0435 \u043d\u0430 deCONZ \u0441 \u043d\u043e\u0432 \u0430\u0434\u0440\u0435\u0441" + }, + "error": { + "no_key": "\u041d\u0435 \u043c\u043e\u0436\u0430 \u0434\u0430 \u0441\u0435 \u043f\u043e\u043b\u0443\u0447\u0438 API \u043a\u043b\u044e\u0447" + }, + "flow_title": "deCONZ Zigbee \u0448\u043b\u044e\u0437 ({host})", + "step": { + "hassio_confirm": { + "description": "\u0418\u0441\u043a\u0430\u0442\u0435 \u043b\u0438 \u0434\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u0442\u0435 Home Assistant \u0434\u0430 \u0441\u0435 \u0441\u0432\u044a\u0440\u0437\u0432\u0430 \u0441 deCONZ \u0431\u0430\u0437\u043e\u0432\u0430 \u0441\u0442\u0430\u043d\u0446\u0438\u044f, \u043f\u0440\u0435\u0434\u043e\u0441\u0442\u0430\u0432\u0435\u043d \u043e\u0442 \u0434\u043e\u0431\u0430\u0432\u043a\u0430\u0442\u0430 \u0437\u0430 hass.io {addon}?", + "title": "deCONZ Zigbee \u0431\u0430\u0437\u043e\u0432\u0430 \u0441\u0442\u0430\u043d\u0446\u0438\u044f \u0447\u0440\u0435\u0437 Hass.io \u0434\u043e\u0431\u0430\u0432\u043a\u0430" + }, + "init": { + "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\u0438\u0434\u0435\u0442\u0435 \u043d\u0430 deCONZ Settings -> Gateway -> Advanced\n2. \u041d\u0430\u0442\u0438\u0441\u043d\u0435\u0442\u0435 \u0431\u0443\u0442\u043e\u043d\u0430 \"Authenticate app\"", + "title": "\u0412\u0440\u044a\u0437\u043a\u0430 \u0441 deCONZ" + }, + "manual_confirm": { + "data": { + "host": "\u0410\u0434\u0440\u0435\u0441", + "port": "\u041f\u043e\u0440\u0442" + } + } + } + }, + "device_automation": { + "trigger_subtype": { + "both_buttons": "\u0418 \u0434\u0432\u0430\u0442\u0430 \u0431\u0443\u0442\u043e\u043d\u0430", + "button_1": "\u041f\u044a\u0440\u0432\u0438 \u0431\u0443\u0442\u043e\u043d", + "button_2": "\u0412\u0442\u043e\u0440\u0438 \u0431\u0443\u0442\u043e\u043d", + "button_3": "\u0422\u0440\u0435\u0442\u0438 \u0431\u0443\u0442\u043e\u043d", + "button_4": "\u0427\u0435\u0442\u0432\u044a\u0440\u0442\u0438 \u0431\u0443\u0442\u043e\u043d", + "close": "\u0417\u0430\u0442\u0432\u0430\u0440\u044f\u043d\u0435", + "dim_down": "\u0417\u0430\u0442\u044a\u043c\u043d\u044f\u0432\u0430\u043d\u0435", + "dim_up": "\u041e\u0441\u0432\u0435\u0442\u044f\u0432\u0430\u043d\u0435", + "left": "\u041b\u044f\u0432\u043e", + "open": "\u041e\u0442\u0432\u0430\u0440\u044f\u043d\u0435", + "right": "\u0414\u044f\u0441\u043d\u043e", + "side_1": "\u0421\u0442\u0440\u0430\u043d\u0430 1", + "side_2": "\u0421\u0442\u0440\u0430\u043d\u0430 2", + "side_3": "\u0421\u0442\u0440\u0430\u043d\u0430 3", + "side_4": "\u0421\u0442\u0440\u0430\u043d\u0430 4", + "side_5": "\u0421\u0442\u0440\u0430\u043d\u0430 5", + "side_6": "\u0421\u0442\u0440\u0430\u043d\u0430 6", + "turn_off": "\u0418\u0437\u043a\u043b\u044e\u0447\u0438", + "turn_on": "\u0412\u043a\u043b\u044e\u0447\u0438" + }, + "trigger_type": { + "remote_awakened": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0441\u0435 \u0441\u044a\u0431\u0443\u0434\u0438", + "remote_button_double_press": "\"{subtype}\" \u0431\u0443\u0442\u043e\u043d\u044a\u0442 \u0431\u0435\u0448\u0435 \u043d\u0430\u0442\u0438\u0441\u043d\u0430\u0442 \u0434\u0432\u0443\u043a\u0440\u0430\u0442\u043d\u043e", + "remote_button_long_press": "\"{subtype}\" \u0431\u0443\u0442\u043e\u043d\u044a\u0442 \u0431\u0435\u0448\u0435 \u043d\u0430\u0442\u0438\u0441\u043d\u0430\u0442 \u043f\u0440\u043e\u0434\u044a\u043b\u0436\u0438\u0442\u0435\u043b\u043d\u043e", + "remote_button_long_release": "\"{subtype}\" \u0431\u0443\u0442\u043e\u043d\u044a\u0442 \u0431\u0435\u0448\u0435 \u043e\u0442\u043f\u0443\u0441\u043d\u0430\u0442 \u0441\u043b\u0435\u0434 \u043f\u0440\u043e\u0434\u044a\u043b\u0436\u0438\u0442\u0435\u043b\u043d\u043e \u043d\u0430\u0442\u0438\u0441\u043a\u0430\u043d\u0435", + "remote_button_quadruple_press": "\"{subtype}\" \u0431\u0443\u0442\u043e\u043d\u044a\u0442 \u0431\u0435\u0448\u0435 \u043d\u0430\u0442\u0438\u0441\u043d\u0430\u0442 \u0447\u0435\u0442\u0438\u0440\u0438\u043a\u0440\u0430\u0442\u043d\u043e", + "remote_button_quintuple_press": "\"{subtype}\" \u0431\u0443\u0442\u043e\u043d\u044a\u0442 \u0431\u0435\u0448\u0435 \u043d\u0430\u0442\u0438\u0441\u043d\u0430\u0442 \u043f\u0435\u0442\u043a\u0440\u0430\u0442\u043d\u043e", + "remote_button_rotated": "\u0417\u0430\u0432\u044a\u0440\u0442\u044f\u043d \u0431\u0443\u0442\u043e\u043d \"{subtype}\"", + "remote_button_rotation_stopped": "\u0421\u043f\u0440\u044f \u0432\u044a\u0440\u0442\u0435\u043d\u0435\u0442\u043e \u043d\u0430 \u0431\u0443\u0442\u043e\u043d \"{subtype}\"", + "remote_button_short_press": "\"{subtype}\" \u0431\u0443\u0442\u043e\u043d\u044a\u0442 \u0431\u0435\u0448\u0435 \u043d\u0430\u0442\u0438\u0441\u043d\u0430\u0442", + "remote_button_short_release": "\"{subtype}\" \u0431\u0443\u0442\u043e\u043d\u044a\u0442 \u0431\u0435\u0448\u0435 \u043e\u0442\u043f\u0443\u0441\u043d\u0430\u0442", + "remote_button_triple_press": "\"{subtype}\" \u0431\u0443\u0442\u043e\u043d\u044a\u0442 \u0431\u0435\u0448\u0435 \u043d\u0430\u0442\u0438\u0441\u043d\u0430\u0442 \u0442\u0440\u0438\u043a\u0440\u0430\u0442\u043d\u043e", + "remote_double_tap": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \"{subtype}\" \u0435 \u043f\u043e\u0447\u0443\u043a\u0430\u043d\u043e \u0434\u0432\u0430 \u043f\u044a\u0442\u0438", + "remote_falling": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u043f\u0430\u0434\u0430", + "remote_gyro_activated": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0435 \u0440\u0430\u0437\u043a\u043b\u0430\u0442\u0435\u043d\u043e", + "remote_moved": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0435 \u043f\u0440\u0435\u043c\u0435\u0441\u0442\u0435\u043d\u043e \u0441 \"{subtype}\" \u043d\u0430\u0433\u043e\u0440\u0435", + "remote_rotate_from_side_1": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0435 \u0437\u0430\u0432\u044a\u0440\u0442\u044f\u043d\u043e \u043e\u0442 \"\u0441\u0442\u0440\u0430\u043d\u0430 1\" \u043a\u044a\u043c \" {subtype} \"", + "remote_rotate_from_side_2": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0435 \u0437\u0430\u0432\u044a\u0440\u0442\u044f\u043d\u043e \u043e\u0442 \"\u0441\u0442\u0440\u0430\u043d\u0430 2\" \u043a\u044a\u043c \" {subtype} \"", + "remote_rotate_from_side_3": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0435 \u0437\u0430\u0432\u044a\u0440\u0442\u044f\u043d\u043e \u043e\u0442 \"\u0441\u0442\u0440\u0430\u043d\u0430 3\" \u043a\u044a\u043c \" {subtype} \"", + "remote_rotate_from_side_4": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0435 \u0437\u0430\u0432\u044a\u0440\u0442\u044f\u043d\u043e \u043e\u0442 \"\u0441\u0442\u0440\u0430\u043d\u0430 4\" \u043a\u044a\u043c \" {subtype} \"", + "remote_rotate_from_side_5": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0435 \u0437\u0430\u0432\u044a\u0440\u0442\u044f\u043d\u043e \u043e\u0442 \"\u0441\u0442\u0440\u0430\u043d\u0430 5\" \u043a\u044a\u043c \" {subtype} \"", + "remote_rotate_from_side_6": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0435 \u0437\u0430\u0432\u044a\u0440\u0442\u044f\u043d\u043e \u043e\u0442 \"\u0441\u0442\u0440\u0430\u043d\u0430 6\" \u043a\u044a\u043c \" {subtype} \"" + } + }, + "options": { + "step": { + "deconz_devices": { + "data": { + "allow_clip_sensor": "\u0420\u0430\u0437\u0440\u0435\u0448\u0430\u0432\u0430\u043d\u0435 \u043d\u0430 deCONZ CLIP \u0441\u0435\u043d\u0437\u043e\u0440\u0438", + "allow_deconz_groups": "\u0420\u0430\u0437\u0440\u0435\u0448\u0438 deCONZ \u0441\u0432\u0435\u0442\u043b\u0438\u043d\u043d\u0438 \u0433\u0440\u0443\u043f\u0438" + }, + "description": "\u041a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u0439\u0442\u0435 \u0432\u0438\u0434\u0438\u043c\u043e\u0441\u0442\u0442\u0430 \u043d\u0430 \u0442\u0438\u043f\u043e\u0432\u0435\u0442\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 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..9559dcfb21121 --- /dev/null +++ b/homeassistant/components/deconz/translations/ca.json @@ -0,0 +1,114 @@ +{ + "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" + }, + "flow_title": "Passarel\u00b7la d'enlla\u00e7 deCONZ Zigbee ({host})", + "step": { + "hassio_confirm": { + "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": { + "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" + }, + "manual_confirm": { + "data": { + "host": "Amfitri\u00f3", + "port": "Port" + } + }, + "manual_input": { + "data": { + "host": "Amfitri\u00f3", + "port": "Port" + }, + "title": "Configuraci\u00f3 de la passarel\u00b7la deCONZ" + }, + "user": { + "data": { + "host": "Selecciona la passarel\u00b7la deCONZ descoberta" + }, + "title": "Selecci\u00f3 de la passarel\u00b7la deCONZ" + } + } + }, + "device_automation": { + "trigger_subtype": { + "both_buttons": "Ambd\u00f3s botons", + "bottom_buttons": "Botons inferiors", + "button_1": "Primer bot\u00f3", + "button_2": "Segon bot\u00f3", + "button_3": "Tercer bot\u00f3", + "button_4": "Quart bot\u00f3", + "close": "Tanca", + "dim_down": "Atenua la brillantor", + "dim_up": "Augmenta la brillantor", + "left": "Esquerra", + "open": "Obert", + "right": "Dreta", + "side_1": "cara 1", + "side_2": "cara 2", + "side_3": "cara 3", + "side_4": "cara 4", + "side_5": "cara 5", + "side_6": "cara 6", + "top_buttons": "Botons superiors", + "turn_off": "Desactiva", + "turn_on": "Activa" + }, + "trigger_type": { + "remote_awakened": "Dispositiu despertat", + "remote_button_double_press": "Bot\u00f3 \"{subtype}\" clicat dues vegades", + "remote_button_long_press": "Bot\u00f3 \"{subtype}\" premut cont\u00ednuament", + "remote_button_long_release": "Bot\u00f3 \"{subtype}\" alliberat despr\u00e9s d'una estona premut", + "remote_button_quadruple_press": "Bot\u00f3 \"{subtype}\" clicat quatre vegades", + "remote_button_quintuple_press": "Bot\u00f3 \"{subtype}\" clicat cinc vegades", + "remote_button_rotated": "Bot\u00f3 \"{subtype}\" girat", + "remote_button_rotation_stopped": "La rotaci\u00f3 del bot\u00f3 \"{subtype}\" s'ha aturat", + "remote_button_short_press": "Bot\u00f3 \"{subtype}\" premut", + "remote_button_short_release": "Bot\u00f3 \"{subtype}\" alliberat", + "remote_button_triple_press": "Bot\u00f3 \"{subtype}\" clicat tres vegades", + "remote_double_tap": "Dispositiu \"{subtype}\" tocat dues vegades", + "remote_double_tap_any_side": "Dispositiu tocat dues vegades a alguna cara", + "remote_falling": "Dispositiu en caiguda lliure", + "remote_flip_180_degrees": "Dispositiu voltejat 180 graus", + "remote_flip_90_degrees": "Dispositiu voltejat 90 graus", + "remote_gyro_activated": "Dispositiu sacsejat", + "remote_moved": "Dispositiu mogut amb la \"{subtype}\" amunt", + "remote_moved_any_side": "Dispositiu mogut amb alguna cara amunt", + "remote_rotate_from_side_1": "Dispositiu rotat de la \"cara 1\" a la \"{subtype}\"", + "remote_rotate_from_side_2": "Dispositiu rotat de la \"cara 2\" a la \"{subtype}\"", + "remote_rotate_from_side_3": "Dispositiu rotat de la \"cara 3\" a la \"{subtype}\"", + "remote_rotate_from_side_4": "Dispositiu rotat de la \"cara 4\" a la \"{subtype}\"", + "remote_rotate_from_side_5": "Dispositiu rotat de la \"cara 5\" a la \"{subtype}\"", + "remote_rotate_from_side_6": "Dispositiu rotat de la \"cara 6\" a la \"{subtype}\"", + "remote_turned_clockwise": "Dispositiu girat en sentit horari", + "remote_turned_counter_clockwise": "Dispositiu girat en sentit antihorari" + } + }, + "options": { + "step": { + "deconz_devices": { + "data": { + "allow_clip_sensor": "Permet sensors deCONZ CLIP", + "allow_deconz_groups": "Permet grups de llums deCONZ" + }, + "description": "Configura la visibilitat dels tipus dels dispositius deCONZ", + "title": "Opcions de deCONZ" + } + } + } +} \ 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..360cc9e113fbf --- /dev/null +++ b/homeassistant/components/deconz/translations/cs.json @@ -0,0 +1,32 @@ +{ + "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" + }, + "flow_title": "Br\u00e1na deCONZ ZigBee ({host})", + "step": { + "hassio_confirm": { + "description": "Chcete nakonfigurovat slu\u017ebu Home Assistant pro p\u0159ipojen\u00ed k deCONZ br\u00e1n\u011b pomoc\u00ed hass.io {addon}?", + "title": "deCONZ Zigbee br\u00e1na prost\u0159ednictv\u00edm dopl\u0148ku Hass.io" + }, + "init": { + "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" + }, + "manual_confirm": { + "data": { + "host": "Hostitel", + "port": "Port" + } + } + } + } +} \ 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..594ea26ee6f58 --- /dev/null +++ b/homeassistant/components/deconz/translations/cy.json @@ -0,0 +1,27 @@ +{ + "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": { + "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" + }, + "manual_confirm": { + "data": { + "host": "Gwesteiwr", + "port": "Port (gwerth diofyn: '80')" + } + } + } + } +} \ 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..348eba18ae3ed --- /dev/null +++ b/homeassistant/components/deconz/translations/da.json @@ -0,0 +1,99 @@ +{ + "config": { + "abort": { + "already_configured": "Bridge er allerede konfigureret", + "already_in_progress": "Konfigurationsflow for bro er allerede i gang.", + "no_bridges": "Ingen deConz-bridge fundet", + "not_deconz_bridge": "Ikke en deCONZ-bro", + "one_instance_only": "Komponenten underst\u00f8tter kun \u00e9n deCONZ-instans", + "updated_instance": "Opdaterede deCONZ-instans med ny v\u00e6rtadresse" + }, + "error": { + "no_key": "Kunne ikke f\u00e5 en API-n\u00f8gle" + }, + "flow_title": "deCONZ Zigbee gateway ({host})", + "step": { + "hassio_confirm": { + "description": "Vil du konfigurere Home Assistant til at oprette forbindelse til deCONZ-gateway'en leveret af Hass.io-tilf\u00f8jelsen {addon}?", + "title": "deCONZ Zigbee-gateway via Hass.io-tilf\u00f8jelse" + }, + "init": { + "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": "Forbind med deCONZ" + }, + "manual_confirm": { + "data": { + "host": "V\u00e6rt", + "port": "Port" + } + } + } + }, + "device_automation": { + "trigger_subtype": { + "both_buttons": "Begge knapper", + "button_1": "F\u00f8rste knap", + "button_2": "Anden knap", + "button_3": "Tredje knap", + "button_4": "Fjerde knap", + "close": "Luk", + "dim_down": "D\u00e6mp ned", + "dim_up": "D\u00e6mp op", + "left": "Venstre", + "open": "\u00c5ben", + "right": "H\u00f8jre", + "side_1": "Side 1", + "side_2": "Side 2", + "side_3": "Side 3", + "side_4": "Side 4", + "side_5": "Side 5", + "side_6": "Side 6", + "turn_off": "Sluk", + "turn_on": "T\u00e6nd" + }, + "trigger_type": { + "remote_awakened": "Enheden v\u00e6kket", + "remote_button_double_press": "\"{subtype}\"-knappen er dobbeltklikket", + "remote_button_long_press": "\"{subtype}\"-knappen trykket p\u00e5 konstant", + "remote_button_long_release": "\"{subtype}\"-knappen frigivet efter langt tryk", + "remote_button_quadruple_press": "\"{subtype}\"-knappen firedobbelt-klikket", + "remote_button_quintuple_press": "\"{subtype}\"-knappen femdobbelt-klikket", + "remote_button_rotated": "Knap roteret \"{subtype}\"", + "remote_button_rotation_stopped": "Knaprotation \"{subtype}\" er stoppet", + "remote_button_short_press": "\"{subtype}\"-knappen trykket p\u00e5", + "remote_button_short_release": "\"{subtype}\"-knappen frigivet", + "remote_button_triple_press": "\"{subtype}\"-knappen tredobbeltklikkes", + "remote_double_tap": "Enheden \"{subtype}\" dobbelttappet", + "remote_double_tap_any_side": "Enhed dobbelttappet p\u00e5 enhver side", + "remote_falling": "Enheden er i frit fald", + "remote_flip_180_degrees": "Enhed vendt 180 grader", + "remote_flip_90_degrees": "Enhed vendt 90 grader", + "remote_gyro_activated": "Enhed rystet", + "remote_moved": "Enheden flyttede med \"{subtype}\" op", + "remote_moved_any_side": "Enhed flyttet med enhver side opad", + "remote_rotate_from_side_1": "Enhed roteret fra \"side 1\" til \"{subtype}\"", + "remote_rotate_from_side_2": "Enhed roteret fra \"side 2\" til \"{subtype}\"", + "remote_rotate_from_side_3": "Enhed roteret fra \"side 3\" til \"{subtype}\"", + "remote_rotate_from_side_4": "Enhed roteret fra \"side 4\" til \"{subtype}\"", + "remote_rotate_from_side_5": "Enhed roteret fra \"side 5\" til \"{subtype}\"", + "remote_rotate_from_side_6": "Enhed roteret fra \"side 6\" til \"{subtype}\"", + "remote_turned_clockwise": "Enhed drejet med uret", + "remote_turned_counter_clockwise": "Enhed drejet mod uret" + } + }, + "options": { + "step": { + "deconz_devices": { + "data": { + "allow_clip_sensor": "Tillad deCONZ CLIP-sensorer", + "allow_deconz_groups": "Tillad deCONZ-lysgrupper" + }, + "description": "Konfigurer synligheden af deCONZ-enhedstyper", + "title": "deCONZ-indstillinger" + } + } + } +} \ 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..be359a00ca82f --- /dev/null +++ b/homeassistant/components/deconz/translations/de.json @@ -0,0 +1,114 @@ +{ + "config": { + "abort": { + "already_configured": "Bridge ist bereits konfiguriert", + "already_in_progress": "Der Konfigurationsablauf f\u00fcr die Bridge wird bereits ausgef\u00fchrt.", + "no_bridges": "Keine deCON-Bridges entdeckt", + "not_deconz_bridge": "Keine deCONZ Bridge 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" + }, + "flow_title": "deCONZ Zigbee Gateway", + "step": { + "hassio_confirm": { + "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": { + "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" + }, + "manual_confirm": { + "data": { + "host": "Host", + "port": "Port" + } + }, + "manual_input": { + "data": { + "host": "Host", + "port": "Port" + }, + "title": "Konfigurieren Sie das deCONZ-Gateway" + }, + "user": { + "data": { + "host": "W\u00e4hlen Sie das erkannte deCONZ-Gateway aus" + }, + "title": "W\u00e4hlen Sie das deCONZ-Gateway" + } + } + }, + "device_automation": { + "trigger_subtype": { + "both_buttons": "Beide Tasten", + "bottom_buttons": "Untere Tasten", + "button_1": "Erste Taste", + "button_2": "Zweite Taste", + "button_3": "Dritte Taste", + "button_4": "Vierte Taste", + "close": "Schlie\u00dfen", + "dim_down": "Dimmer runter", + "dim_up": "Dimmer hoch", + "left": "Links", + "open": "Offen", + "right": "Rechts", + "side_1": "Seite 1", + "side_2": "Seite 2", + "side_3": "Seite 3", + "side_4": "Seite 4", + "side_5": "Seite 5", + "side_6": "Seite 6", + "top_buttons": "Obere Tasten", + "turn_off": "Ausschalten", + "turn_on": "Einschalten" + }, + "trigger_type": { + "remote_awakened": "Ger\u00e4t aufgeweckt", + "remote_button_double_press": "\"{subtype}\" Taste doppelt angeklickt", + "remote_button_long_press": "\"{subtype}\" Taste kontinuierlich gedr\u00fcckt", + "remote_button_long_release": "\"{subtype}\" Taste nach langem Dr\u00fccken losgelassen", + "remote_button_quadruple_press": "\"{subtype}\" Taste vierfach geklickt", + "remote_button_quintuple_press": "\"{subtype}\" Taste f\u00fcnffach geklickt", + "remote_button_rotated": "Button gedreht \"{subtype}\".", + "remote_button_rotation_stopped": "Die Tastendrehung \"{subtype}\" wurde gestoppt", + "remote_button_short_press": "\"{subtype}\" Taste gedr\u00fcckt", + "remote_button_short_release": "\"{subtype}\" Taste losgelassen", + "remote_button_triple_press": "\"{subtype}\" Taste dreimal geklickt", + "remote_double_tap": "Ger\u00e4t \"{subtype}\" doppelt getippt", + "remote_double_tap_any_side": "Ger\u00e4t auf beliebiger Seite doppelt angetippt", + "remote_falling": "Ger\u00e4t im freien Fall", + "remote_flip_180_degrees": "Ger\u00e4t um 180 Grad gekippt", + "remote_flip_90_degrees": "Ger\u00e4t um 90 Grad gekippt", + "remote_gyro_activated": "Ger\u00e4t ersch\u00fcttert", + "remote_moved": "Ger\u00e4t mit \"{subtype}\" nach oben bewegt", + "remote_moved_any_side": "Ger\u00e4t mit beliebiger Seite nach oben bewegt", + "remote_rotate_from_side_1": "Ger\u00e4t von \"Seite 1\" auf \"{subtype}\" gedreht", + "remote_rotate_from_side_2": "Ger\u00e4t von \"Seite 2\" auf \"{subtype}\" gedreht", + "remote_rotate_from_side_3": "Ger\u00e4t von \"Seite 3\" auf \"{subtype}\" gedreht", + "remote_rotate_from_side_4": "Ger\u00e4t von \"Seite 4\" auf \"{subtype}\" gedreht", + "remote_rotate_from_side_5": "Ger\u00e4t von \"Seite 5\" auf \"{subtype}\" gedreht", + "remote_rotate_from_side_6": "Ger\u00e4t von \"Seite 6\" auf \"{subtype}\" gedreht", + "remote_turned_clockwise": "Ger\u00e4t im Uhrzeigersinn gedreht", + "remote_turned_counter_clockwise": "Ger\u00e4t gegen den Uhrzeigersinn gedreht" + } + }, + "options": { + "step": { + "deconz_devices": { + "data": { + "allow_clip_sensor": "deCONZ CLIP-Sensoren zulassen", + "allow_deconz_groups": "deCONZ-Lichtgruppen zulassen" + }, + "description": "Sichtbarkeit der deCONZ-Ger\u00e4tetypen konfigurieren", + "title": "deCONZ-Optionen" + } + } + } +} \ 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..159171a65d24d --- /dev/null +++ b/homeassistant/components/deconz/translations/en.json @@ -0,0 +1,114 @@ +{ + "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" + }, + "flow_title": "deCONZ Zigbee gateway ({host})", + "step": { + "hassio_confirm": { + "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": { + "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" + }, + "manual_confirm": { + "data": { + "host": "Host", + "port": "Port" + } + }, + "manual_input": { + "data": { + "host": "Host", + "port": "Port" + }, + "title": "Configure deCONZ gateway" + }, + "user": { + "data": { + "host": "Select discovered deCONZ gateway" + }, + "title": "Select deCONZ gateway" + } + } + }, + "device_automation": { + "trigger_subtype": { + "both_buttons": "Both buttons", + "bottom_buttons": "Bottom buttons", + "button_1": "First button", + "button_2": "Second button", + "button_3": "Third button", + "button_4": "Fourth button", + "close": "Close", + "dim_down": "Dim down", + "dim_up": "Dim up", + "left": "Left", + "open": "Open", + "right": "Right", + "side_1": "Side 1", + "side_2": "Side 2", + "side_3": "Side 3", + "side_4": "Side 4", + "side_5": "Side 5", + "side_6": "Side 6", + "top_buttons": "Top buttons", + "turn_off": "Turn off", + "turn_on": "Turn on" + }, + "trigger_type": { + "remote_awakened": "Device awakened", + "remote_button_double_press": "\"{subtype}\" button double clicked", + "remote_button_long_press": "\"{subtype}\" button continuously pressed", + "remote_button_long_release": "\"{subtype}\" button released after long press", + "remote_button_quadruple_press": "\"{subtype}\" button quadruple clicked", + "remote_button_quintuple_press": "\"{subtype}\" button quintuple clicked", + "remote_button_rotated": "Button rotated \"{subtype}\"", + "remote_button_rotation_stopped": "Button rotation \"{subtype}\" stopped", + "remote_button_short_press": "\"{subtype}\" button pressed", + "remote_button_short_release": "\"{subtype}\" button released", + "remote_button_triple_press": "\"{subtype}\" button triple clicked", + "remote_double_tap": "Device \"{subtype}\" double tapped", + "remote_double_tap_any_side": "Device double tapped on any side", + "remote_falling": "Device in free fall", + "remote_flip_180_degrees": "Device flipped 180 degrees", + "remote_flip_90_degrees": "Device flipped 90 degrees", + "remote_gyro_activated": "Device shaken", + "remote_moved": "Device moved with \"{subtype}\" up", + "remote_moved_any_side": "Device moved with any side up", + "remote_rotate_from_side_1": "Device rotated from \"side 1\" to \"{subtype}\"", + "remote_rotate_from_side_2": "Device rotated from \"side 2\" to \"{subtype}\"", + "remote_rotate_from_side_3": "Device rotated from \"side 3\" to \"{subtype}\"", + "remote_rotate_from_side_4": "Device rotated from \"side 4\" to \"{subtype}\"", + "remote_rotate_from_side_5": "Device rotated from \"side 5\" to \"{subtype}\"", + "remote_rotate_from_side_6": "Device rotated from \"side 6\" to \"{subtype}\"", + "remote_turned_clockwise": "Device turned clockwise", + "remote_turned_counter_clockwise": "Device turned counter clockwise" + } + }, + "options": { + "step": { + "deconz_devices": { + "data": { + "allow_clip_sensor": "Allow deCONZ CLIP sensors", + "allow_deconz_groups": "Allow deCONZ light groups" + }, + "description": "Configure visibility of deCONZ device types", + "title": "deCONZ options" + } + } + } +} \ 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..8208e2578b031 --- /dev/null +++ b/homeassistant/components/deconz/translations/es-419.json @@ -0,0 +1,58 @@ +{ + "config": { + "abort": { + "already_configured": "El Bridge ya est\u00e1 configurado", + "already_in_progress": "El flujo de configuraci\u00f3n para el puente ya est\u00e1 en progreso.", + "no_bridges": "No se descubrieron puentes deCONZ", + "not_deconz_bridge": "No es un puente deCONZ", + "one_instance_only": "El componente solo admite una instancia deCONZ", + "updated_instance": "Instancia deCONZ actualizada con nueva direcci\u00f3n de host" + }, + "error": { + "no_key": "No se pudo obtener una clave de API" + }, + "step": { + "hassio_confirm": { + "description": "\u00bfDesea configurar Home Assistant para conectarse a la puerta de enlace deCONZ proporcionada por el complemento hass.io {addon}?", + "title": "deCONZ Zigbee gateway a trav\u00e9s del complemento Hass.io" + }, + "init": { + "title": "Definir el gateway deCONZ" + }, + "link": { + "description": "Desbloquee su puerta de enlace deCONZ para registrarse con Home Assistant. \n\n 1. Vaya a Configuraci\u00f3n deCONZ - > Gateway - > Avanzado \n 2. Presione el bot\u00f3n \"Autenticar aplicaci\u00f3n\"", + "title": "Enlazar con deCONZ" + }, + "manual_confirm": { + "data": { + "host": "Host", + "port": "Puerto" + } + }, + "manual_input": { + "data": { + "port": "Puerto" + } + } + } + }, + "device_automation": { + "trigger_subtype": { + "both_buttons": "Ambos botones", + "button_1": "Primer bot\u00f3n", + "button_2": "Segundo bot\u00f3n", + "button_3": "Tercer bot\u00f3n", + "button_4": "Cuarto bot\u00f3n", + "close": "Cerrar", + "left": "Izquierda", + "open": "Abrir", + "right": "Derecha", + "turn_off": "Apagar", + "turn_on": "Encender" + }, + "trigger_type": { + "remote_button_rotated": "Bot\u00f3n girado \"{subtype}\"", + "remote_gyro_activated": "Dispositivo agitado" + } + } +} \ 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..5ef7c0cc5d985 --- /dev/null +++ b/homeassistant/components/deconz/translations/es.json @@ -0,0 +1,114 @@ +{ + "config": { + "abort": { + "already_configured": "El puente ya esta configurado", + "already_in_progress": "El flujo de configuraci\u00f3n para el puente ya est\u00e1 en curso.", + "no_bridges": "No se han descubierto puentes deCONZ", + "not_deconz_bridge": "No es un puente 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" + }, + "flow_title": "pasarela deCONZ Zigbee ({host})", + "step": { + "hassio_confirm": { + "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": { + "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" + }, + "manual_confirm": { + "data": { + "host": "Host", + "port": "Puerto" + } + }, + "manual_input": { + "data": { + "host": "Host", + "port": "Puerto" + }, + "title": "Configurar la puerta de enlace deCONZ" + }, + "user": { + "data": { + "host": "Seleccione la puerta de enlace descubierta deCONZ" + }, + "title": "Seleccione la puerta de enlace deCONZ" + } + } + }, + "device_automation": { + "trigger_subtype": { + "both_buttons": "Ambos botones", + "bottom_buttons": "Botones inferiores", + "button_1": "Primer bot\u00f3n", + "button_2": "Segundo bot\u00f3n", + "button_3": "Tercer bot\u00f3n", + "button_4": "Cuarto bot\u00f3n", + "close": "Cerrar", + "dim_down": "Bajar la intensidad", + "dim_up": "Subir la intensidad", + "left": "Izquierda", + "open": "Abierto", + "right": "Derecha", + "side_1": "Lado 1", + "side_2": "Lado 2", + "side_3": "Lado 3", + "side_4": "Lado 4", + "side_5": "Lado 5", + "side_6": "Lado 6", + "top_buttons": "Botones superiores", + "turn_off": "Apagar", + "turn_on": "Encender" + }, + "trigger_type": { + "remote_awakened": "Dispositivo despertado", + "remote_button_double_press": "Bot\u00f3n \"{subtype}\" doble pulsaci\u00f3n", + "remote_button_long_press": "Bot\u00f3n \"{subtype}\" pulsado continuamente", + "remote_button_long_release": "Bot\u00f3n \"{subtype}\" soltado despu\u00e9s de una pulsaci\u00f3n larga", + "remote_button_quadruple_press": "Bot\u00f3n \"{subtype}\" cu\u00e1druple pulsaci\u00f3n", + "remote_button_quintuple_press": "Bot\u00f3n \"{subtype}\" qu\u00edntuple pulsaci\u00f3n", + "remote_button_rotated": "Bot\u00f3n \"{subtype}\" girado", + "remote_button_rotation_stopped": "Bot\u00f3n rotativo \"{subtype}\" detenido", + "remote_button_short_press": "Bot\u00f3n \"{subtype}\" pulsado", + "remote_button_short_release": "Bot\u00f3n \"{subtype}\" soltado", + "remote_button_triple_press": "Bot\u00f3n \"{subtype}\" triple pulsaci\u00f3n", + "remote_double_tap": "Dispositivo \" {subtype} \" doble pulsaci\u00f3n", + "remote_double_tap_any_side": "Dispositivo con doble toque en cualquier lado", + "remote_falling": "Dispositivo en ca\u00edda libre", + "remote_flip_180_degrees": "Dispositivo volteado 180 grados", + "remote_flip_90_degrees": "Dispositivo volteado 90 grados", + "remote_gyro_activated": "Dispositivo sacudido", + "remote_moved": "Dispositivo movido con \"{subtipo}\" hacia arriba", + "remote_moved_any_side": "Dispositivo movido con cualquier lado hacia arriba", + "remote_rotate_from_side_1": "Dispositivo girado del \"lado 1\" al \" {subtype} \"", + "remote_rotate_from_side_2": "Dispositivo girado del \"lado 2\" al \" {subtype} \"", + "remote_rotate_from_side_3": "Dispositivo girado del \"lado 3\" al \" {subtype} \"", + "remote_rotate_from_side_4": "Dispositivo girado del \"lado 4\" al \" {subtype} \"", + "remote_rotate_from_side_5": "Dispositivo girado del \"lado 5\" al \" {subtype} \"", + "remote_rotate_from_side_6": "Dispositivo girado de \"lado 6\" a \" {subtype} \"", + "remote_turned_clockwise": "Dispositivo girado en el sentido de las agujas del reloj", + "remote_turned_counter_clockwise": "Dispositivo girado en sentido contrario a las agujas del reloj" + } + }, + "options": { + "step": { + "deconz_devices": { + "data": { + "allow_clip_sensor": "Permitir sensores deCONZ CLIP", + "allow_deconz_groups": "Permitir grupos de luz deCONZ" + }, + "description": "Configurar la visibilidad de los tipos de dispositivos deCONZ", + "title": "Opciones 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..45bb3967060ca --- /dev/null +++ b/homeassistant/components/deconz/translations/et.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "manual_confirm": { + "data": { + "host": "", + "port": "" + } + } + } + } +} \ 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..b7d77ad7b1ed5 --- /dev/null +++ b/homeassistant/components/deconz/translations/fr.json @@ -0,0 +1,105 @@ +{ + "config": { + "abort": { + "already_configured": "Ce pont est d\u00e9j\u00e0 configur\u00e9", + "already_in_progress": "Le flux de configuration pour le pont est d\u00e9j\u00e0 en cours.", + "no_bridges": "Aucun pont deCONZ n'a \u00e9t\u00e9 d\u00e9couvert", + "not_deconz_bridge": "Pas un pont deCONZ", + "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" + }, + "flow_title": "Passerelle deCONZ Zigbee ({host})", + "step": { + "hassio_confirm": { + "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": { + "title": "Initialiser la passerelle deCONZ" + }, + "link": { + "description": "D\u00e9verrouillez votre passerelle deCONZ pour vous enregistrer avec Home Assistant. \n\n 1. Acc\u00e9dez aux param\u00e8tres avanc\u00e9s du syst\u00e8me deCONZ \n 2. Cliquez sur \"D\u00e9verrouiller la passerelle\"", + "title": "Lien vers deCONZ" + }, + "manual_confirm": { + "data": { + "host": "H\u00f4te", + "port": "Port" + } + }, + "manual_input": { + "data": { + "host": "H\u00f4te", + "port": "Port" + } + } + } + }, + "device_automation": { + "trigger_subtype": { + "both_buttons": "Les deux boutons", + "button_1": "Premier bouton", + "button_2": "Deuxi\u00e8me bouton", + "button_3": "Troisi\u00e8me bouton", + "button_4": "Quatri\u00e8me bouton", + "close": "Ferm\u00e9", + "dim_down": "Assombrir", + "dim_up": "\u00c9claircir", + "left": "Gauche", + "open": "Ouvert", + "right": "Droite", + "side_1": "Face 1", + "side_2": "Face 2", + "side_3": "Face 3", + "side_4": "Face 4", + "side_5": "Face 5", + "side_6": "Face 6", + "turn_off": "\u00c9teint", + "turn_on": "Allumer" + }, + "trigger_type": { + "remote_awakened": "Appareil r\u00e9veill\u00e9", + "remote_button_double_press": "Double clic sur le bouton \" {subtype} \"", + "remote_button_long_press": "Appuyer en continu sur le bouton \" {subtype} \"", + "remote_button_long_release": "Bouton \"{subtype}\" rel\u00e2ch\u00e9 apr\u00e8s appui long", + "remote_button_quadruple_press": "Quadruple clic sur le bouton \" {subtype} \"", + "remote_button_quintuple_press": "Quintuple clic sur le bouton \" {subtype} \"", + "remote_button_rotated": "Bouton \"{subtype}\" tourn\u00e9", + "remote_button_rotation_stopped": "La rotation du bouton \" {subtype} \" s'est arr\u00eat\u00e9e", + "remote_button_short_press": "Bouton \"{subtype}\" appuy\u00e9", + "remote_button_short_release": "Bouton \"{subtype}\" rel\u00e2ch\u00e9", + "remote_button_triple_press": "Triple clic sur le bouton \" {subtype} \"", + "remote_double_tap": "Appareil \"{subtype}\" tapot\u00e9 deux fois", + "remote_double_tap_any_side": "Appareil double tap\u00e9 de n\u2019importe quel c\u00f4t\u00e9", + "remote_falling": "Appareil en chute libre", + "remote_flip_180_degrees": "Dispositif retourn\u00e9 \u00e0 180 degr\u00e9s", + "remote_flip_90_degrees": "Dispositif retourn\u00e9 \u00e0 90 degr\u00e9s", + "remote_gyro_activated": "Appareil secou\u00e9", + "remote_moved": "Appareil d\u00e9plac\u00e9 avec \"{subtype}\" vers le haut", + "remote_moved_any_side": "Dispositif d\u00e9plac\u00e9 avec un c\u00f4t\u00e9 quelconque vers le haut", + "remote_rotate_from_side_1": "Appareil tourn\u00e9 de \"c\u00f4t\u00e9 1\" \u00e0 \"{subtype}\"", + "remote_rotate_from_side_2": "Appareil tourn\u00e9 de \"c\u00f4t\u00e9 2\" \u00e0 \"{subtype}\"", + "remote_rotate_from_side_3": "Appareil tourn\u00e9 de \"c\u00f4t\u00e9 3\" \u00e0 \"{subtype}\"", + "remote_rotate_from_side_4": "Appareil tourn\u00e9 de \"c\u00f4t\u00e9 4\" \u00e0 \"{subtype}\"", + "remote_rotate_from_side_5": "Appareil tourn\u00e9 de \"c\u00f4t\u00e9 5\" \u00e0 \"{subtype}\"", + "remote_rotate_from_side_6": "Appareil tourn\u00e9 de \"c\u00f4t\u00e9 6\" \u00e0 \"{subtype}\"", + "remote_turned_clockwise": "Appareil tourn\u00e9 dans le sens horaire", + "remote_turned_counter_clockwise": "Appareil tourn\u00e9 dans le sens antihoraire" + } + }, + "options": { + "step": { + "deconz_devices": { + "data": { + "allow_clip_sensor": "Autoriser les capteurs deCONZ CLIP", + "allow_deconz_groups": "Autoriser les groupes de lumi\u00e8res deCONZ" + }, + "description": "Configurer la visibilit\u00e9 des appareils de type deCONZ", + "title": "Options deCONZ" + } + } + } +} \ 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..3a6dff48933a4 --- /dev/null +++ b/homeassistant/components/deconz/translations/he.json @@ -0,0 +1,27 @@ +{ + "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": { + "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" + }, + "manual_confirm": { + "data": { + "host": "\u05de\u05d0\u05e8\u05d7", + "port": "\u05e4\u05d5\u05e8\u05d8 (\u05d1\u05e8\u05d9\u05e8\u05ea \u05de\u05d7\u05d3\u05dc: '80')" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/deconz/translations/hr.json b/homeassistant/components/deconz/translations/hr.json new file mode 100644 index 0000000000000..50fec879cb6bb --- /dev/null +++ b/homeassistant/components/deconz/translations/hr.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "manual_confirm": { + "data": { + "host": "Host", + "port": "Port" + } + } + } + } +} \ 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..216d7ddf1a0e7 --- /dev/null +++ b/homeassistant/components/deconz/translations/hu.json @@ -0,0 +1,97 @@ +{ + "config": { + "abort": { + "already_configured": "A bridge m\u00e1r konfigur\u00e1lva van", + "already_in_progress": "Az \u00e1tj\u00e1r\u00f3 konfigur\u00e1ci\u00f3s folyamata m\u00e1r folyamatban van.", + "no_bridges": "Nem tal\u00e1ltam deCONZ bridget", + "not_deconz_bridge": "Nem egy deCONZ \u00e1tj\u00e1r\u00f3", + "one_instance_only": "Ez a komponens csak egy deCONZ egys\u00e9get t\u00e1mogat", + "updated_instance": "A deCONZ-p\u00e9ld\u00e1ny \u00faj \u00e1llom\u00e1sc\u00edmmel friss\u00edtve" + }, + "error": { + "no_key": "API kulcs lek\u00e9r\u00e9se nem siker\u00fclt" + }, + "flow_title": "deCONZ Zigbee \u00e1tj\u00e1r\u00f3 ({host})", + "step": { + "hassio_confirm": { + "title": "deCONZ Zigbee \u00e1tj\u00e1r\u00f3 a Hass.io kieg\u00e9sz\u00edt\u0151 seg\u00edts\u00e9g\u00e9vel" + }, + "init": { + "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" + }, + "manual_confirm": { + "data": { + "host": "Hoszt", + "port": "Port" + } + } + } + }, + "device_automation": { + "trigger_subtype": { + "both_buttons": "Mindk\u00e9t gomb", + "button_1": "Els\u0151 gomb", + "button_2": "M\u00e1sodik gomb", + "button_3": "Harmadik gomb", + "button_4": "Negyedik gomb", + "close": "Bez\u00e1r\u00e1s", + "dim_down": "S\u00f6t\u00e9t\u00edt", + "dim_up": "Vil\u00e1gos\u00edt", + "left": "Balra", + "open": "Nyitva", + "right": "Jobbra", + "side_1": "1. oldal", + "side_2": "2. oldal", + "side_3": "3. oldal", + "side_4": "4. oldal", + "side_5": "5. oldal", + "side_6": "6. oldal", + "turn_off": "Kikapcsolva", + "turn_on": "Bekapcsolva" + }, + "trigger_type": { + "remote_awakened": "A k\u00e9sz\u00fcl\u00e9k fel\u00e9bredt", + "remote_button_double_press": "\" {subtype} \" gombra k\u00e9tszer kattintottak", + "remote_button_long_press": "A \" {subtype} \" gomb folyamatosan lenyomva", + "remote_button_long_release": "A \" {subtype} \" gomb hossz\u00fa megnyom\u00e1s ut\u00e1n elengedve", + "remote_button_quadruple_press": "\" {subtype} \" gombra n\u00e9gyszer kattintottak", + "remote_button_quintuple_press": "\" {subtype} \" gombra \u00f6tsz\u00f6r kattintottak", + "remote_button_rotated": "A gomb elforgatva: \" {subtype} \"", + "remote_button_rotation_stopped": "A (z) \" {subtype} \" gomb forg\u00e1sa le\u00e1llt", + "remote_button_short_press": "\" {subtype} \" gomb lenyomva", + "remote_button_short_release": "\"{alt\u00edpus}\" gomb elengedve", + "remote_button_triple_press": "\" {subtype} \" gombra h\u00e1romszor kattintottak", + "remote_double_tap": "Az \" {subtype} \" eszk\u00f6z dupla kattint\u00e1sa", + "remote_double_tap_any_side": "A k\u00e9sz\u00fcl\u00e9k b\u00e1rmelyik oldal\u00e1n dupl\u00e1n koppint.", + "remote_falling": "K\u00e9sz\u00fcl\u00e9k szabades\u00e9sben", + "remote_flip_180_degrees": "180 fokkal megd\u00f6nt\u00f6tt eszk\u00f6z", + "remote_flip_90_degrees": "90 fokkal megd\u00f6nt\u00f6tt eszk\u00f6z", + "remote_gyro_activated": "A k\u00e9sz\u00fcl\u00e9k meg lett r\u00e1zva", + "remote_moved": "Az eszk\u00f6z a \" {subtype} \"-lal felfel\u00e9 mozgatva", + "remote_moved_any_side": "A k\u00e9sz\u00fcl\u00e9k valamelyik oldal\u00e1val felfel\u00e9 mozogott", + "remote_rotate_from_side_1": "Az eszk\u00f6z a \"1. oldalr\u00f3l\" a \" {subtype} \" -ra fordult", + "remote_rotate_from_side_2": "Az eszk\u00f6z a \"2. oldalr\u00f3l\" a \" {subtype} \" -ra fordult", + "remote_rotate_from_side_3": "Az eszk\u00f6z a \"3. oldalr\u00f3l\" a \" {subtype} \" -ra fordult", + "remote_rotate_from_side_4": "Az eszk\u00f6z a \"4. oldalr\u00f3l\" a \" {subtype} \" -ra fordult", + "remote_rotate_from_side_5": "Az eszk\u00f6z a \"5. oldalr\u00f3l\" a \" {subtype} \" -ra fordult", + "remote_rotate_from_side_6": "Az eszk\u00f6z a \"6. oldalr\u00f3l\" a \" {subtype} \" -ra fordult", + "remote_turned_clockwise": "A k\u00e9sz\u00fcl\u00e9k az \u00f3ramutat\u00f3 j\u00e1r\u00e1s\u00e1val megegyez\u0151en fordult", + "remote_turned_counter_clockwise": "A k\u00e9sz\u00fcl\u00e9k az \u00f3ramutat\u00f3 j\u00e1r\u00e1s\u00e1val ellent\u00e9tes ir\u00e1nyban fordult" + } + }, + "options": { + "step": { + "deconz_devices": { + "data": { + "allow_clip_sensor": "Enged\u00e9lyezze a deCONZ CLIP \u00e9rz\u00e9kel\u0151ket", + "allow_deconz_groups": "DeCONZ f\u00e9nycsoportok enged\u00e9lyez\u00e9se" + }, + "description": "A deCONZ eszk\u00f6zt\u00edpusok l\u00e1that\u00f3s\u00e1g\u00e1nak konfigur\u00e1l\u00e1sa" + } + } + } +} \ 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..ba8b5d76869ad --- /dev/null +++ b/homeassistant/components/deconz/translations/id.json @@ -0,0 +1,27 @@ +{ + "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": { + "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" + }, + "manual_confirm": { + "data": { + "host": "Host", + "port": "Port (nilai default: '80')" + } + } + } + } +} \ 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..55f200560204d --- /dev/null +++ b/homeassistant/components/deconz/translations/it.json @@ -0,0 +1,114 @@ +{ + "config": { + "abort": { + "already_configured": "Il Bridge \u00e8 gi\u00e0 configurato", + "already_in_progress": "Il flusso di configurazione per bridge \u00e8 gi\u00e0 in corso.", + "no_bridges": "Nessun bridge deCONZ rilevato", + "not_deconz_bridge": "Non \u00e8 un bridge deCONZ", + "one_instance_only": "Il componente supporto solo un'istanza di deCONZ", + "updated_instance": "Istanza deCONZ aggiornata con nuovo indirizzo host" + }, + "error": { + "no_key": "Impossibile ottenere una API key" + }, + "flow_title": "Gateway Zigbee deCONZ ({host})", + "step": { + "hassio_confirm": { + "description": "Vuoi configurare Home Assistant per connettersi al gateway deCONZ fornito dal componente aggiuntivo di Hass.io: {addon}?", + "title": "Gateway Pigmee deCONZ tramite il componente aggiuntivo di Hass.io" + }, + "init": { + "title": "Definisci il gateway deCONZ" + }, + "link": { + "description": "Sblocca il tuo gateway deCONZ per registrarti con Home Assistant.\n\n1. Vai a Impostazioni deCONZ -> Gateway -> Avanzate\n2. Premere il pulsante \"Autentica app\"", + "title": "Collega con deCONZ" + }, + "manual_confirm": { + "data": { + "host": "Host", + "port": "Porta" + } + }, + "manual_input": { + "data": { + "host": "Host", + "port": "Porta" + }, + "title": "Configurare il gateway deCONZ" + }, + "user": { + "data": { + "host": "Selezionare il gateway deCONZ rilevato" + }, + "title": "Selezionare il gateway deCONZ" + } + } + }, + "device_automation": { + "trigger_subtype": { + "both_buttons": "Entrambi", + "bottom_buttons": "Pulsanti inferiori", + "button_1": "Primo", + "button_2": "Secondo pulsante", + "button_3": "Terzo pulsante", + "button_4": "Quarto pulsante", + "close": "Chiudere", + "dim_down": "Diminuire luminosit\u00e0", + "dim_up": "Aumentare luminosit\u00e0", + "left": "Sinistra", + "open": "Aperto", + "right": "Destra", + "side_1": "Lato 1", + "side_2": "Lato 2", + "side_3": "Lato 3", + "side_4": "Lato 4", + "side_5": "Lato 5", + "side_6": "Lato 6", + "top_buttons": "Pulsanti superiori", + "turn_off": "Spegnere", + "turn_on": "Accendere" + }, + "trigger_type": { + "remote_awakened": "Dispositivo risvegliato", + "remote_button_double_press": "Pulsante \"{subtype}\" cliccato due volte", + "remote_button_long_press": "Pulsante \"{subtype}\" premuto continuamente", + "remote_button_long_release": "Pulsante \"{subtype}\" rilasciato dopo una lunga pressione", + "remote_button_quadruple_press": "Pulsante \"{subtype}\" cliccato quattro volte", + "remote_button_quintuple_press": "Pulsante \"{subtype}\" cliccato cinque volte", + "remote_button_rotated": "Pulsante ruotato \"{subtype}\"", + "remote_button_rotation_stopped": "La rotazione dei pulsanti \"{subtype}\" si \u00e8 arrestata", + "remote_button_short_press": "Pulsante \"{subtype}\" premuto", + "remote_button_short_release": "Pulsante \"{subtype}\" rilasciato", + "remote_button_triple_press": "Pulsante \"{subtype}\" cliccato tre volte", + "remote_double_tap": "Dispositivo \"{subtype}\" toccato due volte", + "remote_double_tap_any_side": "Dispositivo toccato due volte su qualsiasi lato", + "remote_falling": "Dispositivo in caduta libera", + "remote_flip_180_degrees": "Dispositivo capovolto di 180 gradi", + "remote_flip_90_degrees": "Dispositivo capovolto di 90 gradi", + "remote_gyro_activated": "Dispositivo in vibrazione", + "remote_moved": "Dispositivo spostato con \"{subtype}\" verso l'alto", + "remote_moved_any_side": "Dispositivo spostato con qualsiasi lato verso l'alto", + "remote_rotate_from_side_1": "Dispositivo ruotato da \"lato 1\" a \"{subtype}\"", + "remote_rotate_from_side_2": "Dispositivo ruotato da \"lato 2\" a \"{subtype}\"", + "remote_rotate_from_side_3": "Dispositivo ruotato da \"lato 3\" a \"{subtype}\"", + "remote_rotate_from_side_4": "Dispositivo ruotato da \"lato 4\" a \"{subtype}\"", + "remote_rotate_from_side_5": "Dispositivo ruotato da \"lato 5\" a \"{subtype}\"", + "remote_rotate_from_side_6": "Dispositivo ruotato da \"lato 6\" a \"{subtype}\"", + "remote_turned_clockwise": "Dispositivo ruotato in senso orario", + "remote_turned_counter_clockwise": "Dispositivo ruotato in senso antiorario" + } + }, + "options": { + "step": { + "deconz_devices": { + "data": { + "allow_clip_sensor": "Consentire sensori CLIP deCONZ", + "allow_deconz_groups": "Consentire gruppi luce deCONZ" + }, + "description": "Configurare la visibilit\u00e0 dei tipi di dispositivi deCONZ", + "title": "Opzioni 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..a1d40f49d343d --- /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": { + "manual_confirm": { + "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..3065ac361baac --- /dev/null +++ b/homeassistant/components/deconz/translations/ko.json @@ -0,0 +1,114 @@ +{ + "config": { + "abort": { + "already_configured": "\ube0c\ub9bf\uc9c0\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "already_in_progress": "\ube0c\ub9bf\uc9c0 \uad6c\uc131\uc774 \uc774\ubbf8 \uc9c4\ud589 \uc911\uc785\ub2c8\ub2e4.", + "no_bridges": "\ubc1c\uacac\ub41c deCONZ \ube0c\ub9bf\uc9c0\uac00 \uc5c6\uc2b5\ub2c8\ub2e4", + "not_deconz_bridge": "deCONZ \ube0c\ub9bf\uc9c0\uac00 \uc544\ub2d9\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" + }, + "flow_title": "deCONZ Zigbee \uac8c\uc774\ud2b8\uc6e8\uc774 ({host})", + "step": { + "hassio_confirm": { + "description": "Hass.io {addon} \uc560\ub4dc\uc628\uc5d0\uc11c \uc81c\uacf5\ub41c 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": { + "title": "deCONZ \uac8c\uc774\ud2b8\uc6e8\uc774 \uc815\uc758\ud558\uae30" + }, + "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 \uc5f0\uacb0\ud558\uae30" + }, + "manual_confirm": { + "data": { + "host": "\ud638\uc2a4\ud2b8", + "port": "\ud3ec\ud2b8" + } + }, + "manual_input": { + "data": { + "host": "\ud638\uc2a4\ud2b8", + "port": "\ud3ec\ud2b8" + }, + "title": "deCONZ \uac8c\uc774\ud2b8\uc6e8\uc774 \uad6c\uc131\ud558\uae30" + }, + "user": { + "data": { + "host": "\ubc1c\uacac\ub41c deCONZ \uac8c\uc774\ud2b8\uc6e8\uc774 \uc120\ud0dd" + }, + "title": "deCONZ \uac8c\uc774\ud2b8\uc6e8\uc774 \uc120\ud0dd\ud558\uae30" + } + } + }, + "device_automation": { + "trigger_subtype": { + "both_buttons": "\ub450 \uac1c", + "bottom_buttons": "\ud558\ub2e8 \ubc84\ud2bc", + "button_1": "\uccab \ubc88\uc9f8", + "button_2": "\ub450 \ubc88\uc9f8", + "button_3": "\uc138 \ubc88\uc9f8", + "button_4": "\ub124 \ubc88\uc9f8", + "close": "\ub2eb\uae30", + "dim_down": "\uc5b4\ub461\uac8c \ud558\uae30", + "dim_up": "\ubc1d\uac8c \ud558\uae30", + "left": "\uc67c\ucabd", + "open": "\uc5f4\uae30", + "right": "\uc624\ub978\ucabd", + "side_1": "\uba74 1", + "side_2": "\uba74 2", + "side_3": "\uba74 3", + "side_4": "\uba74 4", + "side_5": "\uba74 5", + "side_6": "\uba74 6", + "top_buttons": "\uc0c1\ub2e8 \ubc84\ud2bc", + "turn_off": "\ub044\uae30", + "turn_on": "\ucf1c\uae30" + }, + "trigger_type": { + "remote_awakened": "\uae30\uae30 \uc808\uc804 \ubaa8\ub4dc \ud574\uc81c\ub420 \ub54c", + "remote_button_double_press": "\"{subtype}\" \ubc84\ud2bc\uc774 \ub450 \ubc88 \ub20c\ub9b4 \ub54c", + "remote_button_long_press": "\"{subtype}\" \ubc84\ud2bc\uc774 \uacc4\uc18d \ub20c\ub824\uc9c8 \ub54c", + "remote_button_long_release": "\"{subtype}\" \ubc84\ud2bc\uc774 \uae38\uac8c \ub20c\ub838\ub2e4\uac00 \uc190\uc744 \ub5c4 \ub54c", + "remote_button_quadruple_press": "\"{subtype}\" \ubc84\ud2bc\uc774 \ub124 \ubc88 \ub20c\ub9b4 \ub54c", + "remote_button_quintuple_press": "\"{subtype}\" \ubc84\ud2bc\uc774 \ub2e4\uc12f \ubc88 \ub20c\ub9b4 \ub54c", + "remote_button_rotated": "\"{subtype}\" \ub85c \ubc84\ud2bc\uc774 \ud68c\uc804\ub420 \ub54c", + "remote_button_rotation_stopped": "\"{subtype}\" \ub85c \ubc84\ud2bc\uc774 \ud68c\uc804\uc744 \uba48\ucd9c \ub54c", + "remote_button_short_press": "\"{subtype}\" \ubc84\ud2bc\uc774 \ub20c\ub9b4 \ub54c", + "remote_button_short_release": "\"{subtype}\" \ubc84\ud2bc\uc5d0\uc11c \uc190\uc744 \ub5c4 \ub54c", + "remote_button_triple_press": "\"{subtype}\" \ubc84\ud2bc\uc774 \uc138 \ubc88 \ub20c\ub9b4 \ub54c", + "remote_double_tap": "\uae30\uae30\uc758 \"{subtype}\" \uac00 \ub354\ube14 \ud0ed \ub420 \ub54c", + "remote_double_tap_any_side": "\uae30\uae30\uc758 \uc544\ubb34 \uba74\uc774 \ub354\ube14 \ud0ed \ub420 \ub54c", + "remote_falling": "\uae30\uae30\uac00 \ub5a8\uc5b4\uc9c8 \ub54c", + "remote_flip_180_degrees": "\uae30\uae30\uac00 180\ub3c4\ub85c \ub4a4\uc9d1\uc5b4\uc9c8 \ub54c", + "remote_flip_90_degrees": "\uae30\uae30\uac00 90\ub3c4\ub85c \ub4a4\uc9d1\uc5b4\uc9c8 \ub54c", + "remote_gyro_activated": "\uae30\uae30\uac00 \ud754\ub4e4\ub9b4 \ub54c", + "remote_moved": "\uae30\uae30\uc758 \"{subtype}\" \uac00 \uc704\ub85c \ud5a5\ud55c\ucc44\ub85c \uc6c0\uc9c1\uc77c \ub54c", + "remote_moved_any_side": "\uae30\uae30\uc758 \uc544\ubb34 \uba74\uc774\ub098 \uc704\ub85c \ud5a5\ud55c\ucc44\ub85c \uc6c0\uc9c1\uc77c \ub54c", + "remote_rotate_from_side_1": "\"\uba74 1\" \uc5d0\uc11c \"{subtype}\" \ub85c \uae30\uae30\uac00 \ud68c\uc804\ub420 \ub54c", + "remote_rotate_from_side_2": "\"\uba74 2\" \uc5d0\uc11c \"{subtype}\" \ub85c \uae30\uae30\uac00 \ud68c\uc804\ub420 \ub54c", + "remote_rotate_from_side_3": "\"\uba74 3\" \uc5d0\uc11c \"{subtype}\" \ub85c \uae30\uae30\uac00 \ud68c\uc804\ub420 \ub54c", + "remote_rotate_from_side_4": "\"\uba74 4\" \uc5d0\uc11c \"{subtype}\" \ub85c \uae30\uae30\uac00 \ud68c\uc804\ub420 \ub54c", + "remote_rotate_from_side_5": "\"\uba74 5\" \uc5d0\uc11c \"{subtype}\" \ub85c \uae30\uae30\uac00 \ud68c\uc804\ub420 \ub54c", + "remote_rotate_from_side_6": "\"\uba74 6\" \uc5d0\uc11c \"{subtype}\" \ub85c \uae30\uae30\uac00 \ud68c\uc804\ub420 \ub54c", + "remote_turned_clockwise": "\uc2dc\uacc4 \ubc29\ud5a5\uc73c\ub85c \uae30\uae30\uac00 \ud68c\uc804\ub420 \ub54c", + "remote_turned_counter_clockwise": "\ubc18\uc2dc\uacc4 \ubc29\ud5a5\uc73c\ub85c \uae30\uae30\uac00 \ud68c\uc804\ub420 \ub54c" + } + }, + "options": { + "step": { + "deconz_devices": { + "data": { + "allow_clip_sensor": "deCONZ CLIP \uc13c\uc11c \ud5c8\uc6a9", + "allow_deconz_groups": "deCONZ \ub77c\uc774\ud2b8 \uadf8\ub8f9 \ud5c8\uc6a9" + }, + "description": "deCONZ \uae30\uae30 \uc720\ud615\uc758 \ud45c\uc2dc \uc5ec\ubd80 \uad6c\uc131", + "title": "deCONZ \uc635\uc158" + } + } + } +} \ 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..c6f2dfbf189ea --- /dev/null +++ b/homeassistant/components/deconz/translations/lb.json @@ -0,0 +1,114 @@ +{ + "config": { + "abort": { + "already_configured": "Bridge ass schon konfigur\u00e9iert", + "already_in_progress": "Konfiguratioun fir d\u00ebsen Apparat ass schonn am gaang.", + "no_bridges": "Keng dECONZ bridges fonnt", + "not_deconz_bridge": "Keng deCONZ Bridge", + "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" + }, + "flow_title": "deCONZ Zigbee gateway ({host})", + "step": { + "hassio_confirm": { + "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": { + "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" + }, + "manual_confirm": { + "data": { + "host": "Host", + "port": "Port" + } + }, + "manual_input": { + "data": { + "host": "Apparat", + "port": "Port" + }, + "title": "deCONZ Gateway ariichten" + }, + "user": { + "data": { + "host": "Entdeckte deCONZ Gateway auswielen" + }, + "title": "deCONZ Gateway auswielen" + } + } + }, + "device_automation": { + "trigger_subtype": { + "both_buttons": "B\u00e9id Kn\u00e4ppchen", + "bottom_buttons": "\u00cbnnescht Kn\u00e4ppchen", + "button_1": "\u00c9ischte Kn\u00e4ppchen", + "button_2": "Zweete Kn\u00e4ppchen", + "button_3": "Dr\u00ebtte Kn\u00e4ppchen", + "button_4": "V\u00e9ierte Kn\u00e4ppchen", + "close": "Zoumaachen", + "dim_down": "Verd\u00e4ischteren", + "dim_up": "Erhellen", + "left": "L\u00e9nks", + "open": "Op", + "right": "Riets", + "side_1": "S\u00e4it 1", + "side_2": "S\u00e4it 2", + "side_3": "S\u00e4it 3", + "side_4": "S\u00e4it 4", + "side_5": "S\u00e4it 5", + "side_6": "S\u00e4it 6", + "top_buttons": "Iewescht Kn\u00e4ppchen", + "turn_off": "Ausschalten", + "turn_on": "Uschalten" + }, + "trigger_type": { + "remote_awakened": "Apparat erw\u00e4cht", + "remote_button_double_press": "\"{subtype}\" Kn\u00e4ppche zwee mol gedr\u00e9ckt", + "remote_button_long_press": "\"{subtype}\" Kn\u00e4ppche permanent gedr\u00e9ckt", + "remote_button_long_release": "\"{subtype}\" Kn\u00e4ppche no laangem unhalen lassgelooss", + "remote_button_quadruple_press": "\"{subtype}\" Kn\u00e4ppche v\u00e9ier mol gedr\u00e9ckt", + "remote_button_quintuple_press": "\"{subtype}\" Kn\u00e4ppche f\u00ebnnef mol gedr\u00e9ckt", + "remote_button_rotated": "Kn\u00e4ppche gedr\u00e9int \"{subtype}\"", + "remote_button_rotation_stopped": "Kn\u00e4ppchen Rotatioun \"{subtype}\" gestoppt", + "remote_button_short_press": "\"{subtype}\" Kn\u00e4ppche gedr\u00e9ckt", + "remote_button_short_release": "\"{subtype}\" Kn\u00e4ppche lassgelooss", + "remote_button_triple_press": "\"{subtype}\" Kn\u00e4ppche dr\u00e4imol gedr\u00e9ckt", + "remote_double_tap": "Apparat \"{subtype}\" zwee mol gedr\u00e9ckt", + "remote_double_tap_any_side": "Apparat gouf 2 mol ugetippt op enger S\u00e4it", + "remote_falling": "Apparat am fr\u00e4ie Fall", + "remote_flip_180_degrees": "Apparat \u00ebm 180 Grad gedr\u00e9int", + "remote_flip_90_degrees": "Apparat \u00ebm 90 Grad gedr\u00e9int", + "remote_gyro_activated": "Apparat ger\u00ebselt", + "remote_moved": "Apparat beweegt mat \"{subtype}\" erop", + "remote_moved_any_side": "Apparat gouf mat enger S\u00e4it bewegt", + "remote_rotate_from_side_1": "Apparat rot\u00e9iert vun der \"S\u00e4it 1\" op \"{subtype}\"", + "remote_rotate_from_side_2": "Apparat rot\u00e9iert vun der \"S\u00e4it 2\" op \"{subtype}\"", + "remote_rotate_from_side_3": "Apparat rot\u00e9iert vun der \"S\u00e4it 3\" op \"{subtype}\"", + "remote_rotate_from_side_4": "Apparat rot\u00e9iert vun der \"S\u00e4it 4\" op \"{subtype}\"", + "remote_rotate_from_side_5": "Apparat rot\u00e9iert vun der \"S\u00e4it 5\" op \"{subtype}\"", + "remote_rotate_from_side_6": "Apparat rot\u00e9iert vun der \"S\u00e4it\" 6 op \"{subtype}\"", + "remote_turned_clockwise": "Apparat mam Auere Wee gedr\u00e9int", + "remote_turned_counter_clockwise": "Apparat g\u00e9int den Auere Wee gedr\u00e9int" + } + }, + "options": { + "step": { + "deconz_devices": { + "data": { + "allow_clip_sensor": "deCONZ Clip Sensoren erlaben", + "allow_deconz_groups": "deCONZ Luucht Gruppen erlaben" + }, + "description": "Visibilit\u00e9it vun deCONZ Apparater konfigur\u00e9ieren", + "title": "deCONZ Optiounen" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/deconz/translations/lv.json b/homeassistant/components/deconz/translations/lv.json new file mode 100644 index 0000000000000..aceb121a36054 --- /dev/null +++ b/homeassistant/components/deconz/translations/lv.json @@ -0,0 +1,12 @@ +{ + "device_automation": { + "trigger_subtype": { + "both_buttons": "Abas pogas", + "button_1": "Pirm\u0101 poga", + "button_2": "Otr\u0101 poga", + "button_3": "Tre\u0161\u0101 poga", + "turn_off": "Izsl\u0113gt", + "turn_on": "Iesl\u0113gt" + } + } +} \ 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..8b0caa869f896 --- /dev/null +++ b/homeassistant/components/deconz/translations/nl.json @@ -0,0 +1,114 @@ +{ + "config": { + "abort": { + "already_configured": "Bridge is al geconfigureerd", + "already_in_progress": "Configuratiestroom voor bridge wordt al ingesteld.", + "no_bridges": "Geen deCONZ bruggen ontdekt", + "not_deconz_bridge": "Dit is geen deCONZ bridge", + "one_instance_only": "Component ondersteunt slechts \u00e9\u00e9n deCONZ instance", + "updated_instance": "DeCONZ-instantie bijgewerkt met nieuw host-adres" + }, + "error": { + "no_key": "Kon geen API-sleutel ophalen" + }, + "flow_title": "deCONZ Zigbee gateway ( {host} )", + "step": { + "hassio_confirm": { + "description": "Wilt u de Home Assistant configureren om verbinding te maken met de deCONZ gateway van de hass.io add-on {addon}?", + "title": "deCONZ Zigbee Gateway via Hass.io add-on" + }, + "init": { + "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" + }, + "manual_confirm": { + "data": { + "host": "Host", + "port": "Poort" + } + }, + "manual_input": { + "data": { + "host": "Host", + "port": "Poort" + }, + "title": "Configureer deCONZ gateway" + }, + "user": { + "data": { + "host": "Selecteer gevonden deCONZ gateway" + }, + "title": "Selecteer DeCONZ gateway" + } + } + }, + "device_automation": { + "trigger_subtype": { + "both_buttons": "Beide knoppen", + "bottom_buttons": "Onderste knoppen", + "button_1": "Eerste knop", + "button_2": "Tweede knop", + "button_3": "Derde knop", + "button_4": "Vierde knop", + "close": "Sluiten", + "dim_down": "Dim omlaag", + "dim_up": "Dim omhoog", + "left": "Links", + "open": "Open", + "right": "Rechts", + "side_1": "Zijde 1", + "side_2": "Zijde 2", + "side_3": "Zijde 3", + "side_4": "Zijde 4", + "side_5": "Zijde 5", + "side_6": "Zijde 6", + "top_buttons": "Bovenste knoppen", + "turn_off": "Uitschakelen", + "turn_on": "Inschakelen" + }, + "trigger_type": { + "remote_awakened": "Apparaat is gewekt", + "remote_button_double_press": "\"{subtype}\" knop dubbel geklikt", + "remote_button_long_press": "\" {subtype} \" knop continu ingedrukt", + "remote_button_long_release": "\"{subtype}\" knop losgelaten na lang indrukken van de knop", + "remote_button_quadruple_press": "\" {subtype} \" knop viervoudig aangeklikt", + "remote_button_quintuple_press": "\" {subtype} \" knop vijf keer aangeklikt", + "remote_button_rotated": "Knop gedraaid \" {subtype} \"", + "remote_button_rotation_stopped": "Knoprotatie \" {subtype} \" gestopt", + "remote_button_short_press": "\" {subtype} \" knop ingedrukt", + "remote_button_short_release": "\"{subtype}\" knop losgelaten", + "remote_button_triple_press": "\" {subtype} \" knop driemaal geklikt", + "remote_double_tap": "Apparaat \"{subtype}\" dubbel getikt", + "remote_double_tap_any_side": "Apparaat dubbel getikt aan elke kant", + "remote_falling": "Apparaat in vrije val", + "remote_flip_180_degrees": "Apparaat 180 graden omgedraaid", + "remote_flip_90_degrees": "Apparaat 90 graden omgedraaid", + "remote_gyro_activated": "Apparaat geschud", + "remote_moved": "Apparaat verplaatst met \"{subtype}\" omhoog", + "remote_moved_any_side": "Apparaat gedraaid met elke kant naar boven", + "remote_rotate_from_side_1": "Apparaat gedraaid van \"zijde 1\" naar \"{subtype}\"\".", + "remote_rotate_from_side_2": "Apparaat gedraaid van \"zijde 2\" naar \"{subtype}\"\".", + "remote_rotate_from_side_3": "Apparaat gedraaid van \"zijde 3\" naar \" {subtype} \"", + "remote_rotate_from_side_4": "Apparaat gedraaid van \"zijde 4\" naar \" {subtype} \"", + "remote_rotate_from_side_5": "Apparaat gedraaid van \"zijde 5\" naar \" {subtype} \"", + "remote_rotate_from_side_6": "Apparaat gedraaid van \"zijde 6\" naar \" {subtype} \"", + "remote_turned_clockwise": "Apparaat met de klok mee gedraaid", + "remote_turned_counter_clockwise": "Apparaat tegen de klok in gedraaid" + } + }, + "options": { + "step": { + "deconz_devices": { + "data": { + "allow_clip_sensor": "DeCONZ CLIP sensoren toestaan", + "allow_deconz_groups": "Sta deCONZ-lichtgroepen toe" + }, + "description": "Configureer de zichtbaarheid van deCONZ-apparaattypen", + "title": "deCONZ opties" + } + } + } +} \ 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..d6d73478a0b29 --- /dev/null +++ b/homeassistant/components/deconz/translations/nn.json @@ -0,0 +1,27 @@ +{ + "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": { + "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" + }, + "manual_confirm": { + "data": { + "host": "Vert", + "port": "Port (standardverdi: '80')" + } + } + } + } +} \ 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..e1587a07957e4 --- /dev/null +++ b/homeassistant/components/deconz/translations/no.json @@ -0,0 +1,114 @@ +{ + "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" + }, + "flow_title": "", + "step": { + "hassio_confirm": { + "description": "Vil du konfigurere Home Assistant til \u00e5 koble seg til deCONZ-gateway levert av Hass.io-tillegget {addon} ?", + "title": "deCONZ Zigbee gateway via Hass.io tillegg" + }, + "init": { + "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" + }, + "manual_confirm": { + "data": { + "host": "Vert", + "port": "" + } + }, + "manual_input": { + "data": { + "host": "Vert", + "port": "" + }, + "title": "Konfigurer deCONZ gateway" + }, + "user": { + "data": { + "host": "Velg oppdaget deCONZ gateway" + }, + "title": "Velg deCONZ gateway" + } + } + }, + "device_automation": { + "trigger_subtype": { + "both_buttons": "Begge knappene", + "bottom_buttons": "Nederste knappene", + "button_1": "F\u00f8rste knapp", + "button_2": "Andre knapp", + "button_3": "Tredje knapp", + "button_4": "Fjerde knapp", + "close": "Lukk", + "dim_down": "Dimm ned", + "dim_up": "Dimm opp", + "left": "Venstre", + "open": "\u00c5pen", + "right": "H\u00f8yre", + "side_1": "", + "side_2": "", + "side_3": "", + "side_4": "", + "side_5": "", + "side_6": "", + "top_buttons": "\u00d8verste knappene", + "turn_off": "Skru av", + "turn_on": "Sl\u00e5 p\u00e5" + }, + "trigger_type": { + "remote_awakened": "Enheten ble vekket", + "remote_button_double_press": "\"{subtype}\"-knappen ble dobbeltklikket", + "remote_button_long_press": "\"{subtype}\"-knappen ble kontinuerlig trykket", + "remote_button_long_release": "\"{subtype}\"-knappen sluppet etter langt trykk", + "remote_button_quadruple_press": "\"{subtype}\"-knappen ble firedoblet klikket", + "remote_button_quintuple_press": "\"{subtype}\"-knappen femdobbelt klikket", + "remote_button_rotated": "Knappen roterte \"{subtype}\"", + "remote_button_rotation_stopped": "Knapperotasjon \"{subtype}\" stoppet", + "remote_button_short_press": "\"{subtype}\" -knappen ble trykket", + "remote_button_short_release": "\"{subtype}\"-knappen sluppet", + "remote_button_triple_press": "\"{subtype}\"-knappen trippel klikket", + "remote_double_tap": "Enheten \" {subtype} \" dobbeltklikket", + "remote_double_tap_any_side": "Enheten dobbeltklikket p\u00e5 alle sider", + "remote_falling": "Enheten er i fritt fall", + "remote_flip_180_degrees": "Enheten er snudd 180 grader", + "remote_flip_90_degrees": "Enheten er snudd 90 grader", + "remote_gyro_activated": "Enhet er ristet", + "remote_moved": "Enheten ble flyttet med \"{under type}\" opp", + "remote_moved_any_side": "Enheten flyttet med alle sider opp", + "remote_rotate_from_side_1": "Enheten rotert fra \"side 1\" til \" {subtype} \"", + "remote_rotate_from_side_2": "Enheten rotert fra \"side 2\" til \" {subtype} \"", + "remote_rotate_from_side_3": "Enheten rotert fra \"side 3\" til \" {subtype} \"", + "remote_rotate_from_side_4": "Enheten rotert fra \"side 4\" til \" {subtype} \"", + "remote_rotate_from_side_5": "Enheten rotert fra \"side 5\" til \" {subtype} \"", + "remote_rotate_from_side_6": "Enheten rotert fra \"side 6\" til \" {subtype} \"", + "remote_turned_clockwise": "Enheten dreide med klokken", + "remote_turned_counter_clockwise": "Enheten dreide mot klokken" + } + }, + "options": { + "step": { + "deconz_devices": { + "data": { + "allow_clip_sensor": "Tillat deCONZ CLIP-sensorer", + "allow_deconz_groups": "Tillat deCONZ lys grupper" + }, + "description": "Konfigurere synlighet av deCONZ enhetstyper", + "title": "deCONZ alternativer" + } + } + } +} \ 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..a9bff09864468 --- /dev/null +++ b/homeassistant/components/deconz/translations/pl.json @@ -0,0 +1,114 @@ +{ + "config": { + "abort": { + "already_configured": "Mostek jest ju\u017c skonfigurowany.", + "already_in_progress": "Konfiguracja mostka jest ju\u017c w toku.", + "no_bridges": "Nie odkryto mostk\u00f3w deCONZ", + "not_deconz_bridge": "To nie jest mostek 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" + }, + "flow_title": "Bramka deCONZ Zigbee ({host})", + "step": { + "hassio_confirm": { + "description": "Czy chcesz skonfigurowa\u0107 Home Assistant, 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": { + "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 ustawienia deCONZ > bramka > Zaawansowane\n 2. Naci\u015bnij przycisk \"Uwierzytelnij aplikacj\u0119\"", + "title": "Po\u0142\u0105cz z deCONZ" + }, + "manual_confirm": { + "data": { + "host": "Nazwa hosta lub adres IP", + "port": "Port" + } + }, + "manual_input": { + "data": { + "host": "Nazwa hosta lub adres IP", + "port": "Port" + }, + "title": "Konfiguracja bramki deCONZ" + }, + "user": { + "data": { + "host": "Wybierz znalezion\u0105 bramk\u0119 deCONZ" + }, + "title": "Wybierz bramk\u0119 deCONZ" + } + } + }, + "device_automation": { + "trigger_subtype": { + "both_buttons": "oba przyciski", + "bottom_buttons": "Dolne przyciski", + "button_1": "pierwszy przycisk", + "button_2": "drugi przycisk", + "button_3": "trzeci przycisk", + "button_4": "czwarty przycisk", + "close": "nast\u0105pi zamkni\u0119cie", + "dim_down": "nast\u0105pi zmniejszenie jasno\u015bci", + "dim_up": "nast\u0105pi zwi\u0119kszenie jasno\u015bci", + "left": "w lewo", + "open": "otwarcie", + "right": "w prawo", + "side_1": "strona 1", + "side_2": "strona 2", + "side_3": "strona 3", + "side_4": "strona 4", + "side_5": "strona 5", + "side_6": "strona 6", + "top_buttons": "G\u00f3rne przyciski", + "turn_off": "nast\u0105pi wy\u0142\u0105czenie", + "turn_on": "nast\u0105pi w\u0142\u0105czenie" + }, + "trigger_type": { + "remote_awakened": "urz\u0105dzenie si\u0119 obudzi", + "remote_button_double_press": "przycisk \"{subtype}\" zostanie podw\u00f3jnie naci\u015bni\u0119ty", + "remote_button_long_press": "przycisk \"{subtype}\" zostanie naci\u015bni\u0119ty w spos\u00f3b ci\u0105g\u0142y", + "remote_button_long_release": "przycisk \"{subtype}\" zostanie zwolniony po d\u0142ugim naci\u015bni\u0119ciu", + "remote_button_quadruple_press": "przycisk \"{subtype}\" zostanie czterokrotnie naci\u015bni\u0119ty", + "remote_button_quintuple_press": "przycisk \"{subtype}\" zostanie pi\u0119ciokrotnie naci\u015bni\u0119ty", + "remote_button_rotated": "przycisk zostanie obr\u00f3cony \"{subtype}\"", + "remote_button_rotation_stopped": "nast\u0105pi zatrzymanie obrotu przycisku \"{subtype}\"", + "remote_button_short_press": "przycisk \"{subtype}\" zostanie naci\u015bni\u0119ty", + "remote_button_short_release": "przycisk \"{subtype}\" zostanie zwolniony", + "remote_button_triple_press": "przycisk \"{subtype}\" zostanie trzykrotnie naci\u015bni\u0119ty", + "remote_double_tap": "urz\u0105dzenie \"{subtype}\" zostanie dwukrotnie pukni\u0119te", + "remote_double_tap_any_side": "urz\u0105dzenie dwukrotnie pukni\u0119te z dowolnej strony", + "remote_falling": "urz\u0105dzenie zarejestruje swobodny spadek", + "remote_flip_180_degrees": "urz\u0105dzenie odwr\u00f3cone o 180 stopni", + "remote_flip_90_degrees": "urz\u0105dzenie odwr\u00f3cone o 90 stopni", + "remote_gyro_activated": "nast\u0105pi potrz\u0105\u015bni\u0119cie urz\u0105dzeniem", + "remote_moved": "urz\u0105dzenie poruszone z \"{subtype}\" w g\u00f3r\u0119", + "remote_moved_any_side": "urz\u0105dzenie przesuni\u0119te dowoln\u0105 stron\u0105 do g\u00f3ry", + "remote_rotate_from_side_1": "urz\u0105dzenie obr\u00f3cone ze \"strona 1\" na \"{subtype}\"", + "remote_rotate_from_side_2": "urz\u0105dzenie obr\u00f3cone ze \"strona 2\" na \"{subtype}\"", + "remote_rotate_from_side_3": "urz\u0105dzenie obr\u00f3cone ze \"strona 3\" na \"{subtype}\"", + "remote_rotate_from_side_4": "urz\u0105dzenie obr\u00f3cone ze \"strona 4\" na \"{subtype}\"", + "remote_rotate_from_side_5": "urz\u0105dzenie obr\u00f3cone ze \"strona 5\" na \"{subtype}\"", + "remote_rotate_from_side_6": "urz\u0105dzenie obr\u00f3cone ze \"strona 6\" na \"{subtype}\"", + "remote_turned_clockwise": "urz\u0105dzenie obr\u00f3cone zgodnie z ruchem wskaz\u00f3wek zegara", + "remote_turned_counter_clockwise": "urz\u0105dzenie obr\u00f3cone przeciwnie do ruchu wskaz\u00f3wek zegara" + } + }, + "options": { + "step": { + "deconz_devices": { + "data": { + "allow_clip_sensor": "Zezwalaj na czujniki deCONZ CLIP", + "allow_deconz_groups": "Zezwalaj na grupy \u015bwiate\u0142 deCONZ" + }, + "description": "Skonfiguruj widoczno\u015b\u0107 typ\u00f3w urz\u0105dze\u0144 deCONZ", + "title": "Opcje deCONZ" + } + } + } +} \ 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..f548b187103d5 --- /dev/null +++ b/homeassistant/components/deconz/translations/pt-BR.json @@ -0,0 +1,34 @@ +{ + "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": { + "hassio_confirm": { + "description": "Deseja configurar o Home Assistant para conectar-se ao gateway deCONZ fornecido pelo add-on hass.io {addon} ?", + "title": "Gateway deCONZ Zigbee via add-on Hass.io" + }, + "init": { + "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" + }, + "manual_confirm": { + "data": { + "host": "Hospedeiro", + "port": "Porta (valor padr\u00e3o: '80')" + } + } + } + } +} \ 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..b385af86ce536 --- /dev/null +++ b/homeassistant/components/deconz/translations/pt.json @@ -0,0 +1,61 @@ +{ + "config": { + "abort": { + "already_configured": "Bridge j\u00e1 est\u00e1 configurada", + "no_bridges": "Nenhum hub 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": { + "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" + }, + "manual_confirm": { + "data": { + "host": "Servidor", + "port": "Porta" + } + }, + "manual_input": { + "data": { + "host": "Servidor", + "port": "Porta" + } + } + } + }, + "device_automation": { + "trigger_subtype": { + "both_buttons": "Ambos os bot\u00f5es", + "bottom_buttons": "Bot\u00f5es inferiores", + "button_1": "Primeiro bot\u00e3o", + "button_2": "Segundo bot\u00e3o", + "button_3": "Terceiro bot\u00e3o", + "button_4": "Quarto bot\u00e3o", + "close": "Fechar", + "dim_down": "Escurecer", + "dim_up": "Clariar", + "left": "Esquerda", + "open": "Abrir", + "right": "Direita", + "side_1": "Lado 1", + "side_2": "Lado 2", + "side_3": "Lado 3", + "side_4": "Lado 4", + "side_5": "Lado 5", + "side_6": "Lado 6", + "top_buttons": "Bot\u00f5es superiores", + "turn_off": "Desligar", + "turn_on": "Ligar" + }, + "trigger_type": { + "remote_falling": "Dispositivo em queda livre" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/deconz/translations/ro.json b/homeassistant/components/deconz/translations/ro.json new file mode 100644 index 0000000000000..a997db44380bb --- /dev/null +++ b/homeassistant/components/deconz/translations/ro.json @@ -0,0 +1,14 @@ +{ + "config": { + "step": { + "link": { + "description": "Debloca\u021bi gateway-ul DECONZ pentru a v\u0103 \u00eenregistra la Home Assistant. \n\n 1. Accesa\u021bi Set\u0103rile deCONZ - > Gateway - > Avansat \n 2. Ap\u0103sa\u021bi butonul \u201eAutentifica\u021bi aplica\u021bia\u201d" + }, + "manual_confirm": { + "data": { + "port": "Port" + } + } + } + } +} \ 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..6e42969eb2972 --- /dev/null +++ b/homeassistant/components/deconz/translations/ru.json @@ -0,0 +1,114 @@ +{ + "config": { + "abort": { + "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u044d\u0442\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\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 \u044d\u0442\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u044f\u0435\u0442\u0441\u044f.", + "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 \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." + }, + "flow_title": "\u0428\u043b\u044e\u0437 Zigbee deCONZ ({host})", + "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 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": { + "title": "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" + }, + "manual_confirm": { + "data": { + "host": "\u0425\u043e\u0441\u0442", + "port": "\u041f\u043e\u0440\u0442" + } + }, + "manual_input": { + "data": { + "host": "\u0425\u043e\u0441\u0442", + "port": "\u041f\u043e\u0440\u0442" + }, + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0448\u043b\u044e\u0437\u0430 deCONZ" + }, + "user": { + "data": { + "host": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u043d\u044b\u0439 \u0448\u043b\u044e\u0437 deCONZ" + }, + "title": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0448\u043b\u044e\u0437 deCONZ" + } + } + }, + "device_automation": { + "trigger_subtype": { + "both_buttons": "\u041e\u0431\u0435 \u043a\u043d\u043e\u043f\u043a\u0438", + "bottom_buttons": "\u041d\u0438\u0436\u043d\u0438\u0435 \u043a\u043d\u043e\u043f\u043a\u0438", + "button_1": "\u041f\u0435\u0440\u0432\u0430\u044f \u043a\u043d\u043e\u043f\u043a\u0430", + "button_2": "\u0412\u0442\u043e\u0440\u0430\u044f \u043a\u043d\u043e\u043f\u043a\u0430", + "button_3": "\u0422\u0440\u0435\u0442\u044c\u044f \u043a\u043d\u043e\u043f\u043a\u0430", + "button_4": "\u0427\u0435\u0442\u0432\u0435\u0440\u0442\u0430\u044f \u043a\u043d\u043e\u043f\u043a\u0430", + "close": "\u0417\u0430\u043a\u0440\u044b\u0432\u0430\u0435\u0442\u0441\u044f", + "dim_down": "\u042f\u0440\u043a\u043e\u0441\u0442\u044c \u0443\u043c\u0435\u043d\u044c\u0448\u0430\u0435\u0442\u0441\u044f", + "dim_up": "\u042f\u0440\u043a\u043e\u0441\u0442\u044c \u0443\u0432\u0435\u043b\u0438\u0447\u0438\u0432\u0430\u0435\u0442\u0441\u044f", + "left": "\u041d\u0430\u043b\u0435\u0432\u043e", + "open": "\u041e\u0442\u043a\u0440\u044b\u0432\u0430\u0435\u0442\u0441\u044f", + "right": "\u041d\u0430\u043f\u0440\u0430\u0432\u043e", + "side_1": "\u0413\u0440\u0430\u043d\u044c 1", + "side_2": "\u0413\u0440\u0430\u043d\u044c 2", + "side_3": "\u0413\u0440\u0430\u043d\u044c 3", + "side_4": "\u0413\u0440\u0430\u043d\u044c 4", + "side_5": "\u0413\u0440\u0430\u043d\u044c 5", + "side_6": "\u0413\u0440\u0430\u043d\u044c 6", + "top_buttons": "\u0412\u0435\u0440\u0445\u043d\u0438\u0435 \u043a\u043d\u043e\u043f\u043a\u0438", + "turn_off": "\u0412\u044b\u043a\u043b\u044e\u0447\u0430\u0435\u0442\u0441\u044f", + "turn_on": "\u0412\u043a\u043b\u044e\u0447\u0430\u0435\u0442\u0441\u044f" + }, + "trigger_type": { + "remote_awakened": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0440\u0430\u0437\u0431\u0443\u0434\u0438\u043b\u0438", + "remote_button_double_press": "{subtype} \u043d\u0430\u0436\u0430\u0442\u0430 \u0434\u0432\u0430 \u0440\u0430\u0437\u0430", + "remote_button_long_press": "{subtype} \u043d\u0435\u043f\u0440\u0435\u0440\u044b\u0432\u043d\u043e \u043d\u0430\u0436\u0430\u0442\u0430", + "remote_button_long_release": "{subtype} \u043e\u0442\u043f\u0443\u0449\u0435\u043d\u0430 \u043f\u043e\u0441\u043b\u0435 \u043d\u0435\u043f\u0440\u0435\u0440\u044b\u0432\u043d\u043e\u0433\u043e \u043d\u0430\u0436\u0430\u0442\u0438\u044f", + "remote_button_quadruple_press": "{subtype} \u043d\u0430\u0436\u0430\u0442\u0430 \u0447\u0435\u0442\u044b\u0440\u0435 \u0440\u0430\u0437\u0430", + "remote_button_quintuple_press": "{subtype} \u043d\u0430\u0436\u0430\u0442\u0430 \u043f\u044f\u0442\u044c \u0440\u0430\u0437", + "remote_button_rotated": "{subtype} \u0432\u0440\u0430\u0449\u0430\u0435\u0442\u0441\u044f", + "remote_button_rotation_stopped": "{subtype} \u043f\u0440\u0435\u043a\u0440\u0430\u0442\u0438\u043b\u0430 \u0432\u0440\u0430\u0449\u0435\u043d\u0438\u0435", + "remote_button_short_press": "{subtype} \u043d\u0430\u0436\u0430\u0442\u0430", + "remote_button_short_release": "{subtype} \u043e\u0442\u043f\u0443\u0449\u0435\u043d\u0430", + "remote_button_triple_press": "{subtype} \u043d\u0430\u0436\u0430\u0442\u0430 \u0442\u0440\u0438 \u0440\u0430\u0437\u0430", + "remote_double_tap": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u043c {subtype} \u043f\u043e\u0441\u0442\u0443\u0447\u0430\u043b\u0438 \u0434\u0432\u0430\u0436\u0434\u044b", + "remote_double_tap_any_side": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u043c \u043f\u043e\u0441\u0442\u0443\u0447\u0430\u043b\u0438 \u0434\u0432\u0430\u0436\u0434\u044b", + "remote_falling": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0432 \u0441\u0432\u043e\u0431\u043e\u0434\u043d\u043e\u043c \u043f\u0430\u0434\u0435\u043d\u0438\u0438", + "remote_flip_180_degrees": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043f\u0435\u0440\u0435\u0432\u0435\u0440\u043d\u0443\u043b\u0438 \u043d\u0430 180 \u0433\u0440\u0430\u0434\u0443\u0441\u043e\u0432", + "remote_flip_90_degrees": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043f\u0435\u0440\u0435\u0432\u0435\u0440\u043d\u0443\u043b\u0438 \u043d\u0430 90 \u0433\u0440\u0430\u0434\u0443\u0441\u043e\u0432", + "remote_gyro_activated": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043f\u043e\u0442\u0440\u044f\u0441\u043b\u0438", + "remote_moved": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0441\u0434\u0432\u0438\u043d\u0443\u043b\u0438, \u043a\u043e\u0433\u0434\u0430 {subtype} \u0441\u0432\u0435\u0440\u0445\u0443", + "remote_moved_any_side": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0441\u0434\u0432\u0438\u043d\u0443\u043b\u0438", + "remote_rotate_from_side_1": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043f\u0435\u0440\u0435\u0432\u0435\u0440\u043d\u0443\u043b\u0438 \u0441 \u0413\u0440\u0430\u043d\u0438 1 \u043d\u0430 {subtype}", + "remote_rotate_from_side_2": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043f\u0435\u0440\u0435\u0432\u0435\u0440\u043d\u0443\u043b\u0438 \u0441 \u0413\u0440\u0430\u043d\u0438 2 \u043d\u0430 {subtype}", + "remote_rotate_from_side_3": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043f\u0435\u0440\u0435\u0432\u0435\u0440\u043d\u0443\u043b\u0438 \u0441 \u0413\u0440\u0430\u043d\u0438 3 \u043d\u0430 {subtype}", + "remote_rotate_from_side_4": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043f\u0435\u0440\u0435\u0432\u0435\u0440\u043d\u0443\u043b\u0438 \u0441 \u0413\u0440\u0430\u043d\u0438 4 \u043d\u0430 {subtype}", + "remote_rotate_from_side_5": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043f\u0435\u0440\u0435\u0432\u0435\u0440\u043d\u0443\u043b\u0438 \u0441 \u0413\u0440\u0430\u043d\u0438 5 \u043d\u0430 {subtype}", + "remote_rotate_from_side_6": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043f\u0435\u0440\u0435\u0432\u0435\u0440\u043d\u0443\u043b\u0438 \u0441 \u0413\u0440\u0430\u043d\u0438 6 \u043d\u0430 {subtype}", + "remote_turned_clockwise": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043f\u043e\u0432\u0435\u0440\u043d\u0443\u043b\u0438 \u043f\u043e \u0447\u0430\u0441\u043e\u0432\u043e\u0439 \u0441\u0442\u0440\u0435\u043b\u043a\u0435", + "remote_turned_counter_clockwise": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043f\u043e\u0432\u0435\u0440\u043d\u0443\u043b\u0438 \u043f\u0440\u043e\u0442\u0438\u0432 \u0447\u0430\u0441\u043e\u0432\u043e\u0439 \u0441\u0442\u0440\u0435\u043b\u043a\u0438" + } + }, + "options": { + "step": { + "deconz_devices": { + "data": { + "allow_clip_sensor": "\u041e\u0442\u043e\u0431\u0440\u0430\u0436\u0430\u0442\u044c \u0441\u0435\u043d\u0441\u043e\u0440\u044b deCONZ CLIP", + "allow_deconz_groups": "\u041e\u0442\u043e\u0431\u0440\u0430\u0436\u0430\u0442\u044c \u0433\u0440\u0443\u043f\u043f\u044b \u043e\u0441\u0432\u0435\u0449\u0435\u043d\u0438\u044f deCONZ" + }, + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0432\u0438\u0434\u0438\u043c\u043e\u0441\u0442\u0438 \u0442\u0438\u043f\u043e\u0432 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432 deCONZ", + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 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..3d7902ef1a0ce --- /dev/null +++ b/homeassistant/components/deconz/translations/sl.json @@ -0,0 +1,101 @@ +{ + "config": { + "abort": { + "already_configured": "Most je \u017ee nastavljen", + "already_in_progress": "Konfiguracijski tok za most je \u017ee v teku.", + "no_bridges": "Ni odkritih mostov deCONZ", + "not_deconz_bridge": "Ni deCONZ most", + "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" + }, + "flow_title": "deCONZ Zigbee prehod ({host})", + "step": { + "hassio_confirm": { + "description": "Ali \u017eelite konfigurirati Home Assistant za povezavo s prehodom deCONZ, ki ga ponuja dodatek Hass.io {addon} ?", + "title": "deCONZ Zigbee prehod preko dodatka Hass.io" + }, + "init": { + "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" + }, + "manual_confirm": { + "data": { + "host": "Gostitelj", + "port": "Vrata" + } + } + } + }, + "device_automation": { + "trigger_subtype": { + "both_buttons": "Oba gumba", + "bottom_buttons": "Spodnji gumbi", + "button_1": "Prvi gumb", + "button_2": "Drugi gumb", + "button_3": "Tretji gumb", + "button_4": "\u010cetrti gumb", + "close": "Zapri", + "dim_down": "Zatemnite", + "dim_up": "pove\u010dajte mo\u010d", + "left": "Levo", + "open": "Odprto", + "right": "Desno", + "side_1": "Stran 1", + "side_2": "Stran 2", + "side_3": "Stran 3", + "side_4": "Stran 4", + "side_5": "Stran 5", + "side_6": "Stran 6", + "top_buttons": "Zgornji gumbi", + "turn_off": "Ugasni", + "turn_on": "Pri\u017egi" + }, + "trigger_type": { + "remote_awakened": "Naprava se je prebudila", + "remote_button_double_press": "Dvakrat kliknete gumb \"{subtype}\"", + "remote_button_long_press": "\"{subtype}\" gumb neprekinjeno pritisnjen", + "remote_button_long_release": "\"{subtype}\" gumb spro\u0161\u010den po dolgem pritisku", + "remote_button_quadruple_press": "\"{subtype}\" gumb \u0161tirikrat kliknjen", + "remote_button_quintuple_press": "\"{subtype}\" gumb petkrat kliknjen", + "remote_button_rotated": "Gumb \"{subtype}\" zasukan", + "remote_button_rotation_stopped": "Vrtenje \"{subtype}\" gumba se je ustavilo", + "remote_button_short_press": "Pritisnjen \"{subtype}\" gumb", + "remote_button_short_release": "Gumb \"{subtype}\" spro\u0161\u010den", + "remote_button_triple_press": "Gumb \"{subtype}\" trikrat kliknjen", + "remote_double_tap": "Naprava \"{subtype}\" dvakrat dotaknjena", + "remote_double_tap_any_side": "Naprava je bila dvojno tapnjena na katerokoli stran", + "remote_falling": "Naprava v prostem padu", + "remote_flip_180_degrees": "Naprava se je obrnila za 180 stopinj", + "remote_flip_90_degrees": "Naprava se je obrnila za 90 stopinj", + "remote_gyro_activated": "Naprava se je pretresla", + "remote_moved": "Naprava je premaknjena s \"{subtype}\" navzgor", + "remote_moved_any_side": "Naprava se je premikala s katero koli stranjo navzgor", + "remote_rotate_from_side_1": "Naprava je zasukana iz \"strani 1\" v \"{subtype}\"", + "remote_rotate_from_side_2": "Naprava je zasukana iz \"strani 2\" v \"{subtype}\"", + "remote_rotate_from_side_3": "Naprava je zasukana iz \"strani 3\" v \"{subtype}\"", + "remote_rotate_from_side_4": "Naprava je zasukana iz \"strani 4\" v \"{subtype}\"", + "remote_rotate_from_side_5": "Naprava je zasukana iz \"strani 5\" v \"{subtype}\"", + "remote_rotate_from_side_6": "Naprava je zasukana iz \"strani 6\" v \"{subtype}\"", + "remote_turned_clockwise": "Naprava se je obrnila v smeri urinega kazalca", + "remote_turned_counter_clockwise": "Naprava se je obrnila v nasprotni smeri urinega kazalca" + } + }, + "options": { + "step": { + "deconz_devices": { + "data": { + "allow_clip_sensor": "Dovoli deCONZ CLIP senzorje", + "allow_deconz_groups": "Dovolite deCONZ skupine lu\u010di" + }, + "description": "Konfiguracija vidnosti tipov naprav deCONZ", + "title": "mo\u017enosti deCONZ" + } + } + } +} \ 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..e7e0f5d917f55 --- /dev/null +++ b/homeassistant/components/deconz/translations/sv.json @@ -0,0 +1,104 @@ +{ + "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" + }, + "flow_title": "deCONZ Zigbee gateway ({host})", + "step": { + "hassio_confirm": { + "description": "Vill du konfigurera Home Assistant att ansluta till den deCONZ-gateway som tillhandah\u00e5lls av Hass.io-till\u00e4gget {addon}?", + "title": "deCONZ Zigbee gateway via Hass.io till\u00e4gg" + }, + "init": { + "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" + }, + "manual_confirm": { + "data": { + "host": "V\u00e4rd", + "port": "Port (standardv\u00e4rde: '80')" + } + }, + "manual_input": { + "data": { + "host": "V\u00e4rd", + "port": "Port" + } + } + } + }, + "device_automation": { + "trigger_subtype": { + "both_buttons": "B\u00e5da knapparna", + "button_1": "F\u00f6rsta knappen", + "button_2": "Andra knappen", + "button_3": "Tredje knappen", + "button_4": "Fj\u00e4rde knappen", + "close": "St\u00e4ng", + "dim_down": "Dimma ned", + "dim_up": "Dimma upp", + "left": "V\u00e4nster", + "open": "\u00d6ppen", + "right": "H\u00f6ger", + "side_1": "Sida 1", + "side_2": "Sida 2", + "side_3": "Sida 3", + "side_4": "Sida 4", + "side_5": "Sida 5", + "side_6": "Sida 6", + "turn_off": "St\u00e4ng av", + "turn_on": "Starta" + }, + "trigger_type": { + "remote_awakened": "Enheten v\u00e4cktes", + "remote_button_double_press": "\"{subtype}\"-knappen dubbelklickades", + "remote_button_long_press": "\"{subtype}\"-knappen kontinuerligt nedtryckt", + "remote_button_long_release": "\"{subtype}\"-knappen sl\u00e4pptes efter ett l\u00e5ngttryck", + "remote_button_quadruple_press": "\"{subtype}\"-knappen klickades \nfyrfaldigt", + "remote_button_quintuple_press": "\"{subtype}\"-knappen klickades \nfemfaldigt", + "remote_button_rotated": "Knappen roterade \"{subtype}\"", + "remote_button_rotation_stopped": "Knapprotationen \"{subtype}\" stoppades", + "remote_button_short_press": "\"{subtype}\"-knappen trycktes in", + "remote_button_short_release": "\"{subtype}\"-knappen sl\u00e4ppt", + "remote_button_triple_press": "\"{subtype}\"-knappen trippelklickad", + "remote_double_tap": "Enheten \"{subtype}\" dubbeltryckt", + "remote_double_tap_any_side": "Enheten dubbeltryckt p\u00e5 valfri sida", + "remote_falling": "Enhet i fritt fall", + "remote_flip_180_degrees": "Enheten v\u00e4nd 180 grader", + "remote_flip_90_degrees": "Enheten v\u00e4nd 90 grader", + "remote_gyro_activated": "Enhet skakad", + "remote_moved": "Enheten flyttades med \"{subtype}\" upp", + "remote_moved_any_side": "Enheten flyttades med valfri sida upp\u00e5t", + "remote_rotate_from_side_1": "Enheten roterades fr\u00e5n \"sida 1\" till \"{subtype}\"", + "remote_rotate_from_side_2": "Enheten roterades fr\u00e5n \"sida 2\" till \"{subtype}\"", + "remote_rotate_from_side_3": "Enheten roterades fr\u00e5n \"sida 3\" till \"{subtype}\"", + "remote_rotate_from_side_4": "Enheten roterades fr\u00e5n \"sida 4\" till \"{subtype}\"", + "remote_rotate_from_side_5": "Enheten roterades fr\u00e5n \"sida 5\" till \"{subtype}\"", + "remote_rotate_from_side_6": "Enheten roterades fr\u00e5n \"sida 6\" till \"{subtype}\"", + "remote_turned_clockwise": "Enheten vriden medurs", + "remote_turned_counter_clockwise": "Enheten v\u00e4nde moturs" + } + }, + "options": { + "step": { + "deconz_devices": { + "data": { + "allow_clip_sensor": "Till\u00e5t deCONZ CLIP-sensorer", + "allow_deconz_groups": "Till\u00e5t deCONZ ljusgrupper" + }, + "description": "Konfigurera synlighet f\u00f6r deCONZ-enhetstyper" + } + } + } +} \ 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..db5e0efae1043 --- /dev/null +++ b/homeassistant/components/deconz/translations/th.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "manual_confirm": { + "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..95880d43e39bf --- /dev/null +++ b/homeassistant/components/deconz/translations/vi.json @@ -0,0 +1,19 @@ +{ + "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": { + "manual_confirm": { + "data": { + "port": "C\u1ed5ng (gi\u00e1 tr\u1ecb m\u1eb7c \u0111\u1ecbnh: '80')" + } + } + } + } +} \ 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..1152d5c394d8d --- /dev/null +++ b/homeassistant/components/deconz/translations/zh-Hans.json @@ -0,0 +1,57 @@ +{ + "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": { + "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" + }, + "manual_confirm": { + "data": { + "host": "\u4e3b\u673a", + "port": "\u7aef\u53e3\uff08\u9ed8\u8ba4\u503c\uff1a'80'\uff09" + } + } + } + }, + "device_automation": { + "trigger_subtype": { + "side_1": "\u7b2c 1 \u9762", + "side_2": "\u7b2c 2 \u9762", + "side_3": "\u7b2c 3 \u9762", + "side_4": "\u7b2c 4 \u9762", + "side_5": "\u7b2c 5 \u9762", + "side_6": "\u7b2c 6 \u9762" + }, + "trigger_type": { + "remote_awakened": "\u8bbe\u5907\u5524\u9192", + "remote_double_tap": "\u8bbe\u5907\u7684\u201c{subtype}\u201d\u88ab\u8f7b\u6572\u4e24\u6b21", + "remote_falling": "\u8bbe\u5907\u81ea\u7531\u843d\u4f53", + "remote_gyro_activated": "\u8bbe\u5907\u6447\u6643", + "remote_moved": "\u8bbe\u5907\u6c34\u5e73\u79fb\u52a8\u4e14\u201c{subtype}\u201d\u671d\u4e0a", + "remote_rotate_from_side_1": "\u8bbe\u5907\u4ece\u201c\u7b2c 1 \u9762\u201d\u7ffb\u8f6c\u5230\u201c{subtype}\u201d", + "remote_rotate_from_side_2": "\u8bbe\u5907\u4ece\u201c\u7b2c 2 \u9762\u201d\u7ffb\u8f6c\u5230\u201c{subtype}\u201d", + "remote_rotate_from_side_3": "\u8bbe\u5907\u4ece\u201c\u7b2c 3 \u9762\u201d\u7ffb\u8f6c\u5230\u201c{subtype}\u201d", + "remote_rotate_from_side_4": "\u8bbe\u5907\u4ece\u201c\u7b2c 4 \u9762\u201d\u7ffb\u8f6c\u5230\u201c{subtype}\u201d", + "remote_rotate_from_side_5": "\u8bbe\u5907\u4ece\u201c\u7b2c 5 \u9762\u201d\u7ffb\u8f6c\u5230\u201c{subtype}\u201d", + "remote_rotate_from_side_6": "\u8bbe\u5907\u4ece\u201c\u7b2c 6 \u9762\u201d\u7ffb\u8f6c\u5230\u201c{subtype}\u201d" + } + }, + "options": { + "step": { + "deconz_devices": { + "title": "deCONZ \u9009\u9879" + } + } + } +} \ 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..e80524ce23fc9 --- /dev/null +++ b/homeassistant/components/deconz/translations/zh-Hant.json @@ -0,0 +1,114 @@ +{ + "config": { + "abort": { + "already_configured": "Bridge \u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "already_in_progress": "Bridge \u8a2d\u5b9a\u5df2\u7d93\u9032\u884c\u4e2d\u3002", + "no_bridges": "\u672a\u641c\u5c0b\u5230 deCONZ Bridfe", + "not_deconz_bridge": "\u975e deCONZ Bridge \u8a2d\u5099", + "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 \u7269\u4ef6" + }, + "error": { + "no_key": "\u7121\u6cd5\u53d6\u5f97 API key" + }, + "flow_title": "deCONZ Zigbee \u9598\u9053\u5668\uff08{host}\uff09", + "step": { + "hassio_confirm": { + "description": "\u662f\u5426\u8981\u8a2d\u5b9a Home Assistant \u4ee5\u9023\u7dda\u81f3 Hass.io \u9644\u52a0\u6574\u5408 {addon} \u4e4b deCONZ \u9598\u9053\u5668\uff1f", + "title": "\u900f\u904e Hass.io \u9644\u52a0\u7d44\u4ef6 deCONZ Zigbee \u9598\u9053\u5668" + }, + "init": { + "title": "\u5b9a\u7fa9 deCONZ \u9598\u9053\u5668" + }, + "link": { + "description": "\u89e3\u9664 deCONZ \u9598\u9053\u5668\u9396\u5b9a\uff0c\u4ee5\u65bc Home Assistant \u9032\u884c\u8a3b\u518a\u3002\n\n1. \u9032\u5165 deCONZ \u7cfb\u7d71\u8a2d\u5b9a -> \u9598\u9053\u5668 -> \u9032\u968e\u8a2d\u5b9a\n2. \u6309\u4e0b\u300c\u8a8d\u8b49\u7a0b\u5f0f\uff08Authenticate app\uff09\u300d\u6309\u9215", + "title": "\u9023\u7d50\u81f3 deCONZ" + }, + "manual_confirm": { + "data": { + "host": "\u4e3b\u6a5f\u7aef", + "port": "\u901a\u8a0a\u57e0" + } + }, + "manual_input": { + "data": { + "host": "\u4e3b\u6a5f\u7aef", + "port": "\u901a\u8a0a\u57e0" + }, + "title": "\u8a2d\u5b9a deCONZ \u9598\u9053\u5668" + }, + "user": { + "data": { + "host": "\u9078\u64c7\u6240\u63a2\u7d22\u5230\u7684 deCONZ \u9598\u9053\u5668" + }, + "title": "\u9078\u64c7 deCONZ \u9598\u9053\u5668" + } + } + }, + "device_automation": { + "trigger_subtype": { + "both_buttons": "\u5169\u500b\u6309\u9215", + "bottom_buttons": "\u4e0b\u65b9\u6309\u9215", + "button_1": "\u7b2c\u4e00\u500b\u6309\u9215", + "button_2": "\u7b2c\u4e8c\u500b\u6309\u9215", + "button_3": "\u7b2c\u4e09\u500b\u6309\u9215", + "button_4": "\u7b2c\u56db\u500b\u6309\u9215", + "close": "\u95dc\u9589", + "dim_down": "\u8abf\u6697", + "dim_up": "\u8abf\u4eae", + "left": "\u5de6", + "open": "\u958b\u555f", + "right": "\u53f3", + "side_1": "\u7b2c 1 \u9762", + "side_2": "\u7b2c 2 \u9762", + "side_3": "\u7b2c 3 \u9762", + "side_4": "\u7b2c 4 \u9762", + "side_5": "\u7b2c 5 \u9762", + "side_6": "\u7b2c 6 \u9762", + "top_buttons": "\u4e0a\u65b9\u6309\u9215", + "turn_off": "\u95dc\u9589", + "turn_on": "\u958b\u555f" + }, + "trigger_type": { + "remote_awakened": "\u8a2d\u5099\u5df2\u559a\u9192", + "remote_button_double_press": "\"{subtype}\" \u6309\u9215\u96d9\u64ca", + "remote_button_long_press": "\"{subtype}\" \u6309\u9215\u6301\u7e8c\u6309\u4e0b", + "remote_button_long_release": "\u9577\u6309\u5f8c\u91cb\u653e \"{subtype}\" \u6309\u9215", + "remote_button_quadruple_press": "\"{subtype}\" \u6309\u9215\u56db\u9023\u9ede\u64ca", + "remote_button_quintuple_press": "\"{subtype}\" \u6309\u9215\u4e94\u9023\u9ede\u64ca", + "remote_button_rotated": "\u65cb\u8f49 \"{subtype}\" \u6309\u9215", + "remote_button_rotation_stopped": "\u65cb\u8f49 \"{subtype}\" \u6309\u9215\u5df2\u505c\u6b62", + "remote_button_short_press": "\"{subtype}\" \u6309\u9215\u5df2\u6309\u4e0b", + "remote_button_short_release": "\"{subtype}\" \u6309\u9215\u5df2\u91cb\u653e", + "remote_button_triple_press": "\"{subtype}\" \u6309\u9215\u4e09\u9023\u9ede\u64ca", + "remote_double_tap": "\u8a2d\u5099 \"{subtype}\" \u96d9\u6572", + "remote_double_tap_any_side": "\u8a2d\u5099\u4efb\u4e00\u9762\u96d9\u9ede\u9078", + "remote_falling": "\u8a2d\u5099\u81ea\u7531\u843d\u4e0b", + "remote_flip_180_degrees": "\u8a2d\u5099\u65cb\u8f49 180 \u5ea6", + "remote_flip_90_degrees": "\u8a2d\u5099\u65cb\u8f49 90 \u5ea6", + "remote_gyro_activated": "\u8a2d\u5099\u6416\u6643", + "remote_moved": "\u8a2d\u5099\u79fb\u52d5\u81f3 \"{subtype}\" \u671d\u4e0a", + "remote_moved_any_side": "\u8a2d\u5099\u4efb\u4e00\u9762\u671d\u4e0a", + "remote_rotate_from_side_1": "\u8a2d\u5099\u7531\u300c\u7b2c 1 \u9762\u300d\u65cb\u8f49\u81f3\u300c{subtype}\u300d", + "remote_rotate_from_side_2": "\u8a2d\u5099\u7531\u300c\u7b2c 2 \u9762\u300d\u65cb\u8f49\u81f3\u300c{subtype}\u300d", + "remote_rotate_from_side_3": "\u8a2d\u5099\u7531\u300c\u7b2c 3 \u9762\u300d\u65cb\u8f49\u81f3\u300c{subtype}\u300d", + "remote_rotate_from_side_4": "\u8a2d\u5099\u7531\u300c\u7b2c 4 \u9762\u300d\u65cb\u8f49\u81f3\u300c{subtype}\u300d", + "remote_rotate_from_side_5": "\u8a2d\u5099\u7531\u300c\u7b2c 5 \u9762\u300d\u65cb\u8f49\u81f3\u300c{subtype}\u300d", + "remote_rotate_from_side_6": "\u8a2d\u5099\u7531\u300c\u7b2c 6 \u9762\u300d\u65cb\u8f49\u81f3\u300c{subtype}\u300d", + "remote_turned_clockwise": "\u8a2d\u5099\u9806\u6642\u91dd\u65cb\u8f49", + "remote_turned_counter_clockwise": "\u8a2d\u5099\u9006\u6642\u91dd\u65cb\u8f49" + } + }, + "options": { + "step": { + "deconz_devices": { + "data": { + "allow_clip_sensor": "\u5141\u8a31 deCONZ CLIP \u611f\u61c9\u5668", + "allow_deconz_groups": "\u5141\u8a31 deCONZ \u71c8\u5149\u7fa4\u7d44" + }, + "description": "\u8a2d\u5b9a deCONZ \u53ef\u8996\u8a2d\u5099\u985e\u578b", + "title": "deCONZ \u9078\u9805" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/decora/light.py b/homeassistant/components/decora/light.py index 2f6c050b79e64..5b6015b7c5462 100644 --- a/homeassistant/components/decora/light.py +++ b/homeassistant/components/decora/light.py @@ -1,39 +1,58 @@ """Support for Decora dimmers.""" -import importlib -import logging +import copy from functools import wraps +import logging import time +from bluepy.btle import BTLEException # pylint: disable=import-error, no-member +import decora # pylint: disable=import-error, no-member 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) + ATTR_BRIGHTNESS, + PLATFORM_SCHEMA, + SUPPORT_BRIGHTNESS, + LightEntity, +) +from homeassistant.const import CONF_API_KEY, CONF_DEVICES, CONF_NAME import homeassistant.helpers.config_validation as cv +import homeassistant.util as util _LOGGER = logging.getLogger(__name__) -SUPPORT_DECORA_LED = (SUPPORT_BRIGHTNESS) +SUPPORT_DECORA_LED = SUPPORT_BRIGHTNESS + -DEVICE_SCHEMA = vol.Schema({ - vol.Optional(CONF_NAME): cv.string, - vol.Required(CONF_API_KEY): cv.string, -}) +def _name_validator(config): + """Validate the name.""" + config = copy.deepcopy(config) + for address, device_config in config[CONF_DEVICES].items(): + if CONF_NAME not in device_config: + device_config[CONF_NAME] = util.slugify(address) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_DEVICES, default={}): {cv.string: DEVICE_SCHEMA}, -}) + return config + + +DEVICE_SCHEMA = vol.Schema( + {vol.Optional(CONF_NAME): cv.string, vol.Required(CONF_API_KEY): cv.string} +) + +PLATFORM_SCHEMA = vol.Schema( + vol.All( + PLATFORM_SCHEMA.extend( + {vol.Optional(CONF_DEVICES, default={}): {cv.string: DEVICE_SCHEMA}} + ), + _name_validator, + ) +) 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: @@ -41,12 +60,13 @@ def wrapper_retry(device, *args, **kwargs): 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) + except (decora.decoraException, AttributeError, BTLEException): + _LOGGER.warning( + "Decora connect error for device %s. Reconnecting...", device.name, + ) # pylint: disable=protected-access device._switch.connect() + return wrapper_retry @@ -55,25 +75,23 @@ def setup_platform(hass, config, add_entities, discovery_info=None): 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 + 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): +class DecoraLight(LightEntity): """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._name = device["name"] + self._address = device["address"] self._key = device["key"] self._switch = decora.decora(self._address, self._key) self._brightness = 0 diff --git a/homeassistant/components/decora/manifest.json b/homeassistant/components/decora/manifest.json index 923a543e82788..247422bee73c3 100644 --- a/homeassistant/components/decora/manifest.json +++ b/homeassistant/components/decora/manifest.json @@ -1,11 +1,7 @@ { "domain": "decora", - "name": "Decora", - "documentation": "https://www.home-assistant.io/components/decora", - "requirements": [ - "bluepy==1.1.4", - "decora==0.6" - ], - "dependencies": [], + "name": "Leviton Decora", + "documentation": "https://www.home-assistant.io/integrations/decora", + "requirements": ["bluepy==1.3.0", "decora==0.6"], "codeowners": [] } diff --git a/homeassistant/components/decora_wifi/light.py b/homeassistant/components/decora_wifi/light.py index 390af765b62b4..6f716d3a5dc9d 100644 --- a/homeassistant/components/decora_wifi/light.py +++ b/homeassistant/components/decora_wifi/light.py @@ -2,38 +2,40 @@ import logging +# pylint: disable=import-error +from decora_wifi import DecoraWiFiSession +from decora_wifi.models.person import Person +from decora_wifi.models.residence import Residence +from decora_wifi.models.residential_account import ResidentialAccount 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) + ATTR_BRIGHTNESS, + ATTR_TRANSITION, + PLATFORM_SCHEMA, + SUPPORT_BRIGHTNESS, + SUPPORT_TRANSITION, + LightEntity, +) +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, 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, -}) +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' +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) + + email = config[CONF_USERNAME] + password = config[CONF_PASSWORD] session = DecoraWiFiSession() try: @@ -41,10 +43,11 @@ def setup_platform(hass, config, add_entities, discovery_info=None): # If login failed, notify user. if success is None: - msg = 'Failed to log into myLeviton Services. Check credentials.' + 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) + msg, title=NOTIFICATION_TITLE, notification_id=NOTIFICATION_ID + ) return False # Gather all the available devices... @@ -52,8 +55,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): all_switches = [] for permission in perms: if permission.residentialAccountId is not None: - acct = ResidentialAccount( - session, permission.residentialAccountId) + acct = ResidentialAccount(session, permission.residentialAccountId) for residence in acct.get_residences(): for switch in residence.get_iot_switches(): all_switches.append(switch) @@ -64,7 +66,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): add_entities(DecoraWifiLight(sw) for sw in all_switches) except ValueError: - _LOGGER.error('Failed to communicate with myLeviton Service.') + _LOGGER.error("Failed to communicate with myLeviton Service.") # Listen for the stop event and log out. def logout(event): @@ -73,12 +75,12 @@ def logout(event): if session is not None: Person.logout(session) except ValueError: - _LOGGER.error('Failed to log out of myLeviton Service.') + _LOGGER.error("Failed to log out of myLeviton Service.") hass.bus.listen(EVENT_HOMEASSISTANT_STOP, logout) -class DecoraWifiLight(Light): +class DecoraWifiLight(LightEntity): """Representation of a Decora WiFi switch.""" def __init__(self, switch): @@ -105,39 +107,39 @@ def brightness(self): @property def is_on(self): """Return true if switch is on.""" - return self._switch.power == 'ON' + return self._switch.power == "ON" def turn_on(self, **kwargs): """Instruct the switch to turn on & adjust brightness.""" - attribs = {'power': 'ON'} + attribs = {"power": "ON"} if ATTR_BRIGHTNESS in kwargs: - min_level = self._switch.data.get('minLevel', 0) - max_level = self._switch.data.get('maxLevel', 100) + 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 + attribs["brightness"] = brightness if ATTR_TRANSITION in kwargs: transition = int(kwargs[ATTR_TRANSITION]) - attribs['fadeOnTime'] = attribs['fadeOffTime'] = transition + attribs["fadeOnTime"] = attribs["fadeOffTime"] = transition try: self._switch.update_attributes(attribs) except ValueError: - _LOGGER.error('Failed to turn on myLeviton switch.') + _LOGGER.error("Failed to turn on myLeviton switch.") def turn_off(self, **kwargs): """Instruct the switch to turn off.""" - attribs = {'power': 'OFF'} + attribs = {"power": "OFF"} try: self._switch.update_attributes(attribs) except ValueError: - _LOGGER.error('Failed to turn off myLeviton switch.') + _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.') + _LOGGER.error("Failed to update myLeviton switch data.") diff --git a/homeassistant/components/decora_wifi/manifest.json b/homeassistant/components/decora_wifi/manifest.json index 42ab6bfd6c166..c2a7dc63e00e8 100644 --- a/homeassistant/components/decora_wifi/manifest.json +++ b/homeassistant/components/decora_wifi/manifest.json @@ -1,10 +1,7 @@ { "domain": "decora_wifi", - "name": "Decora wifi", - "documentation": "https://www.home-assistant.io/components/decora_wifi", - "requirements": [ - "decora_wifi==1.4" - ], - "dependencies": [], + "name": "Leviton Decora Wi-Fi", + "documentation": "https://www.home-assistant.io/integrations/decora_wifi", + "requirements": ["decora_wifi==1.4"], "codeowners": [] } diff --git a/homeassistant/components/default_config/__init__.py b/homeassistant/components/default_config/__init__.py index 23add299b2f16..506904a500af6 100644 --- a/homeassistant/components/default_config/__init__.py +++ b/homeassistant/components/default_config/__init__.py @@ -6,7 +6,7 @@ from homeassistant.setup import async_setup_component -DOMAIN = 'default_config' +DOMAIN = "default_config" async def async_setup(hass, config): @@ -14,4 +14,4 @@ async def async_setup(hass, config): if av is None: return True - return await async_setup_component(hass, 'stream', config) + return await async_setup_component(hass, "stream", config) diff --git a/homeassistant/components/default_config/manifest.json b/homeassistant/components/default_config/manifest.json index f52da35dc64e9..0b80e17290449 100644 --- a/homeassistant/components/default_config/manifest.json +++ b/homeassistant/components/default_config/manifest.json @@ -1,24 +1,30 @@ { "domain": "default_config", - "name": "Default config", - "documentation": "https://www.home-assistant.io/components/default_config", - "requirements": [], + "name": "Default Config", + "documentation": "https://www.home-assistant.io/integrations/default_config", "dependencies": [ "automation", "cloud", "config", - "conversation", "frontend", "history", "logbook", "map", "mobile_app", "person", + "scene", "script", + "ssdp", "sun", "system_health", "updater", - "zeroconf" + "zeroconf", + "zone", + "input_boolean", + "input_datetime", + "input_text", + "input_number", + "input_select" ], "codeowners": [] } diff --git a/homeassistant/components/delijn/__init__.py b/homeassistant/components/delijn/__init__.py new file mode 100644 index 0000000000000..cdec126589b92 --- /dev/null +++ b/homeassistant/components/delijn/__init__.py @@ -0,0 +1 @@ +"""The delijn component.""" diff --git a/homeassistant/components/delijn/manifest.json b/homeassistant/components/delijn/manifest.json new file mode 100644 index 0000000000000..3f6efd0a3d758 --- /dev/null +++ b/homeassistant/components/delijn/manifest.json @@ -0,0 +1,7 @@ +{ + "domain": "delijn", + "name": "De Lijn", + "documentation": "https://www.home-assistant.io/integrations/delijn", + "codeowners": ["@bollewolle"], + "requirements": ["pydelijn==0.5.1"] +} diff --git a/homeassistant/components/delijn/sensor.py b/homeassistant/components/delijn/sensor.py new file mode 100644 index 0000000000000..538e071e194af --- /dev/null +++ b/homeassistant/components/delijn/sensor.py @@ -0,0 +1,125 @@ +"""Support for De Lijn (Flemish public transport) information.""" +import logging + +from pydelijn.api import Passages +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ATTR_ATTRIBUTION, DEVICE_CLASS_TIMESTAMP +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__) + +ATTRIBUTION = "Data provided by data.delijn.be" + +CONF_NEXT_DEPARTURE = "next_departure" +CONF_STOP_ID = "stop_id" +CONF_API_KEY = "api_key" +CONF_NUMBER_OF_DEPARTURES = "number_of_departures" + +DEFAULT_NAME = "De Lijn" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_NEXT_DEPARTURE): [ + { + vol.Required(CONF_STOP_ID): cv.string, + vol.Optional(CONF_NUMBER_OF_DEPARTURES, default=5): cv.positive_int, + } + ], + } +) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Create the sensor.""" + api_key = config[CONF_API_KEY] + name = DEFAULT_NAME + + session = async_get_clientsession(hass) + + sensors = [] + for nextpassage in config[CONF_NEXT_DEPARTURE]: + stop_id = nextpassage[CONF_STOP_ID] + number_of_departures = nextpassage[CONF_NUMBER_OF_DEPARTURES] + line = Passages( + hass.loop, stop_id, number_of_departures, api_key, session, True + ) + await line.get_passages() + if line.passages is None: + _LOGGER.warning("No data received from De Lijn") + return + sensors.append(DeLijnPublicTransportSensor(line, name)) + + async_add_entities(sensors, True) + + +class DeLijnPublicTransportSensor(Entity): + """Representation of a Ruter sensor.""" + + def __init__(self, line, name): + """Initialize the sensor.""" + self.line = line + self._attributes = {ATTR_ATTRIBUTION: ATTRIBUTION} + self._name = name + self._state = None + self._available = False + + async def async_update(self): + """Get the latest data from the De Lijn API.""" + await self.line.get_passages() + if self.line.passages is None: + _LOGGER.warning("No data received from De Lijn") + return + try: + first = self.line.passages[0] + if first["due_at_realtime"] is not None: + first_passage = first["due_at_realtime"] + else: + first_passage = first["due_at_schedule"] + self._state = first_passage + self._name = first["stopname"] + self._attributes["stopname"] = first["stopname"] + self._attributes["line_number_public"] = first["line_number_public"] + self._attributes["line_transport_type"] = first["line_transport_type"] + self._attributes["final_destination"] = first["final_destination"] + self._attributes["due_at_schedule"] = first["due_at_schedule"] + self._attributes["due_at_realtime"] = first["due_at_realtime"] + self._attributes["next_passages"] = self.line.passages + self._available = True + except (KeyError, IndexError) as error: + _LOGGER.debug("Error getting data from De Lijn: %s", error) + self._available = False + + @property + def available(self): + """Return True if entity is available.""" + return self._available + + @property + def device_class(self): + """Return the device class.""" + return DEVICE_CLASS_TIMESTAMP + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def icon(self): + """Return the icon of the sensor.""" + return "mdi:bus" + + @property + def device_state_attributes(self): + """Return attributes for the sensor.""" + return self._attributes diff --git a/homeassistant/components/deluge/manifest.json b/homeassistant/components/deluge/manifest.json index 2b3c6d4c05505..53210a17f17a9 100644 --- a/homeassistant/components/deluge/manifest.json +++ b/homeassistant/components/deluge/manifest.json @@ -1,10 +1,7 @@ { "domain": "deluge", "name": "Deluge", - "documentation": "https://www.home-assistant.io/components/deluge", - "requirements": [ - "deluge-client==1.4.0" - ], - "dependencies": [], + "documentation": "https://www.home-assistant.io/integrations/deluge", + "requirements": ["deluge-client==1.7.1"], "codeowners": [] } diff --git a/homeassistant/components/deluge/sensor.py b/homeassistant/components/deluge/sensor.py index 1002ae5107784..4a24e979607bb 100644 --- a/homeassistant/components/deluge/sensor.py +++ b/homeassistant/components/deluge/sensor.py @@ -1,49 +1,59 @@ """Support for monitoring the Deluge BitTorrent client API.""" import logging +from deluge_client import DelugeRPCClient, FailedToReconnectException 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 + CONF_HOST, + CONF_MONITORED_VARIABLES, + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + DATA_RATE_KILOBYTES_PER_SECOND, + STATE_IDLE, +) from homeassistant.exceptions import PlatformNotReady +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) _THROTTLED_REFRESH = None -DEFAULT_NAME = 'Deluge' +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'], + "current_status": ["Status", None], + "download_speed": ["Down Speed", DATA_RATE_KILOBYTES_PER_SECOND], + "upload_speed": ["Up Speed", DATA_RATE_KILOBYTES_PER_SECOND], } -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)]), -}) +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) + name = config[CONF_NAME] + host = config[CONF_HOST] + username = config[CONF_USERNAME] + password = config[CONF_PASSWORD] + port = config[CONF_PORT] deluge_api = DelugeRPCClient(host, port, username, password) try: @@ -75,7 +85,7 @@ def __init__(self, sensor_type, deluge_client, client_name): @property def name(self): """Return the name of the sensor.""" - return '{} {}'.format(self.client_name, self._name) + return f"{self.client_name} {self._name}" @property def state(self): @@ -94,41 +104,45 @@ def unit_of_measurement(self): 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.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'] + 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.type == "current_status": if self.data: if upload > 0 and download > 0: - self._state = 'Up/Down' + self._state = "Up/Down" elif upload > 0 and download == 0: - self._state = 'Seeding' + self._state = "Seeding" elif upload == 0 and download > 0: - self._state = 'Downloading' + self._state = "Downloading" else: self._state = STATE_IDLE else: self._state = None if self.data: - if self.type == 'download_speed': + 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': + 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 index d72ce9a53083b..04acf6a9dd908 100644 --- a/homeassistant/components/deluge/switch.py +++ b/homeassistant/components/deluge/switch.py @@ -1,39 +1,47 @@ """Support for setting the Deluge BitTorrent client in Pause.""" import logging +from deluge_client import DelugeRPCClient, FailedToReconnectException 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 + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + STATE_OFF, + STATE_ON, +) +from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import ToggleEntity _LOGGER = logging.getLogger(__name__) -DEFAULT_NAME = 'Deluge Switch' +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, -}) +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) + name = config[CONF_NAME] + host = config[CONF_HOST] + username = config[CONF_USERNAME] + password = config[CONF_PASSWORD] + port = config[CONF_PORT] deluge_api = DelugeRPCClient(host, port, username, password) try: @@ -77,20 +85,21 @@ def available(self): 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) + 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) + 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']) + torrent_list = self.deluge_client.call( + "core.get_torrents_status", {}, ["paused"] + ) self._available = True except FailedToReconnectException: _LOGGER.error("Connection to Deluge Daemon Lost") diff --git a/homeassistant/components/demo/__init__.py b/homeassistant/components/demo/__init__.py index c61673afda65c..344ffbd9fd30f 100644 --- a/homeassistant/components/demo/__init__.py +++ b/homeassistant/components/demo/__init__.py @@ -3,31 +3,38 @@ import logging import time -from homeassistant import bootstrap -import homeassistant.core as ha +from homeassistant import bootstrap, config_entries from homeassistant.const import ATTR_ENTITY_ID, EVENT_HOMEASSISTANT_START +import homeassistant.core as ha -DOMAIN = 'demo' +DOMAIN = "demo" _LOGGER = logging.getLogger(__name__) + +COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM = [ + "air_quality", + "alarm_control_panel", + "binary_sensor", + "camera", + "climate", + "cover", + "fan", + "light", + "lock", + "media_player", + "sensor", + "switch", + "vacuum", + "water_heater", +] + 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', + "tts", + "stt", + "mailbox", + "notify", + "image_processing", + "calendar", + "device_tracker", ] @@ -36,14 +43,21 @@ async def async_setup(hass, config): if DOMAIN not in config: return True - config.setdefault(ha.DOMAIN, {}) - config.setdefault(DOMAIN, {}) + if not hass.config_entries.async_entries(DOMAIN): + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data={} + ) + ) # Set up demo platforms for component in COMPONENTS_WITH_DEMO_PLATFORM: - hass.async_create_task(hass.helpers.discovery.async_load_platform( - component, DOMAIN, {}, config, - )) + hass.async_create_task( + hass.helpers.discovery.async_load_platform(component, DOMAIN, {}, config) + ) + + config.setdefault(ha.DOMAIN, {}) + config.setdefault(DOMAIN, {}) # Set up sun if not hass.config.latitude: @@ -52,45 +66,64 @@ async def async_setup(hass, config): if not hass.config.longitude: hass.config.longitude = 117.22743 - tasks = [ - bootstrap.async_setup_component(hass, 'sun', config) - ] + 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']}}})) + 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'}}})) + 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'}]}})) + 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", + } + } + }, + ) + ) results = await asyncio.gather(*tasks) @@ -99,8 +132,8 @@ async def async_setup(hass, config): # Set up example persistent notification hass.components.persistent_notification.async_create( - 'This is an example of a persistent notification.', - title='Example Notification') + "This is an example of a persistent notification.", title="Example Notification" + ) # Set up configurator configurator_ids = [] @@ -113,20 +146,23 @@ def hue_configuration_callback(data): # 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[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."), + "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" + fields=[{"id": "username", "name": "Username"}], + submit_caption="I have pressed the button", ) configurator_ids.append(request_id) @@ -139,57 +175,78 @@ async def demo_start_listener(_event): return True +async def async_setup_entry(hass, config_entry): + """Set the config entry up.""" + # Set up demo platforms with config entry + for component in COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(config_entry, component) + ) + 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')) + switches = None + lights = None - # 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 - }}} - ) + while not switches and not lights: + # Not all platforms might be loaded. + if switches is not None: + await asyncio.sleep(0) + switches = sorted(hass.states.async_entity_ids("switch")) + lights = sorted(hass.states.async_entity_ids("light")) # 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]} - }] - }}}) + hass, + "script", + { + "script": { + "demo": { + "alias": f"Toggle {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, - }}, - ]}) + 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 index 77e5c0b2b1a1f..656e22259e1c9 100644 --- a/homeassistant/components/demo/air_quality.py +++ b/homeassistant/components/demo/air_quality.py @@ -2,12 +2,16 @@ from homeassistant.components.air_quality import AirQualityEntity -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the Air Quality.""" - add_entities([ - DemoAirQuality('Home', 14, 23, 100), - DemoAirQuality('Office', 4, 16, None) - ]) + async_add_entities( + [DemoAirQuality("Home", 14, 23, 100), DemoAirQuality("Office", 4, 16, None)] + ) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Demo config entry.""" + await async_setup_platform(hass, {}, async_add_entities) class DemoAirQuality(AirQualityEntity): @@ -23,7 +27,7 @@ def __init__(self, name, pm_2_5, pm_10, n2o): @property def name(self): """Return the name of the sensor.""" - return '{} {}'.format('Demo Air Quality', self._name) + return f"Demo Air Quality {self._name}" @property def should_poll(self): @@ -48,4 +52,4 @@ def nitrogen_oxide(self): @property def attribution(self): """Return the attribution.""" - return 'Powered by Home Assistant' + return "Powered by Home Assistant" diff --git a/homeassistant/components/demo/alarm_control_panel.py b/homeassistant/components/demo/alarm_control_panel.py index 3cf5aaca57e79..d5bb71da67b01 100644 --- a/homeassistant/components/demo/alarm_control_panel.py +++ b/homeassistant/components/demo/alarm_control_panel.py @@ -1,44 +1,65 @@ """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) + CONF_ARMING_TIME, + CONF_DELAY_TIME, + CONF_TRIGGER_TIME, + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_CUSTOM_BYPASS, + STATE_ALARM_ARMED_HOME, + STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_DISARMED, + STATE_ALARM_TRIGGERED, +) -async def async_setup_platform(hass, config, async_add_entities, - discovery_info=None): +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), - }, - }), - ]) + async_add_entities( + [ + ManualAlarm( + hass, + "Alarm", + "1234", + None, + True, + False, + { + STATE_ALARM_ARMED_AWAY: { + CONF_ARMING_TIME: datetime.timedelta(seconds=5), + CONF_DELAY_TIME: datetime.timedelta(seconds=0), + CONF_TRIGGER_TIME: datetime.timedelta(seconds=10), + }, + STATE_ALARM_ARMED_HOME: { + CONF_ARMING_TIME: datetime.timedelta(seconds=5), + CONF_DELAY_TIME: datetime.timedelta(seconds=0), + CONF_TRIGGER_TIME: datetime.timedelta(seconds=10), + }, + STATE_ALARM_ARMED_NIGHT: { + CONF_ARMING_TIME: datetime.timedelta(seconds=5), + CONF_DELAY_TIME: datetime.timedelta(seconds=0), + 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_ARMING_TIME: datetime.timedelta(seconds=5), + CONF_DELAY_TIME: datetime.timedelta(seconds=0), + CONF_TRIGGER_TIME: datetime.timedelta(seconds=10), + }, + STATE_ALARM_TRIGGERED: { + CONF_ARMING_TIME: datetime.timedelta(seconds=5) + }, + }, + ) + ] + ) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Demo config entry.""" + await async_setup_platform(hass, {}, async_add_entities) diff --git a/homeassistant/components/demo/binary_sensor.py b/homeassistant/components/demo/binary_sensor.py index 437497e4facca..04d8e72f9a800 100644 --- a/homeassistant/components/demo/binary_sensor.py +++ b/homeassistant/components/demo/binary_sensor.py @@ -1,24 +1,50 @@ """Demo platform that has two fake binary sensors.""" -from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.components.binary_sensor import BinarySensorEntity +from . import DOMAIN -def setup_platform(hass, config, add_entities, discovery_info=None): + +async def async_setup_platform(hass, config, async_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'), - ]) + async_add_entities( + [ + DemoBinarySensor("binary_1", "Basement Floor Wet", False, "moisture"), + DemoBinarySensor("binary_2", "Movement Backyard", True, "motion"), + ] + ) + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Demo config entry.""" + await async_setup_platform(hass, {}, async_add_entities) -class DemoBinarySensor(BinarySensorDevice): + +class DemoBinarySensor(BinarySensorEntity): """representation of a Demo binary sensor.""" - def __init__(self, name, state, device_class): + def __init__(self, unique_id, name, state, device_class): """Initialize the demo sensor.""" + self._unique_id = unique_id self._name = name self._state = state self._sensor_type = device_class + @property + def device_info(self): + """Return device info.""" + return { + "identifiers": { + # Serial numbers are unique identifiers within a specific domain + (DOMAIN, self.unique_id) + }, + "name": self.name, + } + + @property + def unique_id(self): + """Return the unique id.""" + return self._unique_id + @property def device_class(self): """Return the class of this sensor.""" diff --git a/homeassistant/components/demo/calendar.py b/homeassistant/components/demo/calendar.py index 6096f8247c49f..42cb2b137a113 100644 --- a/homeassistant/components/demo/calendar.py +++ b/homeassistant/components/demo/calendar.py @@ -1,45 +1,33 @@ """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 +import homeassistant.util.dt as dt_util 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', - }), - ]) + add_entities( + [ + DemoGoogleCalendar(hass, calendar_data_future, "Calendar 1"), + DemoGoogleCalendar(hass, calendar_data_current, "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 + event = None 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() + event["title"] = event["summary"] + event["start"] = get_date(event["start"]).isoformat() + event["end"] = get_date(event["end"]).isoformat() return [event] @@ -48,17 +36,15 @@ class DemoGoogleCalendarDataFuture(DemoGoogleCalendarData): def __init__(self): """Set the event to a future event.""" - one_hour_from_now = dt_util.now() \ - + dt_util.dt.timedelta(minutes=30) + one_hour_from_now = dt_util.now() + dt_util.dt.timedelta(minutes=30) self.event = { - 'start': { - 'dateTime': one_hour_from_now.isoformat() + "start": {"dateTime": one_hour_from_now.isoformat()}, + "end": { + "dateTime": ( + one_hour_from_now + dt_util.dt.timedelta(minutes=60) + ).isoformat() }, - 'end': { - 'dateTime': (one_hour_from_now + dt_util.dt. - timedelta(minutes=60)).isoformat() - }, - 'summary': 'Future Event', + "summary": "Future Event", } @@ -67,28 +53,36 @@ class DemoGoogleCalendarDataCurrent(DemoGoogleCalendarData): def __init__(self): """Set the event data.""" - middle_of_event = dt_util.now() \ - - dt_util.dt.timedelta(minutes=30) + 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() + "start": {"dateTime": middle_of_event.isoformat()}, + "end": { + "dateTime": ( + middle_of_event + dt_util.dt.timedelta(minutes=60) + ).isoformat() }, - 'summary': 'Current Event', + "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.""" + def __init__(self, hass, calendar_data, name): + """Initialize demo calendar.""" self.data = calendar_data - super().__init__(hass, data) + self._name = name + + @property + def event(self): + """Return the next upcoming event.""" + return self.data.event + + @property + def name(self): + """Return the name of the entity.""" + return self._name async def async_get_events(self, hass, start_date, end_date): - """Get all events in a specific time frame.""" + """Return calendar events within a datetime range.""" 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 index 95c7df5820086..ce211a47594df 100644 --- a/homeassistant/components/demo/camera.py +++ b/homeassistant/components/demo/camera.py @@ -1,18 +1,20 @@ """Demo camera platform that has a fake camera.""" import logging -import os +from pathlib import Path 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): +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') - ]) + async_add_entities([DemoCamera("Demo camera")]) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Demo config entry.""" + await async_setup_platform(hass, {}, async_add_entities) class DemoCamera(Camera): @@ -26,16 +28,12 @@ def __init__(self, name): self.is_streaming = True self._images_index = 0 - def camera_image(self): + async def async_camera_image(self): """Return a faked still image response.""" self._images_index = (self._images_index + 1) % 4 + image_path = Path(__file__).parent / f"demo_{self._images_index}.jpg" - 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() + return await self.hass.async_add_executor_job(image_path.read_bytes) @property def name(self): @@ -46,7 +44,7 @@ def name(self): def should_poll(self): """Demo camera doesn't need poll. - Need explicitly call schedule_update_ha_state() after state changed. + Need explicitly call async_write_ha_state() after state changed. """ return False @@ -65,22 +63,22 @@ def motion_detection_enabled(self): """Camera Motion Detection Status.""" return self._motion_status - def enable_motion_detection(self): + async def async_enable_motion_detection(self): """Enable the Motion detection in base station (Arm).""" self._motion_status = True - self.schedule_update_ha_state() + self.async_write_ha_state() - def disable_motion_detection(self): + async def async_disable_motion_detection(self): """Disable the motion detection in base station (Disarm).""" self._motion_status = False - self.schedule_update_ha_state() + self.async_write_ha_state() - def turn_off(self): + async def async_turn_off(self): """Turn off camera.""" self.is_streaming = False - self.schedule_update_ha_state() + self.async_write_ha_state() - def turn_on(self): + async def async_turn_on(self): """Turn on camera.""" self.is_streaming = True - self.schedule_update_ha_state() + self.async_write_ha_state() diff --git a/homeassistant/components/demo/climate.py b/homeassistant/components/demo/climate.py index 70eed0c361601..9733d0f114765 100644 --- a/homeassistant/components/demo/climate.py +++ b/homeassistant/components/demo/climate.py @@ -1,85 +1,178 @@ """Demo platform that offers a fake climate device.""" -from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS, TEMP_FAHRENHEIT +import logging -from homeassistant.components.climate import ClimateDevice +from homeassistant.components.climate import ClimateEntity 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) + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + CURRENT_HVAC_COOL, + CURRENT_HVAC_HEAT, + HVAC_MODE_AUTO, + HVAC_MODE_COOL, + HVAC_MODE_HEAT, + HVAC_MODE_HEAT_COOL, + HVAC_MODE_OFF, + HVAC_MODES, + SUPPORT_AUX_HEAT, + SUPPORT_FAN_MODE, + SUPPORT_PRESET_MODE, + SUPPORT_SWING_MODE, + SUPPORT_TARGET_HUMIDITY, + SUPPORT_TARGET_TEMPERATURE, + SUPPORT_TARGET_TEMPERATURE_RANGE, +) +from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS, TEMP_FAHRENHEIT -SUPPORT_FLAGS = SUPPORT_TARGET_HUMIDITY_LOW | SUPPORT_TARGET_HUMIDITY_HIGH +from . import DOMAIN +SUPPORT_FLAGS = 0 +_LOGGER = logging.getLogger(__name__) -def setup_platform(hass, config, add_entities, discovery_info=None): + +async def async_setup_platform(hass, config, async_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): + async_add_entities( + [ + DemoClimate( + unique_id="climate_1", + name="HeatPump", + target_temperature=68, + unit_of_measurement=TEMP_FAHRENHEIT, + preset=None, + current_temperature=77, + fan_mode=None, + target_humidity=None, + current_humidity=None, + swing_mode=None, + hvac_mode=HVAC_MODE_HEAT, + hvac_action=CURRENT_HVAC_HEAT, + aux=None, + target_temp_high=None, + target_temp_low=None, + hvac_modes=[HVAC_MODE_HEAT, HVAC_MODE_OFF], + ), + DemoClimate( + unique_id="climate_2", + name="Hvac", + target_temperature=21, + unit_of_measurement=TEMP_CELSIUS, + preset=None, + current_temperature=22, + fan_mode="On High", + target_humidity=67, + current_humidity=54, + swing_mode="Off", + hvac_mode=HVAC_MODE_COOL, + hvac_action=CURRENT_HVAC_COOL, + aux=False, + target_temp_high=None, + target_temp_low=None, + hvac_modes=[mode for mode in HVAC_MODES if mode != HVAC_MODE_HEAT_COOL], + ), + DemoClimate( + unique_id="climate_3", + name="Ecobee", + target_temperature=None, + unit_of_measurement=TEMP_CELSIUS, + preset="home", + preset_modes=["home", "eco"], + current_temperature=23, + fan_mode="Auto Low", + target_humidity=None, + current_humidity=None, + swing_mode="Auto", + hvac_mode=HVAC_MODE_HEAT_COOL, + hvac_action=None, + aux=None, + target_temp_high=24, + target_temp_low=21, + hvac_modes=[HVAC_MODE_HEAT_COOL, HVAC_MODE_COOL, HVAC_MODE_HEAT], + ), + ] + ) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Demo climate devices config entry.""" + await async_setup_platform(hass, {}, async_add_entities) + + +class DemoClimate(ClimateEntity): """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): + def __init__( + self, + unique_id, + name, + target_temperature, + unit_of_measurement, + preset, + current_temperature, + fan_mode, + target_humidity, + current_humidity, + swing_mode, + hvac_mode, + hvac_action, + aux, + target_temp_high, + target_temp_low, + hvac_modes, + preset_modes=None, + ): """Initialize the climate device.""" + self._unique_id = unique_id 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_TARGET_TEMPERATURE + if preset is not None: + self._support_flags = self._support_flags | SUPPORT_PRESET_MODE + if 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_TARGET_HUMIDITY + if 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 hvac_action is not None: + self._support_flags = self._support_flags 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 + if HVAC_MODE_HEAT_COOL in hvac_modes or HVAC_MODE_AUTO in hvac_modes: + self._support_flags = self._support_flags | SUPPORT_TARGET_TEMPERATURE_RANGE self._target_temperature = target_temperature self._target_humidity = target_humidity self._unit_of_measurement = unit_of_measurement - self._away = away - self._hold = hold + self._preset = preset + self._preset_modes = preset_modes self._current_temperature = current_temperature self._current_humidity = current_humidity - self._current_fan_mode = current_fan_mode - self._current_operation = current_operation + self._current_fan_mode = fan_mode + self._hvac_action = hvac_action + self._hvac_mode = hvac_mode 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._current_swing_mode = swing_mode + self._fan_modes = ["On Low", "On High", "Auto Low", "Auto High", "Off"] + self._hvac_modes = hvac_modes + self._swing_modes = ["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 device_info(self): + """Return device info.""" + return { + "identifiers": { + # Serial numbers are unique identifiers within a specific domain + (DOMAIN, self.unique_id) + }, + "name": self.name, + } + + @property + def unique_id(self): + """Return the unique id.""" + return self._unique_id @property def supported_features(self): @@ -132,116 +225,98 @@ def target_humidity(self): return self._target_humidity @property - def current_operation(self): + def hvac_action(self): """Return current operation ie. heat, cool, idle.""" - return self._current_operation + return self._hvac_action + + @property + def hvac_mode(self): + """Return hvac target hvac state.""" + return self._hvac_mode @property - def operation_list(self): + def hvac_modes(self): """Return the list of available operation modes.""" - return self._operation_list + return self._hvac_modes @property - def is_away_mode_on(self): - """Return if away mode is on.""" - return self._away + def preset_mode(self): + """Return preset mode.""" + return self._preset @property - def current_hold_mode(self): - """Return hold mode setting.""" - return self._hold + def preset_modes(self): + """Return preset modes.""" + return self._preset_modes @property - def is_aux_heat_on(self): + def is_aux_heat(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): + def fan_mode(self): """Return the fan setting.""" return self._current_fan_mode @property - def fan_list(self): + def fan_modes(self): """Return the list of available fan modes.""" - return self._fan_list + return self._fan_modes - def set_temperature(self, **kwargs): + @property + def swing_mode(self): + """Return the swing setting.""" + return self._current_swing_mode + + @property + def swing_modes(self): + """List of available swing modes.""" + return self._swing_modes + + async def async_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: + 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() + self.async_write_ha_state() - def set_humidity(self, humidity): + async def async_set_humidity(self, humidity): """Set new humidity level.""" self._target_humidity = humidity - self.schedule_update_ha_state() + self.async_write_ha_state() - def set_swing_mode(self, swing_mode): + async def async_set_swing_mode(self, swing_mode): """Set new swing mode.""" self._current_swing_mode = swing_mode - self.schedule_update_ha_state() + self.async_write_ha_state() - def set_fan_mode(self, fan_mode): + async def async_set_fan_mode(self, fan_mode): """Set new fan mode.""" self._current_fan_mode = fan_mode - self.schedule_update_ha_state() + self.async_write_ha_state() - def set_operation_mode(self, operation_mode): + async def async_set_hvac_mode(self, hvac_mode): """Set new operation mode.""" - self._current_operation = operation_mode - self.schedule_update_ha_state() + self._hvac_mode = hvac_mode + self.async_write_ha_state() - @property - def current_swing_mode(self): - """Return the swing setting.""" - return self._current_swing_mode - - @property - def swing_list(self): - """List of available swing modes.""" - return self._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() + async def async_set_preset_mode(self, preset_mode): + """Update preset_mode on.""" + self._preset = preset_mode + self.async_write_ha_state() def turn_aux_heat_on(self): """Turn auxiliary heater on.""" self._aux = True - self.schedule_update_ha_state() + self.async_write_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() + self.async_write_ha_state() diff --git a/homeassistant/components/demo/config_flow.py b/homeassistant/components/demo/config_flow.py new file mode 100644 index 0000000000000..1f3975d024195 --- /dev/null +++ b/homeassistant/components/demo/config_flow.py @@ -0,0 +1,97 @@ +"""Config flow to configure demo component.""" +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv + +# pylint: disable=unused-import +from . import DOMAIN + +CONF_STRING = "string" +CONF_BOOLEAN = "bool" +CONF_INT = "int" +CONF_SELECT = "select" +CONF_MULTISELECT = "multi" + + +class DemoConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Demo configuration flow.""" + + VERSION = 1 + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get the options flow for this handler.""" + return OptionsFlowHandler(config_entry) + + async def async_step_import(self, import_info): + """Set the config entry up from yaml.""" + return self.async_create_entry(title="Demo", data={}) + + +class OptionsFlowHandler(config_entries.OptionsFlow): + """Handle options.""" + + def __init__(self, config_entry): + """Initialize options flow.""" + self.config_entry = config_entry + self.options = dict(config_entry.options) + + async def async_step_init(self, user_input=None): + """Manage the options.""" + return await self.async_step_options_1() + + async def async_step_options_1(self, user_input=None): + """Manage the options.""" + if user_input is not None: + self.options.update(user_input) + return await self.async_step_options_2() + + return self.async_show_form( + step_id="options_1", + data_schema=vol.Schema( + { + vol.Optional( + CONF_BOOLEAN, + default=self.config_entry.options.get(CONF_BOOLEAN, False), + ): bool, + vol.Optional( + CONF_INT, default=self.config_entry.options.get(CONF_INT, 10), + ): int, + } + ), + ) + + async def async_step_options_2(self, user_input=None): + """Manage the options 2.""" + if user_input is not None: + self.options.update(user_input) + return await self._update_options() + + return self.async_show_form( + step_id="options_2", + data_schema=vol.Schema( + { + vol.Optional( + CONF_STRING, + default=self.config_entry.options.get(CONF_STRING, "Default",), + ): str, + vol.Optional( + CONF_SELECT, + default=self.config_entry.options.get(CONF_SELECT, "default"), + ): vol.In(["default", "other"]), + vol.Optional( + CONF_MULTISELECT, + default=self.config_entry.options.get( + CONF_MULTISELECT, ["default"] + ), + ): cv.multi_select({"default": "Default", "other": "Other"}), + } + ), + ) + + async def _update_options(self): + """Update config entry options.""" + return self.async_create_entry(title="", data=self.options) diff --git a/homeassistant/components/demo/const.py b/homeassistant/components/demo/const.py new file mode 100644 index 0000000000000..e11b0b0731a3b --- /dev/null +++ b/homeassistant/components/demo/const.py @@ -0,0 +1,3 @@ +"""Constants for the Demo component.""" +DOMAIN = "demo" +SERVICE_RANDOMIZE_DEVICE_TRACKER_DATA = "randomize_device_tracker_data" diff --git a/homeassistant/components/demo/cover.py b/homeassistant/components/demo/cover.py index aa2931a987a30..e65d6e59ece63 100644 --- a/homeassistant/components/demo/cover.py +++ b/homeassistant/components/demo/cover.py @@ -1,29 +1,56 @@ """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) - + ATTR_POSITION, + ATTR_TILT_POSITION, + SUPPORT_CLOSE, + SUPPORT_OPEN, + CoverEntity, +) +from homeassistant.core import callback +from homeassistant.helpers.event import async_track_utc_time_change -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)), - ]) +from . import DOMAIN -class DemoCover(CoverDevice): +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the Demo covers.""" + async_add_entities( + [ + DemoCover(hass, "cover_1", "Kitchen Window"), + DemoCover(hass, "cover_2", "Hall Window", 10), + DemoCover(hass, "cover_3", "Living Room Window", 70, 50), + DemoCover( + hass, + "cover_4", + "Garage Door", + device_class="garage", + supported_features=(SUPPORT_OPEN | SUPPORT_CLOSE), + ), + ] + ) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Demo config entry.""" + await async_setup_platform(hass, {}, async_add_entities) + + +class DemoCover(CoverEntity): """Representation of a demo cover.""" - def __init__(self, hass, name, position=None, tilt_position=None, - device_class=None, supported_features=None): + def __init__( + self, + hass, + unique_id, + name, + position=None, + tilt_position=None, + device_class=None, + supported_features=None, + ): """Initialize the cover.""" self.hass = hass + self._unique_id = unique_id self._name = name self._position = position self._device_class = device_class @@ -42,6 +69,22 @@ def __init__(self, hass, name, position=None, tilt_position=None, else: self._closed = self.current_cover_position <= 0 + @property + def device_info(self): + """Return device info.""" + return { + "identifiers": { + # Serial numbers are unique identifiers within a specific domain + (DOMAIN, self.unique_id) + }, + "name": self.name, + } + + @property + def unique_id(self): + """Return unique ID for cover.""" + return self._unique_id + @property def name(self): """Return the name of the cover.""" @@ -89,21 +132,21 @@ def supported_features(self): return self._supported_features return super().supported_features - def close_cover(self, **kwargs): + async def async_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() + self.async_write_ha_state() return self._is_closing = True self._listen_cover() self._requested_closing = True - self.schedule_update_ha_state() + self.async_write_ha_state() - def close_cover_tilt(self, **kwargs): + async def async_close_cover_tilt(self, **kwargs): """Close the cover tilt.""" if self._tilt_position in (0, None): return @@ -111,21 +154,21 @@ def close_cover_tilt(self, **kwargs): self._listen_cover_tilt() self._requested_closing_tilt = True - def open_cover(self, **kwargs): + async def async_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() + self.async_write_ha_state() return self._is_opening = True self._listen_cover() self._requested_closing = False - self.schedule_update_ha_state() + self.async_write_ha_state() - def open_cover_tilt(self, **kwargs): + async def async_open_cover_tilt(self, **kwargs): """Open the cover tilt.""" if self._tilt_position in (100, None): return @@ -133,7 +176,7 @@ def open_cover_tilt(self, **kwargs): self._listen_cover_tilt() self._requested_closing_tilt = False - def set_cover_position(self, **kwargs): + async def async_set_cover_position(self, **kwargs): """Move the cover to a specific position.""" position = kwargs.get(ATTR_POSITION) self._set_position = round(position, -1) @@ -143,7 +186,7 @@ def set_cover_position(self, **kwargs): self._listen_cover() self._requested_closing = position < self._position - def set_cover_tilt_position(self, **kwargs): + async def async_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) @@ -153,7 +196,7 @@ def set_cover_tilt_position(self, **kwargs): self._listen_cover_tilt() self._requested_closing_tilt = tilt_position < self._tilt_position - def stop_cover(self, **kwargs): + async def async_stop_cover(self, **kwargs): """Stop the cover.""" self._is_closing = False self._is_opening = False @@ -164,7 +207,7 @@ def stop_cover(self, **kwargs): self._unsub_listener_cover = None self._set_position = None - def stop_cover_tilt(self, **kwargs): + async def async_stop_cover_tilt(self, **kwargs): """Stop the cover tilt.""" if self._tilt_position is None: return @@ -174,13 +217,15 @@ def stop_cover_tilt(self, **kwargs): self._unsub_listener_cover_tilt = None self._set_tilt_position = None + @callback 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) + self._unsub_listener_cover = async_track_utc_time_change( + self.hass, self._time_changed_cover + ) - def _time_changed_cover(self, now): + async def _time_changed_cover(self, now): """Track time changes.""" if self._requested_closing: self._position -= 10 @@ -188,19 +233,20 @@ def _time_changed_cover(self, now): self._position += 10 if self._position in (100, 0, self._set_position): - self.stop_cover() + await self.async_stop_cover() self._closed = self.current_cover_position <= 0 + self.async_write_ha_state() - self.schedule_update_ha_state() - + @callback 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) + self._unsub_listener_cover_tilt = async_track_utc_time_change( + self.hass, self._time_changed_cover_tilt + ) - def _time_changed_cover_tilt(self, now): + async def _time_changed_cover_tilt(self, now): """Track time changes.""" if self._requested_closing_tilt: self._tilt_position -= 10 @@ -208,6 +254,6 @@ def _time_changed_cover_tilt(self, now): self._tilt_position += 10 if self._tilt_position in (100, 0, self._set_tilt_position): - self.stop_cover_tilt() + await self.async_stop_cover_tilt() - self.schedule_update_ha_state() + self.async_write_ha_state() diff --git a/homeassistant/components/demo/device_tracker.py b/homeassistant/components/demo/device_tracker.py index ff038d7009e04..02864111527b6 100644 --- a/homeassistant/components/demo/device_tracker.py +++ b/homeassistant/components/demo/device_tracker.py @@ -1,11 +1,12 @@ """Demo platform for the Device tracker component.""" import random -from homeassistant.components.device_tracker import DOMAIN +from .const import DOMAIN, SERVICE_RANDOMIZE_DEVICE_TRACKER_DATA 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)) @@ -15,27 +16,26 @@ def random_see(dev_id, name): see( dev_id=dev_id, host_name=name, - gps=(hass.config.latitude + offset(), - hass.config.longitude + offset()), + gps=(hass.config.latitude + offset(), hass.config.longitude + offset()), gps_accuracy=random.randrange(50, 150), - battery=random.randrange(10, 90) + battery=random.randrange(10, 90), ) def observe(call=None): """Observe three entities.""" - random_see('demo_paulus', 'Paulus') - random_see('demo_anne_therese', 'Anne Therese') + random_see("demo_paulus", "Paulus") + random_see("demo_anne_therese", "Anne Therese") observe() see( - dev_id='demo_home_boy', - host_name='Home Boy', + 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 + battery=53, ) - hass.services.register(DOMAIN, 'demo', observe) + hass.services.register(DOMAIN, SERVICE_RANDOMIZE_DEVICE_TRACKER_DATA, observe) return True diff --git a/homeassistant/components/demo/fan.py b/homeassistant/components/demo/fan.py index 4710bbecfe1f4..966ba51cacb79 100644 --- a/homeassistant/components/demo/fan.py +++ b/homeassistant/components/demo/fan.py @@ -1,20 +1,32 @@ """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) + SPEED_HIGH, + SPEED_LOW, + SPEED_MEDIUM, + SUPPORT_DIRECTION, + SUPPORT_OSCILLATE, + SUPPORT_SET_SPEED, + FanEntity, +) +from homeassistant.const import STATE_OFF 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): +async def async_setup_platform(hass, config, async_add_entities, 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), - ]) + async_add_entities( + [ + DemoFan(hass, "Living Room Fan", FULL_SUPPORT), + DemoFan(hass, "Ceiling Fan", LIMITED_SUPPORT), + ] + ) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Demo config entry.""" + await async_setup_platform(hass, {}, async_add_entities) class DemoFan(FanEntity): @@ -26,13 +38,13 @@ def __init__(self, hass, name: str, supported_features: int) -> None: self._supported_features = supported_features self._speed = STATE_OFF self.oscillating = None - self.direction = None + self._direction = None self._name = name if supported_features & SUPPORT_OSCILLATE: self.oscillating = False if supported_features & SUPPORT_DIRECTION: - self.direction = "forward" + self._direction = "forward" @property def name(self) -> str: @@ -72,7 +84,7 @@ def set_speed(self, speed: str) -> None: def set_direction(self, direction: str) -> None: """Set the direction of the fan.""" - self.direction = direction + self._direction = direction self.schedule_update_ha_state() def oscillate(self, oscillating: bool) -> None: @@ -83,7 +95,7 @@ def oscillate(self, oscillating: bool) -> None: @property def current_direction(self) -> str: """Fan direction.""" - return self.direction + return self._direction @property def supported_features(self) -> int: diff --git a/homeassistant/components/demo/geo_location.py b/homeassistant/components/demo/geo_location.py index 6b91faac92f36..76dea52e84667 100644 --- a/homeassistant/components/demo/geo_location.py +++ b/homeassistant/components/demo/geo_location.py @@ -5,24 +5,36 @@ import random from typing import Optional -from homeassistant.helpers.event import track_time_interval - from homeassistant.components.geo_location import GeolocationEvent +from homeassistant.const import LENGTH_KILOMETERS +from homeassistant.helpers.event import track_time_interval _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' +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): @@ -47,24 +59,26 @@ def _generate_random_event(self): 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_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)) + 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) + return DemoGeolocationEvent( + event_name, radius_in_km, latitude, longitude, LENGTH_KILOMETERS + ) 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) + track_time_interval( + self._hass, lambda now: self._update(), DEFAULT_UPDATE_INTERVAL + ) def _update(self, count=1): """Remove events and add new random events.""" @@ -89,8 +103,7 @@ def _update(self, count=1): class DemoGeolocationEvent(GeolocationEvent): """This represents a demo geolocation event.""" - def __init__(self, name, distance, latitude, longitude, - unit_of_measurement): + def __init__(self, name, distance, latitude, longitude, unit_of_measurement): """Initialize entity with data provided.""" self._name = name self._distance = distance diff --git a/homeassistant/components/demo/image_processing.py b/homeassistant/components/demo/image_processing.py index acb97e4ebd613..9183609509e53 100644 --- a/homeassistant/components/demo/image_processing.py +++ b/homeassistant/components/demo/image_processing.py @@ -1,19 +1,24 @@ """Support for the demo image processing.""" from homeassistant.components.image_processing import ( - ImageProcessingFaceEntity, ATTR_CONFIDENCE, ATTR_NAME, ATTR_AGE, - ATTR_GENDER - ) + ATTR_AGE, + ATTR_CONFIDENCE, + ATTR_GENDER, + ATTR_NAME, + ImageProcessingFaceEntity, +) from homeassistant.components.openalpr_local.image_processing import ( - ImageProcessingAlprEntity) + 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") - ]) + add_entities( + [ + DemoImageProcessingAlpr("camera.demo_camera", "Demo Alpr"), + DemoImageProcessingFace("camera.demo_camera", "Demo Face"), + ] + ) class DemoImageProcessingAlpr(ImageProcessingAlprEntity): @@ -44,10 +49,10 @@ def name(self): def process_image(self, image): """Process image.""" demo_data = { - 'AC3829': 98.3, - 'BE392034': 95.5, - 'CD02394': 93.4, - 'DF923043': 90.8 + "AC3829": 98.3, + "BE392034": 95.5, + "CD02394": 93.4, + "DF923043": 90.8, } self.process_plates(demo_data, 1) @@ -83,19 +88,12 @@ def process_image(self, image): demo_data = [ { ATTR_CONFIDENCE: 98.34, - ATTR_NAME: 'Hans', + 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', + 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 index 285866c6eb8c9..11b6a4812e8ac 100644 --- a/homeassistant/components/demo/light.py +++ b/homeassistant/components/demo/light.py @@ -2,41 +2,77 @@ 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) + 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, + LightEntity, +) -LIGHT_COLORS = [ - (56, 86), - (345, 75), -] +from . import DOMAIN -LIGHT_EFFECT_LIST = ['rainbow', 'none'] +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) +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): +async def async_setup_platform(hass, config, async_add_entities, 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): + async_add_entities( + [ + DemoLight( + "light_1", + "Bed Light", + False, + True, + effect_list=LIGHT_EFFECT_LIST, + effect=LIGHT_EFFECT_LIST[0], + ), + DemoLight("light_2", "Ceiling Lights", True, True, ct=LIGHT_TEMPS[1]), + DemoLight( + "light_3", "Kitchen Lights", True, True, LIGHT_COLORS[1], LIGHT_TEMPS[0] + ), + ] + ) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Demo config entry.""" + await async_setup_platform(hass, {}, async_add_entities) + + +class DemoLight(LightEntity): """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): + 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 @@ -48,6 +84,18 @@ def __init__(self, unique_id, name, state, available=False, hs_color=None, self._effect_list = effect_list self._effect = effect self._available = True + self._color_mode = "ct" if ct is not None and hs_color is None else "hs" + + @property + def device_info(self): + """Return device info.""" + return { + "identifiers": { + # Serial numbers are unique identifiers within a specific domain + (DOMAIN, self.unique_id) + }, + "name": self.name, + } @property def should_poll(self) -> bool: @@ -79,12 +127,16 @@ def brightness(self) -> int: @property def hs_color(self) -> tuple: """Return the hs color value.""" - return self._hs_color + if self._color_mode == "hs": + return self._hs_color + return None @property def color_temp(self) -> int: """Return the CT color temperature.""" - return self._ct + if self._color_mode == "ct": + return self._ct + return None @property def white_value(self) -> int: @@ -111,14 +163,16 @@ def supported_features(self) -> int: """Flag supported features.""" return SUPPORT_DEMO - def turn_on(self, **kwargs) -> None: + async def async_turn_on(self, **kwargs) -> None: """Turn the light on.""" self._state = True if ATTR_HS_COLOR in kwargs: + self._color_mode = "hs" self._hs_color = kwargs[ATTR_HS_COLOR] if ATTR_COLOR_TEMP in kwargs: + self._color_mode = "ct" self._ct = kwargs[ATTR_COLOR_TEMP] if ATTR_BRIGHTNESS in kwargs: @@ -132,12 +186,12 @@ def turn_on(self, **kwargs) -> None: # As we have disabled polling, we need to inform # Home Assistant about updates in our state ourselves. - self.schedule_update_ha_state() + self.async_write_ha_state() - def turn_off(self, **kwargs) -> None: + async def async_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() + self.async_write_ha_state() diff --git a/homeassistant/components/demo/lock.py b/homeassistant/components/demo/lock.py index cd15a43413805..63f2d2189579f 100644 --- a/homeassistant/components/demo/lock.py +++ b/homeassistant/components/demo/lock.py @@ -1,19 +1,25 @@ """Demo lock platform that has two fake locks.""" +from homeassistant.components.lock import SUPPORT_OPEN, LockEntity 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): +async def async_setup_platform(hass, config, async_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) - ]) + async_add_entities( + [ + DemoLock("Front Door", STATE_LOCKED), + DemoLock("Kitchen Door", STATE_UNLOCKED), + DemoLock("Openable Lock", STATE_LOCKED, True), + ] + ) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Demo config entry.""" + await async_setup_platform(hass, {}, async_add_entities) -class DemoLock(LockDevice): +class DemoLock(LockEntity): """Representation of a Demo lock.""" def __init__(self, name, state, openable=False): diff --git a/homeassistant/components/demo/mailbox.py b/homeassistant/components/demo/mailbox.py index fcffc44eefb83..860524dfd7cc0 100644 --- a/homeassistant/components/demo/mailbox.py +++ b/homeassistant/components/demo/mailbox.py @@ -3,13 +3,12 @@ import logging import os -from homeassistant.components.mailbox import ( - CONTENT_TYPE_MPEG, Mailbox, StreamError) +from homeassistant.components.mailbox import CONTENT_TYPE_MPEG, Mailbox, StreamError from homeassistant.util import dt _LOGGER = logging.getLogger(__name__) -MAILBOX_NAME = 'DemoMailbox' +MAILBOX_NAME = "DemoMailbox" async def async_get_handler(hass, config, discovery_info=None): @@ -26,19 +25,17 @@ def __init__(self, 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() + msgtime = int(dt.as_timestamp(dt.utcnow()) - 3600 * 24 * (10 - idx)) + msgtxt = f"Message {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', + "info": { + "origtime": msgtime, + "callerid": "John Doe <212-555-1212>", + "duration": "10", }, - 'text': msgtxt, - 'sha': msgsha, + "text": msgtxt, + "sha": msgsha, } self._messages[msgsha] = msg @@ -62,18 +59,19 @@ async def async_get_media(self, 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: + 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) + return sorted( + self._messages.values(), + key=lambda item: item["info"]["origtime"], + reverse=True, + ) - def async_delete(self, msgid): + async def async_delete(self, msgid): """Delete the specified messages.""" if msgid in self._messages: _LOGGER.info("Deleting: %s", msgid) diff --git a/homeassistant/components/demo/manifest.json b/homeassistant/components/demo/manifest.json index 4f167ecae25af..0abe5fb3347fa 100644 --- a/homeassistant/components/demo/manifest.json +++ b/homeassistant/components/demo/manifest.json @@ -1,15 +1,8 @@ { "domain": "demo", "name": "Demo", - "documentation": "https://www.home-assistant.io/components/demo", - "requirements": [], - "dependencies": [ - "conversation", - "zone", - "group", - "configurator" - ], - "codeowners": [ - "@home-assistant/core" - ] + "documentation": "https://www.home-assistant.io/integrations/demo", + "dependencies": ["conversation", "zone", "group", "configurator"], + "codeowners": ["@home-assistant/core"], + "quality_scale": "internal" } diff --git a/homeassistant/components/demo/media_player.py b/homeassistant/components/demo/media_player.py index e293632b71eb4..9cfb5582acca2 100644 --- a/homeassistant/components/demo/media_player.py +++ b/homeassistant/components/demo/media_player.py @@ -1,53 +1,99 @@ """Demo implementation of the media player.""" -from homeassistant.components.media_player import MediaPlayerDevice +from homeassistant.components.media_player import MediaPlayerEntity 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) + 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): +async def async_setup_platform(hass, config, async_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): + async_add_entities( + [ + DemoYoutubePlayer( + "Living Room", + "eyU3bRy2x44", + "♥♥ The Best Fireplace Video (3 hours)", + 300, + ), + DemoYoutubePlayer( + "Bedroom", "kxopViU98Xo", "Epic sax guy 10 hours", 360000 + ), + DemoMusicPlayer(), + DemoTVShowPlayer(), + ] + ) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Demo config entry.""" + await async_setup_platform(hass, {}, async_add_entities) + + +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(MediaPlayerEntity): """A demo media players.""" # We only implement the methods that we support @@ -170,7 +216,7 @@ def __init__(self, name, youtube_id=None, media_title=None, duration=360): self.youtube_id = youtube_id self._media_title = media_title self._duration = duration - self._progress = int(duration * .15) + self._progress = int(duration * 0.15) self._progress_updated_at = dt_util.utcnow() @property @@ -191,7 +237,7 @@ def media_duration(self): @property def media_image_url(self): """Return the image url of current playing media.""" - return YOUTUBE_COVER_URL_FORMAT.format(self.youtube_id) + return f"https://img.youtube.com/vi/{self.youtube_id}/hqdefault.jpg" @property def media_title(self): @@ -217,8 +263,7 @@ def media_position(self): position = self._progress if self._player_state == STATE_PLAYING: - position += (dt_util.utcnow() - - self._progress_updated_at).total_seconds() + position += (dt_util.utcnow() - self._progress_updated_at).total_seconds() return position @@ -249,36 +294,37 @@ class DemoMusicPlayer(AbstractDemoPlayer): # 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)'), + ("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') + super().__init__("Walkman") self._cur_track = 0 @property def media_content_id(self): """Return the content ID of current playing media.""" - return 'bounzz-1' + return "bounzz-1" @property def media_content_type(self): @@ -293,8 +339,7 @@ def media_duration(self): @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' + return "https://graph.facebook.com/v2.5/107771475912710/picture?type=large" @property def media_title(self): @@ -348,15 +393,15 @@ class DemoTVShowPlayer(AbstractDemoPlayer): def __init__(self): """Initialize the demo device.""" - super().__init__('Lounge room') + super().__init__("Lounge room") self._cur_episode = 1 self._episode_count = 13 - self._source = 'dvd' + self._source = "dvd" @property def media_content_id(self): """Return the content ID of current playing media.""" - return 'house-of-cards-1' + return "house-of-cards-1" @property def media_content_type(self): @@ -371,17 +416,17 @@ def media_duration(self): @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' + 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) + return f"Chapter {self._cur_episode}" @property def media_series_title(self): """Return the series title of current playing media (TV Show only).""" - return 'House of Cards' + return "House of Cards" @property def media_season(self): diff --git a/homeassistant/components/demo/notify.py b/homeassistant/components/demo/notify.py index 92aaea6882dba..f390c042ce4f8 100644 --- a/homeassistant/components/demo/notify.py +++ b/homeassistant/components/demo/notify.py @@ -23,5 +23,5 @@ def targets(self): def send_message(self, message="", **kwargs): """Send a message to a user.""" - kwargs['message'] = message + kwargs["message"] = message self.hass.bus.fire(EVENT_NOTIFY, kwargs) diff --git a/homeassistant/components/demo/remote.py b/homeassistant/components/demo/remote.py index b28330fdc67f6..9d12621fef134 100644 --- a/homeassistant/components/demo/remote.py +++ b/homeassistant/components/demo/remote.py @@ -1,17 +1,24 @@ """Demo platform that has two fake remotes.""" -from homeassistant.components.remote import RemoteDevice +from homeassistant.components.remote import RemoteEntity from homeassistant.const import DEVICE_DEFAULT_NAME +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Demo config entry.""" + setup_platform(hass, {}, async_add_entities) + + 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'), - ]) + add_entities_callback( + [ + DemoRemote("Remote One", False, None), + DemoRemote("Remote Two", True, "mdi:remote"), + ] + ) -class DemoRemote(RemoteDevice): +class DemoRemote(RemoteEntity): """Representation of a demo remote.""" def __init__(self, name, state, icon): @@ -45,7 +52,7 @@ def is_on(self): 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} + return {"last_command_sent": self._last_command_sent} def turn_on(self, **kwargs): """Turn the remote on.""" diff --git a/homeassistant/components/demo/sensor.py b/homeassistant/components/demo/sensor.py index ea35c729517f7..6805ebb5b560e 100644 --- a/homeassistant/components/demo/sensor.py +++ b/homeassistant/components/demo/sensor.py @@ -1,31 +1,75 @@ """Demo platform that has a couple of fake sensors.""" from homeassistant.const import ( - ATTR_BATTERY_LEVEL, TEMP_CELSIUS, DEVICE_CLASS_HUMIDITY, - DEVICE_CLASS_TEMPERATURE) + ATTR_BATTERY_LEVEL, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_TEMPERATURE, + TEMP_CELSIUS, + UNIT_PERCENTAGE, +) from homeassistant.helpers.entity import Entity +from . import DOMAIN -def setup_platform(hass, config, add_entities, discovery_info=None): + +async def async_setup_platform(hass, config, async_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), - ]) + async_add_entities( + [ + DemoSensor( + "sensor_1", + "Outside Temperature", + 15.6, + DEVICE_CLASS_TEMPERATURE, + TEMP_CELSIUS, + 12, + ), + DemoSensor( + "sensor_2", + "Outside Humidity", + 54, + DEVICE_CLASS_HUMIDITY, + UNIT_PERCENTAGE, + None, + ), + ] + ) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Demo config entry.""" + await async_setup_platform(hass, {}, async_add_entities) class DemoSensor(Entity): """Representation of a Demo sensor.""" - def __init__(self, name, state, device_class, - unit_of_measurement, battery): + def __init__( + self, unique_id, name, state, device_class, unit_of_measurement, battery + ): """Initialize the sensor.""" + self._unique_id = unique_id self._name = name self._state = state self._device_class = device_class self._unit_of_measurement = unit_of_measurement self._battery = battery + @property + def device_info(self): + """Return device info.""" + return { + "identifiers": { + # Serial numbers are unique identifiers within a specific domain + (DOMAIN, self.unique_id) + }, + "name": self.name, + } + + @property + def unique_id(self): + """Return the unique id.""" + return self._unique_id + @property def should_poll(self): """No polling needed for a demo sensor.""" @@ -55,6 +99,4 @@ def unit_of_measurement(self): def device_state_attributes(self): """Return the state attributes.""" if self._battery: - return { - ATTR_BATTERY_LEVEL: self._battery, - } + return {ATTR_BATTERY_LEVEL: self._battery} diff --git a/homeassistant/components/demo/services.yaml b/homeassistant/components/demo/services.yaml index e69de29bb2d1d..aed23eed95aa6 100644 --- a/homeassistant/components/demo/services.yaml +++ b/homeassistant/components/demo/services.yaml @@ -0,0 +1,2 @@ +randomize_device_tracker_data: + description: Demonstrates using a device tracker to see where devices are located diff --git a/homeassistant/components/demo/strings.json b/homeassistant/components/demo/strings.json new file mode 100644 index 0000000000000..95497b8bf19e1 --- /dev/null +++ b/homeassistant/components/demo/strings.json @@ -0,0 +1,23 @@ +{ + "title": "Demo", + "options": { + "step": { + "init": { + "data": {} + }, + "options_1": { + "data": { + "bool": "Optional boolean", + "int": "Numeric input" + } + }, + "options_2": { + "data": { + "string": "String value", + "select": "Select an option", + "multi": "Multiselect" + } + } + } + } +} diff --git a/homeassistant/components/demo/stt.py b/homeassistant/components/demo/stt.py new file mode 100644 index 0000000000000..e0367fad6a920 --- /dev/null +++ b/homeassistant/components/demo/stt.py @@ -0,0 +1,66 @@ +"""Support for the demo for speech to text service.""" +from typing import List + +from aiohttp import StreamReader + +from homeassistant.components.stt import Provider, SpeechMetadata, SpeechResult +from homeassistant.components.stt.const import ( + AudioBitRates, + AudioChannels, + AudioCodecs, + AudioFormats, + AudioSampleRates, + SpeechResultState, +) + +SUPPORT_LANGUAGES = ["en", "de"] + + +async def async_get_engine(hass, config, discovery_info=None): + """Set up Demo speech component.""" + return DemoProvider() + + +class DemoProvider(Provider): + """Demo speech API provider.""" + + @property + def supported_languages(self) -> List[str]: + """Return a list of supported languages.""" + return SUPPORT_LANGUAGES + + @property + def supported_formats(self) -> List[AudioFormats]: + """Return a list of supported formats.""" + return [AudioFormats.WAV] + + @property + def supported_codecs(self) -> List[AudioCodecs]: + """Return a list of supported codecs.""" + return [AudioCodecs.PCM] + + @property + def supported_bit_rates(self) -> List[AudioBitRates]: + """Return a list of supported bit rates.""" + return [AudioBitRates.BITRATE_16] + + @property + def supported_sample_rates(self) -> List[AudioSampleRates]: + """Return a list of supported sample rates.""" + return [AudioSampleRates.SAMPLERATE_16000, AudioSampleRates.SAMPLERATE_44100] + + @property + def supported_channels(self) -> List[AudioChannels]: + """Return a list of supported channels.""" + return [AudioChannels.CHANNEL_STEREO] + + async def async_process_audio_stream( + self, metadata: SpeechMetadata, stream: StreamReader + ) -> SpeechResult: + """Process an audio stream to STT service.""" + + # Read available data + async for _ in stream.iter_chunked(4096): + pass + + return SpeechResult("Turn the Kitchen Lights on", SpeechResultState.SUCCESS) diff --git a/homeassistant/components/demo/switch.py b/homeassistant/components/demo/switch.py index e7a3b1741a2b4..cdbeb142677b9 100644 --- a/homeassistant/components/demo/switch.py +++ b/homeassistant/components/demo/switch.py @@ -1,27 +1,60 @@ """Demo platform that has two fake switches.""" -from homeassistant.components.switch import SwitchDevice +from homeassistant.components.switch import SwitchEntity from homeassistant.const import DEVICE_DEFAULT_NAME +from . import DOMAIN -def setup_platform(hass, config, add_entities_callback, discovery_info=None): + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the demo switches.""" - add_entities_callback([ - DemoSwitch('Decorative Lights', True, None, True), - DemoSwitch('AC', False, 'mdi:air-conditioner', False) - ]) + async_add_entities( + [ + DemoSwitch("swith1", "Decorative Lights", True, None, True), + DemoSwitch( + "swith2", + "AC", + False, + "mdi:air-conditioner", + False, + device_class="outlet", + ), + ] + ) + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Demo config entry.""" + await async_setup_platform(hass, {}, async_add_entities) -class DemoSwitch(SwitchDevice): + +class DemoSwitch(SwitchEntity): """Representation of a demo switch.""" - def __init__(self, name, state, icon, assumed, device_class=None): + def __init__(self, unique_id, name, state, icon, assumed, device_class=None): """Initialize the Demo switch.""" + self._unique_id = unique_id self._name = name or DEVICE_DEFAULT_NAME self._state = state self._icon = icon self._assumed = assumed self._device_class = device_class + @property + def device_info(self): + """Return device info.""" + return { + "identifiers": { + # Serial numbers are unique identifiers within a specific domain + (DOMAIN, self.unique_id) + }, + "name": self.name, + } + + @property + def unique_id(self): + """Return the unique id.""" + return self._unique_id + @property def should_poll(self): """No polling needed for a demo switch.""" diff --git a/homeassistant/components/demo/translations/bg.json b/homeassistant/components/demo/translations/bg.json new file mode 100644 index 0000000000000..9609b0e64d940 --- /dev/null +++ b/homeassistant/components/demo/translations/bg.json @@ -0,0 +1,3 @@ +{ + "title": "\u0414\u0435\u043c\u043e\u043d\u0441\u0442\u0440\u0430\u0446\u0438\u044f" +} \ No newline at end of file diff --git a/homeassistant/components/demo/translations/ca.json b/homeassistant/components/demo/translations/ca.json new file mode 100644 index 0000000000000..a4176a4308539 --- /dev/null +++ b/homeassistant/components/demo/translations/ca.json @@ -0,0 +1,20 @@ +{ + "options": { + "step": { + "options_1": { + "data": { + "bool": "Entrada booleana opcional", + "int": "Entrada num\u00e8rica" + } + }, + "options_2": { + "data": { + "multi": "Selecci\u00f3 m\u00faltiple", + "select": "Selecciona una opci\u00f3", + "string": "Valor de cadena (string)" + } + } + } + }, + "title": "Demostraci\u00f3" +} \ No newline at end of file diff --git a/homeassistant/components/demo/translations/da.json b/homeassistant/components/demo/translations/da.json new file mode 100644 index 0000000000000..7017d075a8eb8 --- /dev/null +++ b/homeassistant/components/demo/translations/da.json @@ -0,0 +1,26 @@ +{ + "options": { + "step": { + "init": { + "data": { + "one": "en", + "other": "anden" + } + }, + "options_1": { + "data": { + "bool": "Valgfri boolsk", + "int": "Numerisk input" + } + }, + "options_2": { + "data": { + "multi": "Multimarkering", + "select": "V\u00e6lg en mulighed", + "string": "Strengv\u00e6rdi" + } + } + } + }, + "title": "Demo" +} \ No newline at end of file diff --git a/homeassistant/components/demo/translations/de.json b/homeassistant/components/demo/translations/de.json new file mode 100644 index 0000000000000..30778b4490b5c --- /dev/null +++ b/homeassistant/components/demo/translations/de.json @@ -0,0 +1,26 @@ +{ + "options": { + "step": { + "init": { + "data": { + "one": "eins", + "other": "andere" + } + }, + "options_1": { + "data": { + "bool": "Optionaler Boolescher Wert", + "int": "Numerische Eingabe" + } + }, + "options_2": { + "data": { + "multi": "Mehrfachauswahl", + "select": "W\u00e4hlen Sie eine Option", + "string": "String-Wert" + } + } + } + }, + "title": "Demo" +} \ No newline at end of file diff --git a/homeassistant/components/demo/translations/en.json b/homeassistant/components/demo/translations/en.json new file mode 100644 index 0000000000000..6c1d1c1b3e68f --- /dev/null +++ b/homeassistant/components/demo/translations/en.json @@ -0,0 +1,20 @@ +{ + "options": { + "step": { + "options_1": { + "data": { + "bool": "Optional boolean", + "int": "Numeric input" + } + }, + "options_2": { + "data": { + "multi": "Multiselect", + "select": "Select an option", + "string": "String value" + } + } + } + }, + "title": "Demo" +} \ No newline at end of file diff --git a/homeassistant/components/demo/translations/es-419.json b/homeassistant/components/demo/translations/es-419.json new file mode 100644 index 0000000000000..a9abb4aacd9cf --- /dev/null +++ b/homeassistant/components/demo/translations/es-419.json @@ -0,0 +1,3 @@ +{ + "title": "Demo" +} \ No newline at end of file diff --git a/homeassistant/components/demo/translations/es.json b/homeassistant/components/demo/translations/es.json new file mode 100644 index 0000000000000..a60aee6a42be1 --- /dev/null +++ b/homeassistant/components/demo/translations/es.json @@ -0,0 +1,26 @@ +{ + "options": { + "step": { + "init": { + "data": { + "one": "Vacio", + "other": "Vacio" + } + }, + "options_1": { + "data": { + "bool": "Booleano opcional", + "int": "Entrada num\u00e9rica" + } + }, + "options_2": { + "data": { + "multi": "Multiselecci\u00f3n", + "select": "Selecciona una opci\u00f3n", + "string": "Valor de cadena" + } + } + } + }, + "title": "Demo" +} \ No newline at end of file diff --git a/homeassistant/components/demo/translations/fr.json b/homeassistant/components/demo/translations/fr.json new file mode 100644 index 0000000000000..941f04f5c9e45 --- /dev/null +++ b/homeassistant/components/demo/translations/fr.json @@ -0,0 +1,20 @@ +{ + "options": { + "step": { + "options_1": { + "data": { + "bool": "Bool\u00e9en facultatif", + "int": "Entr\u00e9e num\u00e9rique" + } + }, + "options_2": { + "data": { + "multi": "S\u00e9lection multiple", + "select": "S\u00e9lectionnez une option", + "string": "Valeur de cha\u00eene" + } + } + } + }, + "title": "D\u00e9mo" +} \ No newline at end of file diff --git a/homeassistant/components/demo/translations/hu.json b/homeassistant/components/demo/translations/hu.json new file mode 100644 index 0000000000000..996b9c138ef98 --- /dev/null +++ b/homeassistant/components/demo/translations/hu.json @@ -0,0 +1,3 @@ +{ + "title": "Dem\u00f3" +} \ No newline at end of file diff --git a/homeassistant/components/demo/translations/it.json b/homeassistant/components/demo/translations/it.json new file mode 100644 index 0000000000000..16477633de2ab --- /dev/null +++ b/homeassistant/components/demo/translations/it.json @@ -0,0 +1,26 @@ +{ + "options": { + "step": { + "init": { + "data": { + "one": "uno", + "other": "altri" + } + }, + "options_1": { + "data": { + "bool": "Valore booleano facoltativo", + "int": "Input numerico" + } + }, + "options_2": { + "data": { + "multi": "Selezione multipla", + "select": "Selezionare un'opzione", + "string": "Valore stringa" + } + } + } + }, + "title": "Demo" +} \ No newline at end of file diff --git a/homeassistant/components/demo/translations/ja.json b/homeassistant/components/demo/translations/ja.json new file mode 100644 index 0000000000000..713cdd6ae3509 --- /dev/null +++ b/homeassistant/components/demo/translations/ja.json @@ -0,0 +1,3 @@ +{ + "title": "\u30c7\u30e2" +} \ No newline at end of file diff --git a/homeassistant/components/demo/translations/ko.json b/homeassistant/components/demo/translations/ko.json new file mode 100644 index 0000000000000..e9e02a6e1b9f4 --- /dev/null +++ b/homeassistant/components/demo/translations/ko.json @@ -0,0 +1,20 @@ +{ + "options": { + "step": { + "options_1": { + "data": { + "bool": "\ub17c\ub9ac \uc120\ud0dd", + "int": "\uc22b\uc790 \uc785\ub825" + } + }, + "options_2": { + "data": { + "multi": "\ub2e4\uc911 \uc120\ud0dd", + "select": "\uc635\uc158 \uc120\ud0dd", + "string": "\ubb38\uc790\uc5f4 \uac12" + } + } + } + }, + "title": "\ub370\ubaa8" +} \ No newline at end of file diff --git a/homeassistant/components/demo/translations/lb.json b/homeassistant/components/demo/translations/lb.json new file mode 100644 index 0000000000000..864a3603f3d99 --- /dev/null +++ b/homeassistant/components/demo/translations/lb.json @@ -0,0 +1,26 @@ +{ + "options": { + "step": { + "init": { + "data": { + "one": "Een", + "other": "M\u00e9i" + } + }, + "options_1": { + "data": { + "bool": "Optionelle Boolean", + "int": "Numeresch Agab" + } + }, + "options_2": { + "data": { + "multi": "Multiple Auswiel", + "select": "Eng Optioun auswielen", + "string": "String W\u00e4ert" + } + } + } + }, + "title": "Demo" +} \ No newline at end of file diff --git a/homeassistant/components/demo/translations/lv.json b/homeassistant/components/demo/translations/lv.json new file mode 100644 index 0000000000000..a13903a0f66b4 --- /dev/null +++ b/homeassistant/components/demo/translations/lv.json @@ -0,0 +1,3 @@ +{ + "title": "Demonstr\u0101cija" +} \ No newline at end of file diff --git a/homeassistant/components/demo/translations/nl.json b/homeassistant/components/demo/translations/nl.json new file mode 100644 index 0000000000000..ac10172933f14 --- /dev/null +++ b/homeassistant/components/demo/translations/nl.json @@ -0,0 +1,26 @@ +{ + "options": { + "step": { + "init": { + "data": { + "one": "Empty", + "other": "" + } + }, + "options_1": { + "data": { + "bool": "Optioneel Boolean", + "int": "Numerieke invoer" + } + }, + "options_2": { + "data": { + "multi": "Meerkeuze selectie", + "select": "Kies een optie", + "string": "String waarde" + } + } + } + }, + "title": "Demo" +} \ No newline at end of file diff --git a/homeassistant/components/demo/translations/no.json b/homeassistant/components/demo/translations/no.json new file mode 100644 index 0000000000000..e85f5b067a0e4 --- /dev/null +++ b/homeassistant/components/demo/translations/no.json @@ -0,0 +1,20 @@ +{ + "options": { + "step": { + "options_1": { + "data": { + "bool": "Valgfri boolean", + "int": "Numerisk inndata" + } + }, + "options_2": { + "data": { + "multi": "Flervalg", + "select": "Velg et alternativ", + "string": "Strengverdi" + } + } + } + }, + "title": "" +} \ No newline at end of file diff --git a/homeassistant/components/demo/translations/pl.json b/homeassistant/components/demo/translations/pl.json new file mode 100644 index 0000000000000..ea46ae807c6cb --- /dev/null +++ b/homeassistant/components/demo/translations/pl.json @@ -0,0 +1,28 @@ +{ + "options": { + "step": { + "init": { + "data": { + "few": "kilka", + "many": "wiele", + "one": "jedena", + "other": "inne" + } + }, + "options_1": { + "data": { + "bool": "Warto\u015b\u0107 logiczna", + "int": "Warto\u015b\u0107 numeryczna" + } + }, + "options_2": { + "data": { + "multi": "Wielokrotny wyb\u00f3r", + "select": "Wybierz opcj\u0119", + "string": "Warto\u015b\u0107 tekstowa" + } + } + } + }, + "title": "Demo" +} \ No newline at end of file diff --git a/homeassistant/components/demo/translations/pt-BR.json b/homeassistant/components/demo/translations/pt-BR.json new file mode 100644 index 0000000000000..9a07b5ebc5025 --- /dev/null +++ b/homeassistant/components/demo/translations/pt-BR.json @@ -0,0 +1,3 @@ +{ + "title": "Demonstra\u00e7\u00e3o" +} \ No newline at end of file diff --git a/homeassistant/components/demo/translations/pt.json b/homeassistant/components/demo/translations/pt.json new file mode 100644 index 0000000000000..9a07b5ebc5025 --- /dev/null +++ b/homeassistant/components/demo/translations/pt.json @@ -0,0 +1,3 @@ +{ + "title": "Demonstra\u00e7\u00e3o" +} \ No newline at end of file diff --git a/homeassistant/components/demo/translations/ru.json b/homeassistant/components/demo/translations/ru.json new file mode 100644 index 0000000000000..a793985702f96 --- /dev/null +++ b/homeassistant/components/demo/translations/ru.json @@ -0,0 +1,20 @@ +{ + "options": { + "step": { + "options_1": { + "data": { + "bool": "\u041b\u043e\u0433\u0438\u0447\u0435\u0441\u043a\u0438\u0439", + "int": "\u0427\u0438\u0441\u043b\u043e\u0432\u043e\u0439" + } + }, + "options_2": { + "data": { + "multi": "\u0412\u044b\u0431\u0440\u0430\u0442\u044c \u043d\u0435\u0441\u043a\u043e\u043b\u044c\u043a\u043e", + "select": "\u0412\u044b\u0431\u0440\u0430\u0442\u044c \u043e\u043f\u0446\u0438\u044e", + "string": "\u0421\u0442\u0440\u043e\u043a\u043e\u0432\u043e\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435" + } + } + } + }, + "title": "\u0414\u0435\u043c\u043e" +} \ No newline at end of file diff --git a/homeassistant/components/demo/translations/sl.json b/homeassistant/components/demo/translations/sl.json new file mode 100644 index 0000000000000..33e4ece832cc0 --- /dev/null +++ b/homeassistant/components/demo/translations/sl.json @@ -0,0 +1,28 @@ +{ + "options": { + "step": { + "init": { + "data": { + "few": "prazni", + "one": "prazen", + "other": "prazni", + "two": "prazna" + } + }, + "options_1": { + "data": { + "bool": "Izbirna logi\u010dna vrednost", + "int": "\u0160tevil\u010dni vnos" + } + }, + "options_2": { + "data": { + "multi": "Multiselect", + "select": "Izberite mo\u017enost", + "string": "Vrednost niza" + } + } + } + }, + "title": "Demo" +} \ No newline at end of file diff --git a/homeassistant/components/demo/translations/sv.json b/homeassistant/components/demo/translations/sv.json new file mode 100644 index 0000000000000..b577b02e25bad --- /dev/null +++ b/homeassistant/components/demo/translations/sv.json @@ -0,0 +1,26 @@ +{ + "options": { + "step": { + "init": { + "data": { + "one": "Tom", + "other": "Tomma" + } + }, + "options_1": { + "data": { + "bool": "Valfritt boolesk", + "int": "Numerisk inmatning" + } + }, + "options_2": { + "data": { + "multi": "Flera val", + "select": "V\u00e4lj ett alternativ", + "string": "Str\u00e4ngv\u00e4rde" + } + } + } + }, + "title": "Demo" +} \ No newline at end of file diff --git a/homeassistant/components/demo/translations/zh-Hans.json b/homeassistant/components/demo/translations/zh-Hans.json new file mode 100644 index 0000000000000..9155b5066c533 --- /dev/null +++ b/homeassistant/components/demo/translations/zh-Hans.json @@ -0,0 +1,19 @@ +{ + "options": { + "step": { + "options_1": { + "data": { + "bool": "\u5e03\u5c14\u9009\u9879", + "int": "\u6570\u503c\u8f93\u5165" + } + }, + "options_2": { + "data": { + "multi": "\u591a\u91cd\u9009\u62e9", + "select": "\u9009\u62e9\u9009\u9879", + "string": "\u5b57\u7b26\u4e32\u503c" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/demo/translations/zh-Hant.json b/homeassistant/components/demo/translations/zh-Hant.json new file mode 100644 index 0000000000000..084db6adfa2df --- /dev/null +++ b/homeassistant/components/demo/translations/zh-Hant.json @@ -0,0 +1,20 @@ +{ + "options": { + "step": { + "options_1": { + "data": { + "bool": "\u9078\u9805\u5e03\u6797", + "int": "\u6578\u503c\u8f38\u5165" + } + }, + "options_2": { + "data": { + "multi": "\u591a\u91cd\u9078\u64c7", + "select": "\u9078\u64c7\u9078\u9805", + "string": "\u5b57\u4e32\u503c" + } + } + } + }, + "title": "\u5c55\u793a" +} \ No newline at end of file diff --git a/homeassistant/components/demo/tts.py b/homeassistant/components/demo/tts.py index bf18bc1630f66..b7be6349d98d9 100644 --- a/homeassistant/components/demo/tts.py +++ b/homeassistant/components/demo/tts.py @@ -1,24 +1,22 @@ -"""Support for the demo speech service.""" +"""Support for the demo for text to speech service.""" import os import voluptuous as vol from homeassistant.components.tts import CONF_LANG, PLATFORM_SCHEMA, Provider -SUPPORT_LANGUAGES = [ - 'en', 'de' -] +SUPPORT_LANGUAGES = ["en", "de"] -DEFAULT_LANG = 'en' +DEFAULT_LANG = "en" -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_LANG, default=DEFAULT_LANG): vol.In(SUPPORT_LANGUAGES), -}) +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + {vol.Optional(CONF_LANG, default=DEFAULT_LANG): vol.In(SUPPORT_LANGUAGES)} +) -def get_engine(hass, config): +def get_engine(hass, config, discovery_info=None): """Set up Demo speech component.""" - return DemoProvider(config[CONF_LANG]) + return DemoProvider(config.get(CONF_LANG, DEFAULT_LANG)) class DemoProvider(Provider): @@ -27,7 +25,7 @@ class DemoProvider(Provider): def __init__(self, lang): """Initialize demo provider.""" self._lang = lang - self.name = 'Demo' + self.name = "Demo" @property def default_language(self): @@ -42,15 +40,15 @@ def supported_languages(self): @property def supported_options(self): """Return list of supported options like voice, emotionen.""" - return ['voice', 'age'] + 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') + filename = os.path.join(os.path.dirname(__file__), "tts.mp3") try: - with open(filename, 'rb') as voice: + with open(filename, "rb") as voice: data = voice.read() except OSError: return (None, None) - return ('mp3', data) + return ("mp3", data) diff --git a/homeassistant/components/demo/vacuum.py b/homeassistant/components/demo/vacuum.py index dfb9c4e943e97..a5d85aa9bd65c 100644 --- a/homeassistant/components/demo/vacuum.py +++ b/homeassistant/components/demo/vacuum.py @@ -2,54 +2,100 @@ 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) + 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, + StateVacuumEntity, + VacuumEntity, +) _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): +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" + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Demo config entry.""" + await async_setup_platform(hass, {}, async_add_entities) + + +async def async_setup_platform(hass, config, async_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): + async_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(VacuumEntity): """Representation of a demo vacuum.""" def __init__(self, name, supported_features): @@ -57,7 +103,7 @@ def __init__(self, name, supported_features): self._name = name self._supported_features = supported_features self._state = False - self._status = 'Charging' + self._status = "Charging" self._fan_speed = FAN_SPEEDS[1] self._cleaned_area = 0 self._battery_level = 100 @@ -125,7 +171,7 @@ def turn_on(self, **kwargs): self._state = True self._cleaned_area += 5.32 self._battery_level -= 2 - self._status = 'Cleaning' + self._status = "Cleaning" self.schedule_update_ha_state() def turn_off(self, **kwargs): @@ -134,7 +180,7 @@ def turn_off(self, **kwargs): return self._state = False - self._status = 'Charging' + self._status = "Charging" self.schedule_update_ha_state() def stop(self, **kwargs): @@ -143,7 +189,7 @@ def stop(self, **kwargs): return self._state = False - self._status = 'Stopping the current task' + self._status = "Stopping the current task" self.schedule_update_ha_state() def clean_spot(self, **kwargs): @@ -172,11 +218,11 @@ def start_pause(self, **kwargs): self._state = not self._state if self._state: - self._status = 'Resuming the current task' + self._status = "Resuming the current task" self._cleaned_area += 1.32 self._battery_level -= 1 else: - self._status = 'Pausing the current task' + self._status = "Pausing the current task" self.schedule_update_ha_state() def set_fan_speed(self, fan_speed, **kwargs): @@ -194,7 +240,7 @@ def return_to_base(self, **kwargs): return self._state = False - self._status = 'Returning home...' + self._status = "Returning home..." self._battery_level += 5 self.schedule_update_ha_state() @@ -203,12 +249,12 @@ def send_command(self, command, params=None, **kwargs): if self.supported_features & SUPPORT_SEND_COMMAND == 0: return - self._status = 'Executing {}({})'.format(command, params) + self._status = f"Executing {command}({params})" self._state = True self.schedule_update_ha_state() -class StateDemoVacuum(StateVacuumDevice): +class StateDemoVacuum(StateVacuumEntity): """Representation of a demo vacuum supporting states.""" def __init__(self, name): diff --git a/homeassistant/components/demo/water_heater.py b/homeassistant/components/demo/water_heater.py index 6ee17bf0088d6..0b96bbf75f857 100644 --- a/homeassistant/components/demo/water_heater.py +++ b/homeassistant/components/demo/water_heater.py @@ -1,35 +1,43 @@ """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_AWAY_MODE, + SUPPORT_OPERATION_MODE, + SUPPORT_TARGET_TEMPERATURE, + WaterHeaterEntity, +) +from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS, TEMP_FAHRENHEIT -SUPPORT_FLAGS_HEATER = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE | - SUPPORT_AWAY_MODE) +SUPPORT_FLAGS_HEATER = ( + SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE | SUPPORT_AWAY_MODE +) -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_platform(hass, config, async_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'), - ]) + async_add_entities( + [ + DemoWaterHeater("Demo Water Heater", 119, TEMP_FAHRENHEIT, False, "eco"), + DemoWaterHeater("Demo Water Heater Celsius", 45, TEMP_CELSIUS, True, "eco"), + ] + ) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Demo config entry.""" + await async_setup_platform(hass, {}, async_add_entities) -class DemoWaterHeater(WaterHeaterDevice): +class DemoWaterHeater(WaterHeaterEntity): """Representation of a demo water_heater device.""" - def __init__(self, name, target_temperature, unit_of_measurement, - away, current_operation): + 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 + 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: @@ -38,9 +46,15 @@ def __init__(self, name, target_temperature, unit_of_measurement, 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'] + self._operation_list = [ + "eco", + "electric", + "performance", + "high_demand", + "heat_pump", + "gas", + "off", + ] @property def supported_features(self): diff --git a/homeassistant/components/demo/weather.py b/homeassistant/components/demo/weather.py index d20e91b1f9378..b17c88fa828b5 100644 --- a/homeassistant/components/demo/weather.py +++ b/homeassistant/components/demo/weather.py @@ -1,50 +1,98 @@ """Demo platform that offers fake meteorological data.""" -from datetime import datetime, timedelta +from datetime import timedelta from homeassistant.components.weather import ( - ATTR_FORECAST_CONDITION, ATTR_FORECAST_PRECIPITATION, ATTR_FORECAST_TEMP, - ATTR_FORECAST_TEMP_LOW, ATTR_FORECAST_TIME, WeatherEntity) + 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 +import homeassistant.util.dt as dt_util CONDITION_CLASSES = { - 'cloudy': [], - 'fog': [], - 'hail': [], - 'lightning': [], - 'lightning-rainy': [], - 'partlycloudy': [], - 'pouring': [], - 'rainy': ['shower rain'], - 'snowy': [], - 'snowy-rainy': [], - 'sunny': ['sunshine'], - 'windy': [], - 'windy-variant': [], - 'exceptional': [], + "cloudy": [], + "fog": [], + "hail": [], + "lightning": [], + "lightning-rainy": [], + "partlycloudy": [], + "pouring": [], + "rainy": ["shower rain"], + "snowy": [], + "snowy-rainy": [], + "sunny": ["sunshine"], + "windy": [], + "windy-variant": [], + "exceptional": [], } +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Demo config entry.""" + setup_platform(hass, {}, async_add_entities) + + 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]]) - ]) + 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): + def __init__( + self, + name, + condition, + temperature, + humidity, + pressure, + wind_speed, + temperature_unit, + forecast, + ): """Initialize the Demo weather.""" self._name = name self._condition = condition @@ -58,7 +106,7 @@ def __init__(self, name, condition, temperature, humidity, pressure, @property def name(self): """Return the name of the sensor.""" - return '{} {}'.format('Demo Weather', self._name) + return f"Demo Weather {self._name}" @property def should_poll(self): @@ -93,18 +141,19 @@ def pressure(self): @property def condition(self): """Return the weather condition.""" - return [k for k, v in CONDITION_CLASSES.items() if - self._condition.lower() in v][0] + 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' + return "Powered by Home Assistant" @property def forecast(self): """Return the forecast.""" - reftime = datetime.now().replace(hour=16, minute=00) + reftime = dt_util.now().replace(hour=16, minute=00) forecast_data = [] for entry in self._forecast: @@ -113,7 +162,7 @@ def forecast(self): ATTR_FORECAST_CONDITION: entry[0], ATTR_FORECAST_PRECIPITATION: entry[1], ATTR_FORECAST_TEMP: entry[2], - ATTR_FORECAST_TEMP_LOW: entry[3] + ATTR_FORECAST_TEMP_LOW: entry[3], } reftime = reftime + timedelta(hours=4) forecast_data.append(data_dict) diff --git a/homeassistant/components/denon/manifest.json b/homeassistant/components/denon/manifest.json index 2068b72fa9d49..e1f8f309e60c4 100644 --- a/homeassistant/components/denon/manifest.json +++ b/homeassistant/components/denon/manifest.json @@ -1,8 +1,6 @@ { "domain": "denon", - "name": "Denon", - "documentation": "https://www.home-assistant.io/components/denon", - "requirements": [], - "dependencies": [], + "name": "Denon Network Receivers", + "documentation": "https://www.home-assistant.io/integrations/denon", "codeowners": [] } diff --git a/homeassistant/components/denon/media_player.py b/homeassistant/components/denon/media_player.py index 07f6fcc7f9c71..9f451ab302571 100644 --- a/homeassistant/components/denon/media_player.py +++ b/homeassistant/components/denon/media_player.py @@ -4,40 +4,74 @@ import voluptuous as vol -from homeassistant.components.media_player import ( - MediaPlayerDevice, PLATFORM_SCHEMA) +from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerEntity from homeassistant.components.media_player.const import ( - SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, - SUPPORT_PREVIOUS_TRACK, SUPPORT_SELECT_SOURCE, SUPPORT_STOP, - SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET) + SUPPORT_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'} +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", + "Satellite / 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', @@ -46,47 +80,47 @@ 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)) + denon = DenonDevice(config[CONF_NAME], config[CONF_HOST]) if denon.update(): add_entities([denon]) -class DenonDevice(MediaPlayerDevice): +class DenonDevice(MediaPlayerEntity): """Representation of a Denon device.""" def __init__(self, name, host): """Initialize the Denon device.""" self._name = name self._host = host - self._pwstate = 'PWSTANDBY' + 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._mediasource = "" + self._mediainfo = "" self._should_setup_sources = True def _setup_sources(self, telnet): # NSFRN - Network name - nsfrn = self.telnet_request(telnet, 'NSFRN ?')[len('NSFRN '):] + 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) + 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 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] @@ -96,24 +130,24 @@ def _setup_sources(self, telnet): 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') + telnet.write(command.encode("ASCII") + b"\r") lines = [] while True: - line = telnet.read_until(b'\r', timeout=0.2) + line = telnet.read_until(b"\r", timeout=0.2) if not line: break - lines.append(line.decode('ASCII').strip()) + lines.append(line.decode("ASCII").strip()) _LOGGER.debug("Received: %s", line) if all_lines: return lines - return lines[0] if lines else '' + 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.write(command.encode("ASCII") + b"\r") telnet.read_very_eager() # skip response telnet.close() @@ -128,23 +162,32 @@ def update(self): 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 '): + 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')]) + 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 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' + answer_codes = [ + "NSE0", + "NSE1X", + "NSE2X", + "NSE3X", + "NSE4", + "NSE5", + "NSE6", + "NSE7", + "NSE8", + ] + for line in self.telnet_request(telnet, "NSE", all_lines=True): + self._mediainfo += f"{line[len(answer_codes.pop(0)) :]}\n" else: self._mediainfo = self.source @@ -159,9 +202,9 @@ def name(self): @property def state(self): """Return the state of the device.""" - if self._pwstate == 'PWSTANDBY': + if self._pwstate == "PWSTANDBY": return STATE_OFF - if self._pwstate == 'PWON': + if self._pwstate == "PWON": return STATE_ON return None @@ -202,49 +245,49 @@ def source(self): def turn_off(self): """Turn off media player.""" - self.telnet_command('PWSTANDBY') + self.telnet_command("PWSTANDBY") def volume_up(self): """Volume up media player.""" - self.telnet_command('MVUP') + self.telnet_command("MVUP") def volume_down(self): """Volume down media player.""" - self.telnet_command('MVDOWN') + self.telnet_command("MVDOWN") def set_volume_level(self, volume): """Set volume level, range 0..1.""" - self.telnet_command('MV' + - str(round(volume * self._volume_max)).zfill(2)) + self.telnet_command(f"MV{round(volume * self._volume_max):02}") def mute_volume(self, mute): """Mute (true) or unmute (false) media player.""" - self.telnet_command('MU' + ('ON' if mute else 'OFF')) + mute_status = "ON" if mute else "OFF" + self.telnet_command(f"MU{mute_status})") def media_play(self): """Play media player.""" - self.telnet_command('NS9A') + self.telnet_command("NS9A") def media_pause(self): """Pause media player.""" - self.telnet_command('NS9B') + self.telnet_command("NS9B") def media_stop(self): """Pause media player.""" - self.telnet_command('NS9C') + self.telnet_command("NS9C") def media_next_track(self): """Send the next track command.""" - self.telnet_command('NS9D') + self.telnet_command("NS9D") def media_previous_track(self): """Send the previous track command.""" - self.telnet_command('NS9E') + self.telnet_command("NS9E") def turn_on(self): """Turn the media player on.""" - self.telnet_command('PWON') + self.telnet_command("PWON") def select_source(self, source): """Select input source.""" - self.telnet_command('SI' + self._source_list.get(source)) + self.telnet_command(f"SI{self._source_list.get(source)}") diff --git a/homeassistant/components/denonavr/__init__.py b/homeassistant/components/denonavr/__init__.py index dee84449d13a8..8877a7dfb3bd9 100644 --- a/homeassistant/components/denonavr/__init__.py +++ b/homeassistant/components/denonavr/__init__.py @@ -1 +1,35 @@ """The denonavr component.""" +import voluptuous as vol + +from homeassistant.const import ATTR_ENTITY_ID +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import dispatcher_send + +DOMAIN = "denonavr" + +SERVICE_GET_COMMAND = "get_command" +ATTR_COMMAND = "command" + +CALL_SCHEMA = vol.Schema({vol.Required(ATTR_ENTITY_ID): cv.comp_entity_ids}) + +GET_COMMAND_SCHEMA = CALL_SCHEMA.extend({vol.Required(ATTR_COMMAND): cv.string}) + +SERVICE_TO_METHOD = { + SERVICE_GET_COMMAND: {"method": "get_command", "schema": GET_COMMAND_SCHEMA} +} + + +def setup(hass, config): + """Set up the denonavr platform.""" + + def service_handler(service): + method = SERVICE_TO_METHOD.get(service.service) + data = service.data.copy() + data["method"] = method["method"] + dispatcher_send(hass, DOMAIN, data) + + for service in SERVICE_TO_METHOD: + schema = SERVICE_TO_METHOD[service]["schema"] + hass.services.register(DOMAIN, service, service_handler, schema=schema) + + return True diff --git a/homeassistant/components/denonavr/manifest.json b/homeassistant/components/denonavr/manifest.json index df7d58169e056..a26bbdd58ab49 100644 --- a/homeassistant/components/denonavr/manifest.json +++ b/homeassistant/components/denonavr/manifest.json @@ -1,10 +1,7 @@ { "domain": "denonavr", - "name": "Denonavr", - "documentation": "https://www.home-assistant.io/components/denonavr", - "requirements": [ - "denonavr==0.7.8" - ], - "dependencies": [], - "codeowners": [] + "name": "Denon AVR Network Receivers", + "documentation": "https://www.home-assistant.io/integrations/denonavr", + "requirements": ["denonavr==0.8.1"], + "codeowners": ["@scarface-4711", "@starkillerOG"] } diff --git a/homeassistant/components/denonavr/media_player.py b/homeassistant/components/denonavr/media_player.py index da416ce8045bc..524e728588bb0 100644 --- a/homeassistant/components/denonavr/media_player.py +++ b/homeassistant/components/denonavr/media_player.py @@ -3,65 +3,98 @@ from collections import namedtuple import logging +import denonavr import voluptuous as vol -from homeassistant.components.media_player import ( - MediaPlayerDevice, PLATFORM_SCHEMA) +from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerEntity 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) + 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) + ATTR_ENTITY_ID, + CONF_HOST, + CONF_NAME, + CONF_TIMEOUT, + CONF_ZONE, + ENTITY_MATCH_ALL, + ENTITY_MATCH_NONE, + STATE_OFF, + STATE_ON, + STATE_PAUSED, + STATE_PLAYING, +) import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from . import DOMAIN _LOGGER = logging.getLogger(__name__) -ATTR_SOUND_MODE_RAW = 'sound_mode_raw' +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' +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']) +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 = [] @@ -70,8 +103,8 @@ def setup_platform(hass, config, add_entities, discovery_info=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) + show_all_sources = config[CONF_SHOW_ALL_SOURCES] + timeout = config[CONF_TIMEOUT] # Get config option for additional zones zones = config.get(CONF_ZONES) @@ -92,8 +125,8 @@ def setup_platform(hass, config, add_entities, discovery_info=None): # 2. option: discovery using netdisco if discovery_info is not None: - host = discovery_info.get('host') - name = discovery_info.get('name') + host = discovery_info.get("host") + name = discovery_info.get("name") new_hosts.append(NewHost(host=host, name=name)) # 3. option: discovery using denonavr library @@ -103,17 +136,19 @@ def setup_platform(hass, config, add_entities, discovery_info=None): for d_receiver in d_receivers: host = d_receiver["host"] name = d_receiver["friendlyName"] - new_hosts.append( - NewHost(host=host, name=name)) + 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) + 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) @@ -124,7 +159,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): add_entities(receivers) -class DenonDevice(MediaPlayerDevice): +class DenonDevice(MediaPlayerEntity): """Representation of a Denon Media Player Device.""" def __init__(self, receiver): @@ -156,8 +191,30 @@ def __init__(self, receiver): self._sound_mode_list = None self._supported_features_base = SUPPORT_DENON - self._supported_features_base |= (self._sound_mode_support and - SUPPORT_SELECT_SOUND_MODE) + self._supported_features_base |= ( + self._sound_mode_support and SUPPORT_SELECT_SOUND_MODE + ) + + async def async_added_to_hass(self): + """Register signal handler.""" + self.async_on_remove( + async_dispatcher_connect(self.hass, DOMAIN, self.signal_handler) + ) + + def signal_handler(self, data): + """Handle domain-specific signal by calling appropriate method.""" + entity_ids = data[ATTR_ENTITY_ID] + + if entity_ids == ENTITY_MATCH_NONE: + return + + if entity_ids == ENTITY_MATCH_ALL or self.entity_id in entity_ids: + params = { + key: value + for key, value in data.items() + if key not in ["entity_id", "method"] + } + getattr(self, data["method"])(**params) def update(self): """Get the latest status information from device.""" @@ -305,15 +362,26 @@ def media_episode(self): 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'): + 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.""" + """Play or pause the media player.""" return self._receiver.toggle_play_pause() + def media_play(self): + """Send play command.""" + return self._receiver.play() + + def media_pause(self): + """Send pause command.""" + return self._receiver.pause() + def media_previous_track(self): """Send previous track command.""" return self._receiver.previous_track() @@ -364,3 +432,7 @@ def set_volume_level(self, volume): def mute_volume(self, mute): """Send mute command.""" return self._receiver.mute(mute) + + def get_command(self, command, **kwargs): + """Send generic command.""" + self._receiver.send_get_command(command) diff --git a/homeassistant/components/denonavr/services.yaml b/homeassistant/components/denonavr/services.yaml new file mode 100644 index 0000000000000..c9831a68aa529 --- /dev/null +++ b/homeassistant/components/denonavr/services.yaml @@ -0,0 +1,11 @@ +# Describes the format for available webostv services + +get_command: + description: "Send a generic http get command." + fields: + entity_id: + description: Name(s) of the denonavr entities where to run the API method. + example: "media_player.living_room_receiver" + command: + description: Endpoint of the command, including associated parameters. + example: "/goform/formiPhoneAppDirect.xml?RCKSK0410370" diff --git a/homeassistant/components/derivative/__init__.py b/homeassistant/components/derivative/__init__.py new file mode 100644 index 0000000000000..afee8d5d17510 --- /dev/null +++ b/homeassistant/components/derivative/__init__.py @@ -0,0 +1 @@ +"""The derivative component.""" diff --git a/homeassistant/components/derivative/manifest.json b/homeassistant/components/derivative/manifest.json new file mode 100644 index 0000000000000..15f5b71d5cbc5 --- /dev/null +++ b/homeassistant/components/derivative/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "derivative", + "name": "Derivative", + "documentation": "https://www.home-assistant.io/integrations/derivative", + "codeowners": ["@afaucogney"] +} diff --git a/homeassistant/components/derivative/sensor.py b/homeassistant/components/derivative/sensor.py new file mode 100644 index 0000000000000..65bbd9affee42 --- /dev/null +++ b/homeassistant/components/derivative/sensor.py @@ -0,0 +1,218 @@ +"""Numeric derivative of data coming from a source sensor over time.""" +from decimal import Decimal, DecimalException +import logging + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + ATTR_UNIT_OF_MEASUREMENT, + CONF_NAME, + CONF_SOURCE, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + TIME_DAYS, + TIME_HOURS, + TIME_MINUTES, + TIME_SECONDS, +) +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.event import async_track_state_change +from homeassistant.helpers.restore_state import RestoreEntity + +# mypy: allow-untyped-defs, no-check-untyped-defs + +_LOGGER = logging.getLogger(__name__) + +ATTR_SOURCE_ID = "source" + +CONF_ROUND_DIGITS = "round" +CONF_UNIT_PREFIX = "unit_prefix" +CONF_UNIT_TIME = "unit_time" +CONF_UNIT = "unit" +CONF_TIME_WINDOW = "time_window" + +# SI Metric prefixes +UNIT_PREFIXES = { + None: 1, + "n": 1e-9, + "µ": 1e-6, + "m": 1e-3, + "k": 1e3, + "M": 1e6, + "G": 1e9, + "T": 1e12, +} + +# SI Time prefixes +UNIT_TIME = { + TIME_SECONDS: 1, + TIME_MINUTES: 60, + TIME_HOURS: 60 * 60, + TIME_DAYS: 24 * 60 * 60, +} + +ICON = "mdi:chart-line" + +DEFAULT_ROUND = 3 +DEFAULT_TIME_WINDOW = 0 + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_NAME): cv.string, + vol.Required(CONF_SOURCE): cv.entity_id, + vol.Optional(CONF_ROUND_DIGITS, default=DEFAULT_ROUND): vol.Coerce(int), + vol.Optional(CONF_UNIT_PREFIX, default=None): vol.In(UNIT_PREFIXES), + vol.Optional(CONF_UNIT_TIME, default=TIME_HOURS): vol.In(UNIT_TIME), + vol.Optional(CONF_UNIT): cv.string, + vol.Optional(CONF_TIME_WINDOW, default=DEFAULT_TIME_WINDOW): cv.time_period, + } +) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the derivative sensor.""" + derivative = DerivativeSensor( + source_entity=config[CONF_SOURCE], + name=config.get(CONF_NAME), + round_digits=config[CONF_ROUND_DIGITS], + unit_prefix=config[CONF_UNIT_PREFIX], + unit_time=config[CONF_UNIT_TIME], + unit_of_measurement=config.get(CONF_UNIT), + time_window=config[CONF_TIME_WINDOW], + ) + + async_add_entities([derivative]) + + +class DerivativeSensor(RestoreEntity): + """Representation of an derivative sensor.""" + + def __init__( + self, + source_entity, + name, + round_digits, + unit_prefix, + unit_time, + unit_of_measurement, + time_window, + ): + """Initialize the derivative sensor.""" + self._sensor_source_id = source_entity + self._round_digits = round_digits + self._state = 0 + self._state_list = [] # List of tuples with (timestamp, sensor_value) + + self._name = name if name is not None else f"{source_entity} derivative" + + if unit_of_measurement is None: + final_unit_prefix = "" if unit_prefix is None else unit_prefix + self._unit_template = f"{final_unit_prefix}{{}}/{unit_time}" + # we postpone the definition of unit_of_measurement to later + self._unit_of_measurement = None + else: + self._unit_of_measurement = unit_of_measurement + + self._unit_prefix = UNIT_PREFIXES[unit_prefix] + self._unit_time = UNIT_TIME[unit_time] + self._time_window = time_window.total_seconds() + + async def async_added_to_hass(self): + """Handle entity which will be added.""" + await super().async_added_to_hass() + state = await self.async_get_last_state() + if state is not None: + try: + self._state = Decimal(state.state) + except SyntaxError as err: + _LOGGER.warning("Could not restore last state: %s", err) + + @callback + def calc_derivative(entity, old_state, new_state): + """Handle the sensor state changes.""" + if ( + old_state is None + or old_state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE] + or new_state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE] + ): + return + + now = new_state.last_updated + # Filter out the tuples that are older than (and outside of the) `time_window` + self._state_list = [ + (timestamp, state) + for timestamp, state in self._state_list + if (now - timestamp).total_seconds() < self._time_window + ] + # It can happen that the list is now empty, in that case + # we use the old_state, because we cannot do anything better. + if len(self._state_list) == 0: + self._state_list.append((old_state.last_updated, old_state.state)) + self._state_list.append((new_state.last_updated, new_state.state)) + + if self._unit_of_measurement is None: + unit = new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + self._unit_of_measurement = self._unit_template.format( + "" if unit is None else unit + ) + + try: + # derivative of previous measures. + last_time, last_value = self._state_list[-1] + first_time, first_value = self._state_list[0] + + elapsed_time = (last_time - first_time).total_seconds() + delta_value = Decimal(last_value) - Decimal(first_value) + derivative = ( + delta_value + / Decimal(elapsed_time) + / Decimal(self._unit_prefix) + * Decimal(self._unit_time) + ) + assert isinstance(derivative, Decimal) + except ValueError as err: + _LOGGER.warning("While calculating derivative: %s", err) + except DecimalException as err: + _LOGGER.warning( + "Invalid state (%s > %s): %s", old_state.state, new_state.state, err + ) + except AssertionError as err: + _LOGGER.error("Could not calculate derivative: %s", err) + else: + self._state = derivative + self.async_write_ha_state() + + async_track_state_change(self.hass, self._sensor_source_id, calc_derivative) + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return round(self._state, self._round_digits) + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return self._unit_of_measurement + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def device_state_attributes(self): + """Return the state attributes of the sensor.""" + state_attr = {ATTR_SOURCE_ID: self._sensor_source_id} + return state_attr + + @property + def icon(self): + """Return the icon to use in the frontend.""" + return ICON diff --git a/homeassistant/components/deutsche_bahn/manifest.json b/homeassistant/components/deutsche_bahn/manifest.json index 463c7d03fbb23..fa382b1b6a5c8 100644 --- a/homeassistant/components/deutsche_bahn/manifest.json +++ b/homeassistant/components/deutsche_bahn/manifest.json @@ -1,10 +1,7 @@ { "domain": "deutsche_bahn", - "name": "Deutsche bahn", - "documentation": "https://www.home-assistant.io/components/deutsche_bahn", - "requirements": [ - "schiene==0.23" - ], - "dependencies": [], + "name": "Deutsche Bahn", + "documentation": "https://www.home-assistant.io/integrations/deutsche_bahn", + "requirements": ["schiene==0.23"], "codeowners": [] } diff --git a/homeassistant/components/deutsche_bahn/sensor.py b/homeassistant/components/deutsche_bahn/sensor.py index 9c7518eb8ef7a..bb5adb943e9a3 100644 --- a/homeassistant/components/deutsche_bahn/sensor.py +++ b/homeassistant/components/deutsche_bahn/sensor.py @@ -2,6 +2,7 @@ from datetime import timedelta import logging +import schiene import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA @@ -11,38 +12,44 @@ _LOGGER = logging.getLogger(__name__) -CONF_DESTINATION = 'to' -CONF_START = 'from' -CONF_ONLY_DIRECT = 'only_direct' +CONF_DESTINATION = "to" +CONF_START = "from" +CONF_OFFSET = "offset" +DEFAULT_OFFSET = timedelta(minutes=0) +CONF_ONLY_DIRECT = "only_direct" DEFAULT_ONLY_DIRECT = False -ICON = 'mdi:train' +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, -}) +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_DESTINATION): cv.string, + vol.Required(CONF_START): cv.string, + vol.Optional(CONF_OFFSET, default=DEFAULT_OFFSET): cv.time_period, + 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) + destination = config[CONF_DESTINATION] + offset = config[CONF_OFFSET] + only_direct = config[CONF_ONLY_DIRECT] - add_entities([DeutscheBahnSensor(start, destination, only_direct)], True) + add_entities([DeutscheBahnSensor(start, destination, offset, only_direct)], True) class DeutscheBahnSensor(Entity): """Implementation of a Deutsche Bahn sensor.""" - def __init__(self, start, goal, only_direct): + def __init__(self, start, goal, offset, only_direct): """Initialize the sensor.""" - self._name = '{} to {}'.format(start, goal) - self.data = SchieneData(start, goal, only_direct) + self._name = f"{start} to {goal}" + self.data = SchieneData(start, goal, offset, only_direct) self._state = None @property @@ -65,28 +72,28 @@ 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'] + connections["next"] = self.data.connections[1]["departure"] if len(self.data.connections) > 2: - connections['next_on'] = self.data.connections[2]['departure'] + 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']) + self._state = self.data.connections[0].get("departure", "Unknown") + if self.data.connections[0].get("delay", 0) != 0: + self._state += f" + {self.data.connections[0]['delay']}" class SchieneData: """Pull data from the bahn.de web page.""" - def __init__(self, start, goal, only_direct): + def __init__(self, start, goal, offset, only_direct): """Initialize the sensor.""" - import schiene self.start = start self.goal = goal + self.offset = offset self.only_direct = only_direct self.schiene = schiene.Schiene() self.connections = [{}] @@ -94,8 +101,11 @@ def __init__(self, start, goal, only_direct): 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) + self.start, + self.goal, + dt_util.as_local(dt_util.utcnow() + self.offset), + self.only_direct, + ) if not self.connections: self.connections = [{}] @@ -103,10 +113,9 @@ def update(self): 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) + 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..33685b2bc1cec --- /dev/null +++ b/homeassistant/components/device_automation/__init__.py @@ -0,0 +1,290 @@ +"""Helpers for device automations.""" +import asyncio +from functools import wraps +import logging +from types import ModuleType +from typing import Any, List, MutableMapping + +import voluptuous as vol +import voluptuous_serialize + +from homeassistant.components import websocket_api +from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_PLATFORM +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity_registry import async_entries_for_device +from homeassistant.loader import IntegrationNotFound +from homeassistant.requirements import async_get_integration_with_requirements + +from .exceptions import DeviceNotFound, InvalidDeviceAutomationConfig + +# mypy: allow-untyped-calls, allow-untyped-defs + +DOMAIN = "device_automation" + +_LOGGER = logging.getLogger(__name__) + + +TRIGGER_BASE_SCHEMA = vol.Schema( + { + vol.Required(CONF_PLATFORM): "device", + vol.Required(CONF_DOMAIN): str, + vol.Required(CONF_DEVICE_ID): str, + } +) + +TYPES = { + # platform name, get automations function, get capabilities function + "trigger": ( + "device_trigger", + "async_get_triggers", + "async_get_trigger_capabilities", + ), + "condition": ( + "device_condition", + "async_get_conditions", + "async_get_condition_capabilities", + ), + "action": ("device_action", "async_get_actions", "async_get_action_capabilities"), +} + + +async def async_setup(hass, config): + """Set up device automation.""" + hass.components.websocket_api.async_register_command( + websocket_device_automation_list_actions + ) + hass.components.websocket_api.async_register_command( + websocket_device_automation_list_conditions + ) + hass.components.websocket_api.async_register_command( + websocket_device_automation_list_triggers + ) + hass.components.websocket_api.async_register_command( + websocket_device_automation_get_action_capabilities + ) + hass.components.websocket_api.async_register_command( + websocket_device_automation_get_condition_capabilities + ) + hass.components.websocket_api.async_register_command( + websocket_device_automation_get_trigger_capabilities + ) + return True + + +async def async_get_device_automation_platform( + hass: HomeAssistant, domain: str, automation_type: str +) -> ModuleType: + """Load device automation platform for integration. + + Throws InvalidDeviceAutomationConfig if the integration is not found or does not support device automation. + """ + platform_name = TYPES[automation_type][0] + try: + integration = await async_get_integration_with_requirements(hass, domain) + platform = integration.get_platform(platform_name) + except IntegrationNotFound: + raise InvalidDeviceAutomationConfig(f"Integration '{domain}' not found") + except ImportError: + raise InvalidDeviceAutomationConfig( + f"Integration '{domain}' does not support device automation {automation_type}s" + ) + + return platform + + +async def _async_get_device_automations_from_domain( + hass, domain, automation_type, device_id +): + """List device automations.""" + try: + platform = await async_get_device_automation_platform( + hass, domain, automation_type + ) + except InvalidDeviceAutomationConfig: + return None + + function_name = TYPES[automation_type][1] + + return await getattr(platform, function_name)(hass, device_id) + + +async def _async_get_device_automations(hass, automation_type, device_id): + """List device automations.""" + device_registry, entity_registry = await asyncio.gather( + hass.helpers.device_registry.async_get_registry(), + hass.helpers.entity_registry.async_get_registry(), + ) + + domains = set() + automations: List[MutableMapping[str, Any]] = [] + device = device_registry.async_get(device_id) + + if device is None: + raise DeviceNotFound + + for entry_id in device.config_entries: + config_entry = hass.config_entries.async_get_entry(entry_id) + domains.add(config_entry.domain) + + entity_entries = async_entries_for_device(entity_registry, device_id) + for entity_entry in entity_entries: + domains.add(entity_entry.domain) + + device_automations = await asyncio.gather( + *( + _async_get_device_automations_from_domain( + hass, domain, automation_type, device_id + ) + for domain in domains + ) + ) + for device_automation in device_automations: + if device_automation is not None: + automations.extend(device_automation) + + return automations + + +async def _async_get_device_automation_capabilities(hass, automation_type, automation): + """List device automations.""" + try: + platform = await async_get_device_automation_platform( + hass, automation[CONF_DOMAIN], automation_type + ) + except InvalidDeviceAutomationConfig: + return {} + + function_name = TYPES[automation_type][2] + + if not hasattr(platform, function_name): + # The device automation has no capabilities + return {} + + try: + capabilities = await getattr(platform, function_name)(hass, automation) + except InvalidDeviceAutomationConfig: + return {} + + capabilities = capabilities.copy() + + extra_fields = capabilities.get("extra_fields") + if extra_fields is None: + capabilities["extra_fields"] = [] + else: + capabilities["extra_fields"] = voluptuous_serialize.convert( + extra_fields, custom_serializer=cv.custom_serializer + ) + + return capabilities + + +def handle_device_errors(func): + """Handle device automation errors.""" + + @wraps(func) + async def with_error_handling(hass, connection, msg): + try: + await func(hass, connection, msg) + except DeviceNotFound: + connection.send_error( + msg["id"], websocket_api.const.ERR_NOT_FOUND, "Device not found" + ) + + return with_error_handling + + +@websocket_api.websocket_command( + { + vol.Required("type"): "device_automation/action/list", + vol.Required("device_id"): str, + } +) +@websocket_api.async_response +@handle_device_errors +async def websocket_device_automation_list_actions(hass, connection, msg): + """Handle request for device actions.""" + device_id = msg["device_id"] + actions = await _async_get_device_automations(hass, "action", device_id) + connection.send_result(msg["id"], actions) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "device_automation/condition/list", + vol.Required("device_id"): str, + } +) +@websocket_api.async_response +@handle_device_errors +async def websocket_device_automation_list_conditions(hass, connection, msg): + """Handle request for device conditions.""" + device_id = msg["device_id"] + conditions = await _async_get_device_automations(hass, "condition", device_id) + connection.send_result(msg["id"], conditions) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "device_automation/trigger/list", + vol.Required("device_id"): str, + } +) +@websocket_api.async_response +@handle_device_errors +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_automations(hass, "trigger", device_id) + connection.send_result(msg["id"], triggers) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "device_automation/action/capabilities", + vol.Required("action"): dict, + } +) +@websocket_api.async_response +@handle_device_errors +async def websocket_device_automation_get_action_capabilities(hass, connection, msg): + """Handle request for device action capabilities.""" + action = msg["action"] + capabilities = await _async_get_device_automation_capabilities( + hass, "action", action + ) + connection.send_result(msg["id"], capabilities) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "device_automation/condition/capabilities", + vol.Required("condition"): dict, + } +) +@websocket_api.async_response +@handle_device_errors +async def websocket_device_automation_get_condition_capabilities(hass, connection, msg): + """Handle request for device condition capabilities.""" + condition = msg["condition"] + capabilities = await _async_get_device_automation_capabilities( + hass, "condition", condition + ) + connection.send_result(msg["id"], capabilities) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "device_automation/trigger/capabilities", + vol.Required("trigger"): dict, + } +) +@websocket_api.async_response +@handle_device_errors +async def websocket_device_automation_get_trigger_capabilities(hass, connection, msg): + """Handle request for device trigger capabilities.""" + trigger = msg["trigger"] + capabilities = await _async_get_device_automation_capabilities( + hass, "trigger", trigger + ) + connection.send_result(msg["id"], capabilities) diff --git a/homeassistant/components/device_automation/const.py b/homeassistant/components/device_automation/const.py new file mode 100644 index 0000000000000..40bfc4ca0a13e --- /dev/null +++ b/homeassistant/components/device_automation/const.py @@ -0,0 +1,8 @@ +"""Constants for device automations.""" +CONF_IS_OFF = "is_off" +CONF_IS_ON = "is_on" +CONF_TOGGLE = "toggle" +CONF_TURN_OFF = "turn_off" +CONF_TURN_ON = "turn_on" +CONF_TURNED_OFF = "turned_off" +CONF_TURNED_ON = "turned_on" diff --git a/homeassistant/components/device_automation/exceptions.py b/homeassistant/components/device_automation/exceptions.py new file mode 100644 index 0000000000000..ad92696cb945d --- /dev/null +++ b/homeassistant/components/device_automation/exceptions.py @@ -0,0 +1,10 @@ +"""Device automation exceptions.""" +from homeassistant.exceptions import HomeAssistantError + + +class InvalidDeviceAutomationConfig(HomeAssistantError): + """When device automation config is invalid.""" + + +class DeviceNotFound(HomeAssistantError): + """When referenced device not found.""" diff --git a/homeassistant/components/device_automation/manifest.json b/homeassistant/components/device_automation/manifest.json new file mode 100644 index 0000000000000..2eadd214bc1b7 --- /dev/null +++ b/homeassistant/components/device_automation/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "device_automation", + "name": "Device Automation", + "documentation": "https://www.home-assistant.io/integrations/device_automation", + "dependencies": ["webhook"], + "codeowners": ["@home-assistant/core"], + "quality_scale": "internal" +} diff --git a/homeassistant/components/device_automation/toggle_entity.py b/homeassistant/components/device_automation/toggle_entity.py new file mode 100644 index 0000000000000..e9a65f7bedd0f --- /dev/null +++ b/homeassistant/components/device_automation/toggle_entity.py @@ -0,0 +1,237 @@ +"""Device automation helpers for toggle entity.""" +from typing import Any, Dict, List + +import voluptuous as vol + +from homeassistant.components.automation import ( + AutomationActionType, + state as state_automation, +) +from homeassistant.components.device_automation.const import ( + CONF_IS_OFF, + CONF_IS_ON, + CONF_TOGGLE, + CONF_TURN_OFF, + CONF_TURN_ON, + CONF_TURNED_OFF, + CONF_TURNED_ON, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_CONDITION, + CONF_ENTITY_ID, + CONF_FOR, + CONF_PLATFORM, + CONF_TYPE, +) +from homeassistant.core import CALLBACK_TYPE, Context, HomeAssistant, callback +from homeassistant.helpers import condition, config_validation as cv +from homeassistant.helpers.entity_registry import async_entries_for_device +from homeassistant.helpers.typing import ConfigType, TemplateVarsType + +from . import TRIGGER_BASE_SCHEMA + +# mypy: allow-untyped-calls, allow-untyped-defs + +ENTITY_ACTIONS = [ + { + # Turn entity off + CONF_TYPE: CONF_TURN_OFF + }, + { + # Turn entity on + CONF_TYPE: CONF_TURN_ON + }, + { + # Toggle entity + CONF_TYPE: CONF_TOGGLE + }, +] + +ENTITY_CONDITIONS = [ + { + # True when entity is turned off + CONF_CONDITION: "device", + CONF_TYPE: CONF_IS_OFF, + }, + { + # True when entity is turned on + CONF_CONDITION: "device", + CONF_TYPE: CONF_IS_ON, + }, +] + +ENTITY_TRIGGERS = [ + { + # Trigger when entity is turned off + CONF_PLATFORM: "device", + CONF_TYPE: CONF_TURNED_OFF, + }, + { + # Trigger when entity is turned on + CONF_PLATFORM: "device", + CONF_TYPE: CONF_TURNED_ON, + }, +] + +DEVICE_ACTION_TYPES = [CONF_TOGGLE, CONF_TURN_OFF, CONF_TURN_ON] + +ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( + { + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_TYPE): vol.In(DEVICE_ACTION_TYPES), + } +) + +CONDITION_SCHEMA = cv.DEVICE_CONDITION_BASE_SCHEMA.extend( + { + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_TYPE): vol.In([CONF_IS_OFF, CONF_IS_ON]), + vol.Optional(CONF_FOR): cv.positive_time_period_dict, + } +) + +TRIGGER_SCHEMA = TRIGGER_BASE_SCHEMA.extend( + { + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_TYPE): vol.In([CONF_TURNED_OFF, CONF_TURNED_ON]), + vol.Optional(CONF_FOR): cv.positive_time_period_dict, + } +) + + +async def async_call_action_from_config( + hass: HomeAssistant, + config: ConfigType, + variables: TemplateVarsType, + context: Context, + domain: str, +) -> None: + """Change state based on configuration.""" + action_type = config[CONF_TYPE] + if action_type == CONF_TURN_ON: + action = "turn_on" + elif action_type == CONF_TURN_OFF: + action = "turn_off" + else: + action = "toggle" + + service_data = {ATTR_ENTITY_ID: config[CONF_ENTITY_ID]} + + await hass.services.async_call( + domain, action, service_data, blocking=True, context=context + ) + + +@callback +def async_condition_from_config(config: ConfigType) -> condition.ConditionCheckerType: + """Evaluate state based on configuration.""" + condition_type = config[CONF_TYPE] + if condition_type == CONF_IS_ON: + stat = "on" + else: + stat = "off" + state_config = { + condition.CONF_CONDITION: "state", + condition.CONF_ENTITY_ID: config[CONF_ENTITY_ID], + condition.CONF_STATE: stat, + } + if CONF_FOR in config: + state_config[CONF_FOR] = config[CONF_FOR] + + return condition.state_from_config(state_config) + + +async def async_attach_trigger( + hass: HomeAssistant, + config: ConfigType, + action: AutomationActionType, + automation_info: dict, +) -> CALLBACK_TYPE: + """Listen for state changes based on configuration.""" + trigger_type = config[CONF_TYPE] + if trigger_type == CONF_TURNED_ON: + from_state = "off" + to_state = "on" + else: + from_state = "on" + to_state = "off" + state_config = { + state_automation.CONF_PLATFORM: "state", + state_automation.CONF_ENTITY_ID: config[CONF_ENTITY_ID], + state_automation.CONF_FROM: from_state, + state_automation.CONF_TO: to_state, + } + if CONF_FOR in config: + state_config[CONF_FOR] = config[CONF_FOR] + + state_config = state_automation.TRIGGER_SCHEMA(state_config) + return await state_automation.async_attach_trigger( + hass, state_config, action, automation_info, platform_type="device" + ) + + +async def _async_get_automations( + hass: HomeAssistant, device_id: str, automation_templates: List[dict], domain: str +) -> List[dict]: + """List device automations.""" + automations: List[Dict[str, Any]] = [] + entity_registry = await hass.helpers.entity_registry.async_get_registry() + + entries = [ + entry + for entry in async_entries_for_device(entity_registry, device_id) + if entry.domain == domain + ] + + for entry in entries: + automations.extend( + { + **template, + "device_id": device_id, + "entity_id": entry.entity_id, + "domain": domain, + } + for template in automation_templates + ) + + return automations + + +async def async_get_actions( + hass: HomeAssistant, device_id: str, domain: str +) -> List[dict]: + """List device actions.""" + return await _async_get_automations(hass, device_id, ENTITY_ACTIONS, domain) + + +async def async_get_conditions( + hass: HomeAssistant, device_id: str, domain: str +) -> List[Dict[str, str]]: + """List device conditions.""" + return await _async_get_automations(hass, device_id, ENTITY_CONDITIONS, domain) + + +async def async_get_triggers( + hass: HomeAssistant, device_id: str, domain: str +) -> List[dict]: + """List device triggers.""" + return await _async_get_automations(hass, device_id, ENTITY_TRIGGERS, domain) + + +async def async_get_condition_capabilities(hass: HomeAssistant, config: dict) -> dict: + """List condition capabilities.""" + return { + "extra_fields": vol.Schema( + {vol.Optional(CONF_FOR): cv.positive_time_period_dict} + ) + } + + +async def async_get_trigger_capabilities(hass: HomeAssistant, config: dict) -> dict: + """List trigger capabilities.""" + return { + "extra_fields": vol.Schema( + {vol.Optional(CONF_FOR): cv.positive_time_period_dict} + ) + } diff --git a/homeassistant/components/device_sun_light_trigger/__init__.py b/homeassistant/components/device_sun_light_trigger/__init__.py index 945f83686712b..fcf61dfe09717 100644 --- a/homeassistant/components/device_sun_light_trigger/__init__.py +++ b/homeassistant/components/device_sun_light_trigger/__init__.py @@ -1,42 +1,60 @@ """Support to turn on lights based on the states.""" -import logging from datetime import timedelta +import logging 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) + 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 + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_HOME, + STATE_NOT_HOME, + SUN_EVENT_SUNRISE, + SUN_EVENT_SUNSET, +) +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.event import ( + async_track_point_in_utc_time, + async_track_state_change, +) +from homeassistant.helpers.sun import get_astral_event_next, is_up +import homeassistant.util.dt as dt_util -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' +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' +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) +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): @@ -45,26 +63,44 @@ async def async_setup(hass, config): device_tracker = hass.components.device_tracker group = hass.components.group light = hass.components.light + person = hass.components.person 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) + disable_turn_off = conf[CONF_DISABLE_TURN_OFF] + light_group = conf.get(CONF_LIGHT_GROUP) + light_profile = conf[CONF_LIGHT_PROFILE] + + device_group = conf.get(CONF_DEVICE_GROUP) + + if device_group is None: + device_entity_ids = hass.states.async_entity_ids(device_tracker.DOMAIN) + else: + device_entity_ids = group.get_entity_ids(device_group, device_tracker.DOMAIN) + device_entity_ids.extend(group.get_entity_ids(device_group, person.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 light_group is None: + light_ids = hass.states.async_entity_ids(light.DOMAIN) + else: + light_ids = group.get_entity_ids(light_group, light.DOMAIN) if not light_ids: logger.error("No lights found to turn on") return False + @callback + def anyone_home(): + """Test if anyone is home.""" + return any(device_tracker.is_on(dt_id) for dt_id in device_entity_ids) + + @callback + def any_light_on(): + """Test if any light on.""" + return any(light.is_on(light_id) for light_id in light_ids) + def calc_time_for_light_when_sunset(): """Calculate the time when to start fading lights in when sun sets. @@ -77,23 +113,27 @@ def calc_time_for_light_when_sunset(): return None return next_setting - LIGHT_TRANSITION_TIME * len(light_ids) - def async_turn_on_before_sunset(light_id): + async def async_turn_on_before_sunset(light_id): """Turn on lights.""" - if not device_tracker.is_on() or light.is_on(light_id): + if not anyone_home() 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})) + await hass.services.async_call( + DOMAIN_LIGHT, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: light_id, + ATTR_TRANSITION: LIGHT_TRANSITION_TIME.seconds, + ATTR_PROFILE: light_profile, + }, + ) + @callback def async_turn_on_factory(light_id): """Generate turn on callbacks as factory.""" - @callback - def async_turn_on_light(now): + + async def async_turn_on_light(now): """Turn on specific light.""" - async_turn_on_before_sunset(light_id) + await async_turn_on_before_sunset(light_id) return async_turn_on_light @@ -112,12 +152,14 @@ def schedule_light_turn_on(now): 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) + 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)) + 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. @@ -127,7 +169,7 @@ def schedule_light_turn_on(now): @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) + lights_are_on = any_light_on() light_needed = not (lights_are_on or is_up(hass)) # These variables are needed for the elif check @@ -139,16 +181,19 @@ def check_light_on_dev_state_change(entity, old_state, new_state): 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})) + 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)): + 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 @@ -156,8 +201,9 @@ def check_light_on_dev_state_change(entity, old_state, new_state): 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})) + DOMAIN_LIGHT, SERVICE_TURN_ON, {ATTR_ENTITY_ID: light_id} + ) + ) else: # If this light didn't happen to be turned on yet so @@ -165,8 +211,12 @@ def check_light_on_dev_state_change(entity, old_state, new_state): break async_track_state_change( - hass, device_entity_ids, check_light_on_dev_state_change, - STATE_NOT_HOME, STATE_HOME) + hass, + device_entity_ids, + check_light_on_dev_state_change, + STATE_NOT_HOME, + STATE_HOME, + ) if disable_turn_off: return True @@ -174,17 +224,27 @@ def check_light_on_dev_state_change(entity, old_state, new_state): @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): + # Make sure there is not someone home + if anyone_home(): + return + + # Check if any light is on + if not any_light_on(): return - logger.info( - "Everyone has left but there are lights on. Turning them off") + 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})) + 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) + hass, + device_entity_ids, + 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 index abe5a1d500cb8..777e8c5181ed3 100644 --- a/homeassistant/components/device_sun_light_trigger/manifest.json +++ b/homeassistant/components/device_sun_light_trigger/manifest.json @@ -1,12 +1,8 @@ { "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": [] + "name": "Presence-based Lights", + "documentation": "https://www.home-assistant.io/integrations/device_sun_light_trigger", + "after_dependencies": ["device_tracker", "group", "light", "person"], + "codeowners": [], + "quality_scale": "internal" } diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index 60dac103a46f1..6d8e2307145dd 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -1,134 +1,122 @@ """Provide functionality to keep track of devices.""" import asyncio -from datetime import timedelta -import logging -from typing import Any, List, Sequence, Callable import voluptuous as vol -from homeassistant.setup import async_prepare_setup_platform -from homeassistant.core import callback -from homeassistant.loader import bind_hass -from homeassistant.components import group, 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.zone import async_active_zone -from homeassistant.config import load_yaml_config_file, async_log_exception -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_per_platform, discovery +from homeassistant.const import ATTR_GPS_ACCURACY, STATE_HOME +from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.restore_state import RestoreEntity -from homeassistant.helpers.typing import GPSType, ConfigType, HomeAssistantType -from homeassistant import 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 async_track_utc_time_change -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) - -_LOGGER = logging.getLogger(__name__) - -DOMAIN = 'device_tracker' -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_NEW_DEVICE_DEFAULTS = 'new_device_defaults' - -CONF_CONSIDER_HOME = 'consider_home' -DEFAULT_CONSIDER_HOME = timedelta(seconds=180) - -CONF_SCAN_INTERVAL = 'interval_seconds' -DEFAULT_SCAN_INTERVAL = timedelta(seconds=12) - -CONF_AWAY_HIDE = 'hide_if_away' -DEFAULT_AWAY_HIDE = False - -EVENT_NEW_DEVICE = 'device_tracker_new_device' - -SERVICE_SEE = 'see' - -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' - -SOURCE_TYPE_GPS = 'gps' -SOURCE_TYPE_ROUTER = 'router' -SOURCE_TYPE_BLUETOOTH = 'bluetooth' -SOURCE_TYPE_BLUETOOTH_LE = 'bluetooth_le' -SOURCE_TYPES = (SOURCE_TYPE_GPS, SOURCE_TYPE_ROUTER, - SOURCE_TYPE_BLUETOOTH, SOURCE_TYPE_BLUETOOTH_LE) +from homeassistant.helpers.typing import ConfigType, GPSType, HomeAssistantType +from homeassistant.loader import bind_hass -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=DEFAULT_CONSIDER_HOME): vol.All( - cv.time_period, cv.positive_timedelta), - vol.Optional(CONF_NEW_DEVICE_DEFAULTS, - default={}): NEW_DEVICE_DEFAULTS_SCHEMA -}) +from . import legacy, setup +from .config_entry import ( # noqa: F401 pylint: disable=unused-import + async_setup_entry, + async_unload_entry, +) +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_CONSIDER_HOME, + CONF_NEW_DEVICE_DEFAULTS, + CONF_SCAN_INTERVAL, + CONF_TRACK_NEW, + DEFAULT_CONSIDER_HOME, + DEFAULT_TRACK_NEW, + DOMAIN, + PLATFORM_TYPE_LEGACY, + SOURCE_TYPE_BLUETOOTH, + SOURCE_TYPE_BLUETOOTH_LE, + SOURCE_TYPE_GPS, + SOURCE_TYPE_ROUTER, +) +from .legacy import DeviceScanner # noqa: F401 pylint: disable=unused-import + +SERVICE_SEE = "see" + +SOURCE_TYPES = ( + SOURCE_TYPE_GPS, + SOURCE_TYPE_ROUTER, + SOURCE_TYPE_BLUETOOTH, + SOURCE_TYPE_BLUETOOTH_LE, +) + +NEW_DEVICE_DEFAULTS_SCHEMA = vol.Any( + None, + vol.Schema({vol.Optional(CONF_TRACK_NEW, default=DEFAULT_TRACK_NEW): 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=DEFAULT_CONSIDER_HOME): vol.All( + cv.time_period, cv.positive_timedelta + ), + vol.Optional(CONF_NEW_DEVICE_DEFAULTS, default={}): NEW_DEVICE_DEFAULTS_SCHEMA, + } +) 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, - })) +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): +def is_on(hass: HomeAssistantType, entity_id: str): """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: int = None, attributes: dict = None): + return hass.states.is_state(entity_id, 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: int = None, + attributes: dict = None, +): """Call service to notify you see device.""" - data = {key: value for key, value in - ((ATTR_MAC, mac), - (ATTR_DEV_ID, dev_id), - (ATTR_HOST_NAME, host_name), - (ATTR_LOCATION_NAME, location_name), - (ATTR_GPS, gps), - (ATTR_GPS_ACCURACY, gps_accuracy), - (ATTR_BATTERY, battery)) if value is not None} + data = { + key: value + for key, value in ( + (ATTR_MAC, mac), + (ATTR_DEV_ID, dev_id), + (ATTR_HOST_NAME, host_name), + (ATTR_LOCATION_NAME, location_name), + (ATTR_GPS, gps), + (ATTR_GPS_ACCURACY, gps_accuracy), + (ATTR_BATTERY, battery), + ) + if value is not None + } if attributes: data[ATTR_ATTRIBUTES] = attributes hass.services.call(DOMAIN, SERVICE_SEE, data) @@ -136,627 +124,46 @@ def see(hass: HomeAssistantType, mac: str = None, dev_id: str = None, async def async_setup(hass: HomeAssistantType, config: ConfigType): """Set up the device 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) - - async def async_setup_platform(p_type, p_config, disc_info=None): - """Set up a device tracker platform.""" - platform = await async_prepare_setup_platform( - hass, config, DOMAIN, p_type) - if platform is None: - return - - _LOGGER.info("Setting up %s.%s", DOMAIN, p_type) - try: - scanner = None - setup = None - if hasattr(platform, 'async_get_scanner'): - scanner = await platform.async_get_scanner( - hass, {DOMAIN: p_config}) - elif hasattr(platform, 'get_scanner'): - scanner = await hass.async_add_job( - platform.get_scanner, hass, {DOMAIN: p_config}) - elif hasattr(platform, 'async_setup_scanner'): - setup = await platform.async_setup_scanner( - hass, p_config, tracker.async_see, disc_info) - elif hasattr(platform, 'setup_scanner'): - setup = await hass.async_add_job( - platform.setup_scanner, hass, p_config, tracker.see, - disc_info) - elif hasattr(platform, 'async_setup_entry'): - setup = await platform.async_setup_entry( - hass, p_config, tracker.async_see) - else: - raise HomeAssistantError("Invalid device_tracker platform.") - - if scanner: - async_setup_scanner_platform( - hass, p_config, scanner, tracker.async_see, p_type) - return + tracker = await legacy.get_tracker(hass, config) - if not setup: - _LOGGER.error("Error setting up platform %s", p_type) - return + legacy_platforms = await setup.async_extract_config(hass, config) - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Error setting up platform %s", p_type) + setup_tasks = [ + legacy_platform.async_setup_legacy(hass, tracker) + for legacy_platform in legacy_platforms + ] - hass.data[DOMAIN] = async_setup_platform - - setup_tasks = [async_setup_platform(p_type, p_config) for p_type, p_config - in config_per_platform(config, DOMAIN)] if setup_tasks: - await asyncio.wait(setup_tasks, loop=hass.loop) - - tracker.async_setup_group() + await asyncio.wait(setup_tasks) - async def async_platform_discovered(platform, info): + async def async_platform_discovered(p_type, info): """Load a platform.""" - await async_setup_platform(platform, {}, disc_info=info) + platform = await setup.async_create_platform_type(hass, config, p_type, {}) + + if platform is None or platform.type != PLATFORM_TYPE_LEGACY: + return + + await platform.async_setup_legacy(hass, tracker, info) discovery.async_listen_platform(hass, DOMAIN, async_platform_discovered) # Clean up stale devices async_track_utc_time_change( - hass, tracker.async_update_stale, second=range(0, 60, 5)) + hass, tracker.async_update_stale, second=range(0, 60, 5) + ) async def async_see_service(call): """Service to see a device.""" # Temp workaround for iOS, introduced in 0.65 data = dict(call.data) - data.pop('hostname', None) - data.pop('battery_status', None) + data.pop("hostname", None) + data.pop("battery_status", None) await tracker.async_see(**data) hass.services.async_register( - DOMAIN, SERVICE_SEE, async_see_service, SERVICE_SEE_PAYLOAD_SCHEMA) + DOMAIN, SERVICE_SEE, async_see_service, SERVICE_SEE_PAYLOAD_SCHEMA + ) # restore await tracker.async_setup_tracked_device() return True - - -async def async_setup_entry(hass, entry): - """Set up an entry.""" - await hass.data[DOMAIN](entry.domain, entry) - return True - - -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(loop=hass.loop) - - 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, loop=self.hass.loop) - - -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) - - -def load_config(path: str, hass: HomeAssistantType, consider_home: timedelta): - """Load devices from YAML configuration file.""" - return run_coroutine_threadsafe( - async_load_config(path, hass, consider_home), hass.loop).result() - - -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), - }) - try: - 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 [] - - 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 - except (HomeAssistantError, FileNotFoundError): - # When YAML file could not be loaded/did not contain a dict - return [] - - -@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, DEFAULT_SCAN_INTERVAL) - update_lock = asyncio.Lock(loop=hass.loop) - 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(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)) - - -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/config_entry.py b/homeassistant/components/device_tracker/config_entry.py new file mode 100644 index 0000000000000..1be47b9b9813f --- /dev/null +++ b/homeassistant/components/device_tracker/config_entry.py @@ -0,0 +1,143 @@ +"""Code to set up a device tracker platform using a config entry.""" +from typing import Optional + +from homeassistant.components import zone +from homeassistant.const import ( + ATTR_BATTERY_LEVEL, + ATTR_GPS_ACCURACY, + ATTR_LATITUDE, + ATTR_LONGITUDE, + STATE_HOME, + STATE_NOT_HOME, +) +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_component import EntityComponent + +from .const import ATTR_SOURCE_TYPE, DOMAIN, LOGGER + + +async def async_setup_entry(hass, entry): + """Set up an entry.""" + component: Optional[EntityComponent] = hass.data.get(DOMAIN) + + 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 BaseTrackerEntity(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 source_type(self): + """Return the source type, eg gps or router, of the device.""" + raise NotImplementedError + + @property + def state_attributes(self): + """Return the device state attributes.""" + attr = {ATTR_SOURCE_TYPE: self.source_type} + + if self.battery_level: + attr[ATTR_BATTERY_LEVEL] = self.battery_level + + return attr + + +class TrackerEntity(BaseTrackerEntity): + """Represent a tracked device.""" + + @property + def should_poll(self): + """No polling for entities that have location pushed.""" + return False + + @property + def force_update(self): + """All updates need to be written to the state machine if we're not polling.""" + return not self.should_poll + + @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 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.update(super().state_attributes) + if self.latitude is not None: + attr[ATTR_LATITUDE] = self.latitude + attr[ATTR_LONGITUDE] = self.longitude + attr[ATTR_GPS_ACCURACY] = self.location_accuracy + + return attr + + +class ScannerEntity(BaseTrackerEntity): + """Represent a tracked device that is on a scanned network.""" + + @property + def state(self): + """Return the state of the device.""" + if self.is_connected: + return STATE_HOME + return STATE_NOT_HOME + + @property + def is_connected(self): + """Return true if the device is connected to the network.""" + raise NotImplementedError diff --git a/homeassistant/components/device_tracker/const.py b/homeassistant/components/device_tracker/const.py new file mode 100644 index 0000000000000..c9ce9f2024a95 --- /dev/null +++ b/homeassistant/components/device_tracker/const.py @@ -0,0 +1,36 @@ +"""Device tracker constants.""" +from datetime import timedelta +import logging + +LOGGER = logging.getLogger(__package__) + +DOMAIN = "device_tracker" + +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_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/device_condition.py b/homeassistant/components/device_tracker/device_condition.py new file mode 100644 index 0000000000000..9c102bfa745e9 --- /dev/null +++ b/homeassistant/components/device_tracker/device_condition.py @@ -0,0 +1,85 @@ +"""Provides device automations for Device tracker.""" +from typing import Dict, List + +import voluptuous as vol + +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_CONDITION, + CONF_DEVICE_ID, + CONF_DOMAIN, + CONF_ENTITY_ID, + CONF_TYPE, + STATE_HOME, + STATE_NOT_HOME, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import condition, config_validation as cv, entity_registry +from homeassistant.helpers.config_validation import DEVICE_CONDITION_BASE_SCHEMA +from homeassistant.helpers.typing import ConfigType, TemplateVarsType + +from . import DOMAIN + +CONDITION_TYPES = {"is_home", "is_not_home"} + +CONDITION_SCHEMA = DEVICE_CONDITION_BASE_SCHEMA.extend( + { + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_TYPE): vol.In(CONDITION_TYPES), + } +) + + +async def async_get_conditions( + hass: HomeAssistant, device_id: str +) -> List[Dict[str, str]]: + """List device conditions for Device tracker devices.""" + registry = await entity_registry.async_get_registry(hass) + conditions = [] + + # Get all the integrations entities for this device + for entry in entity_registry.async_entries_for_device(registry, device_id): + if entry.domain != DOMAIN: + continue + + # Add conditions for each entity that belongs to this integration + conditions.append( + { + CONF_CONDITION: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "is_home", + } + ) + conditions.append( + { + CONF_CONDITION: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "is_not_home", + } + ) + + return conditions + + +@callback +def async_condition_from_config( + config: ConfigType, config_validation: bool +) -> condition.ConditionCheckerType: + """Create a function to test a device condition.""" + if config_validation: + config = CONDITION_SCHEMA(config) + if config[CONF_TYPE] == "is_home": + state = STATE_HOME + else: + state = STATE_NOT_HOME + + @callback + def test_is_state(hass: HomeAssistant, variables: TemplateVarsType) -> bool: + """Test if an entity is a certain state.""" + return condition.state(hass, config[ATTR_ENTITY_ID], state) + + return test_is_state diff --git a/homeassistant/components/device_tracker/legacy.py b/homeassistant/components/device_tracker/legacy.py new file mode 100644 index 0000000000000..3157a00aa9f08 --- /dev/null +++ b/homeassistant/components/device_tracker/legacy.py @@ -0,0 +1,571 @@ +"""Legacy device tracker classes.""" +import asyncio +from datetime import timedelta +import hashlib +from typing import Any, List, Sequence + +import voluptuous as vol + +from homeassistant import util +from homeassistant.components import zone +from homeassistant.config import async_log_exception, load_yaml_config_file +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_HOME, + STATE_NOT_HOME, +) +from homeassistant.core import callback +from homeassistant.exceptions import HomeAssistantError +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_registry import async_get_registry +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.typing import GPSType, HomeAssistantType +import homeassistant.util.dt as dt_util +from homeassistant.util.yaml import dump + +from .const import ( + ATTR_BATTERY, + ATTR_HOST_NAME, + ATTR_MAC, + ATTR_SOURCE_TYPE, + CONF_CONSIDER_HOME, + CONF_NEW_DEVICE_DEFAULTS, + CONF_TRACK_NEW, + DEFAULT_CONSIDER_HOME, + DEFAULT_TRACK_NEW, + DOMAIN, + LOGGER, + SOURCE_TYPE_GPS, +) + +YAML_DEVICES = "known_devices.yaml" +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._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. + """ + registry = await async_get_registry(self.hass) + 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: + device.async_write_ha_state() + return + + # Guard from calling see on entity registry entities. + entity_id = f"{DOMAIN}.{dev_id}" + if registry.async_is_registered(entity_id): + LOGGER.error( + "The see service is not supported for this entity %s", entity_id + ) + 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, + picture=picture, + icon=icon, + ) + 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: + device.async_write_ha_state() + + 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_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() + dev.async_write_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: str = None + location_name: str = None + gps: GPSType = None + gps_accuracy: int = 0 + last_seen: dt_util.dt.datetime = None + consider_home: dt_util.dt.timedelta = None + battery: int = None + attributes: dict = None + icon: str = None + + # 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, + ) -> None: + """Initialize a device.""" + self.hass = hass + self.entity_id = f"{DOMAIN}.{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.source_type = None + + self._attributes = {} + + @property + def name(self): + """Return the name of the entity.""" + return self.config_name or self.host_name or self.dev_id 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 + + 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 or self.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) + + 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 = zone.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: HomeAssistantType = None + + def scan_devices(self) -> List[str]: + """Scan for devices.""" + raise NotImplementedError() + + async def async_scan_devices(self) -> Any: + """Scan for devices.""" + return await self.hass.async_add_job(self.scan_devices) + + def get_device_name(self, device: str) -> str: + """Get the name of a device.""" + raise NotImplementedError() + + async def async_get_device_name(self, device: str) -> Any: + """Get the name of a device.""" + return await 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() + + async def async_get_extra_attributes(self, device: str) -> Any: + """Get the extra attributes of a device.""" + return await 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("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) + device.pop("hide_if_away", 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, + } + } + 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. + """ + + return ( + f"https://www.gravatar.com/avatar/" + f"{hashlib.md5(email.encode('utf-8').lower()).hexdigest()}.jpg?s=80&d=wavatar" + ) diff --git a/homeassistant/components/device_tracker/manifest.json b/homeassistant/components/device_tracker/manifest.json index 7b32f7845a6d5..6e29d977f6601 100644 --- a/homeassistant/components/device_tracker/manifest.json +++ b/homeassistant/components/device_tracker/manifest.json @@ -1,11 +1,9 @@ { "domain": "device_tracker", - "name": "Device tracker", - "documentation": "https://www.home-assistant.io/components/device_tracker", - "requirements": [], - "dependencies": [ - "group", - "zone" - ], - "codeowners": [] + "name": "Device Tracker", + "documentation": "https://www.home-assistant.io/integrations/device_tracker", + "dependencies": ["zone"], + "after_dependencies": [], + "codeowners": [], + "quality_scale": "internal" } diff --git a/homeassistant/components/device_tracker/services.yaml b/homeassistant/components/device_tracker/services.yaml index 938e9c8e3249f..63435d0ac9d65 100644 --- a/homeassistant/components/device_tracker/services.yaml +++ b/homeassistant/components/device_tracker/services.yaml @@ -5,59 +5,22 @@ see: fields: mac: description: MAC address of device - example: 'FF:FF:FF:FF:FF:FF' + example: "FF:FF:FF:FF:FF:FF" dev_id: description: Id of device (find id in known_devices.yaml). - example: 'phonedave' + example: "phonedave" host_name: description: Hostname of device - example: 'Dave' + example: "Dave" location_name: description: Name of location where device is located (not_home is away). - example: 'home' + example: "home" gps: description: GPS coordinates where device is located (latitude, longitude). - example: '[51.509802, -0.086692]' + example: "[51.509802, -0.086692]" gps_accuracy: description: Accuracy of GPS coordinates. - example: '80' + example: "80" battery: description: Battery level of device. - example: '100' - -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' + example: "100" diff --git a/homeassistant/components/device_tracker/setup.py b/homeassistant/components/device_tracker/setup.py new file mode 100644 index 0000000000000..3b7afbe25eed4 --- /dev/null +++ b/homeassistant/components/device_tracker/setup.py @@ -0,0 +1,196 @@ +"""Device tracker helpers.""" +import asyncio +from types import ModuleType +from typing import Any, Callable, Dict, Optional + +import attr + +from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE +from homeassistant.core import callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_per_platform +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.typing import ConfigType, HomeAssistantType +from homeassistant.setup import async_prepare_setup_platform +from homeassistant.util import dt as dt_util + +from .const import ( + CONF_SCAN_INTERVAL, + DOMAIN, + LOGGER, + PLATFORM_TYPE_LEGACY, + SCAN_INTERVAL, + SOURCE_TYPE_ROUTER, +) + + +@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( + f"Unable to determine type for {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: Any = set() + + 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 = {} + + 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/strings.json b/homeassistant/components/device_tracker/strings.json new file mode 100644 index 0000000000000..58aef88453638 --- /dev/null +++ b/homeassistant/components/device_tracker/strings.json @@ -0,0 +1,15 @@ +{ + "title": "Device tracker", + "device_automation": { + "condition_type": { + "is_home": "{entity_name} is home", + "is_not_home": "{entity_name} is not home" + } + }, + "state": { + "_": { + "home": "[%key:common::state::home%]", + "not_home": "[%key:common::state::not_home%]" + } + } +} diff --git a/homeassistant/components/device_tracker/translations/af.json b/homeassistant/components/device_tracker/translations/af.json new file mode 100644 index 0000000000000..b0e60d5aef359 --- /dev/null +++ b/homeassistant/components/device_tracker/translations/af.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "home": "Tuis", + "not_home": "Elders" + } + }, + "title": "Toestel opspoorder" +} \ No newline at end of file diff --git a/homeassistant/components/device_tracker/translations/ar.json b/homeassistant/components/device_tracker/translations/ar.json new file mode 100644 index 0000000000000..a8f82c90ae15b --- /dev/null +++ b/homeassistant/components/device_tracker/translations/ar.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "home": "\u0641\u064a \u0627\u0644\u0645\u0646\u0632\u0644", + "not_home": "\u062e\u0627\u0631\u062c \u0627\u0644\u0645\u0646\u0632\u0644" + } + }, + "title": "\u062a\u0639\u0642\u0628 \u0627\u0644\u062c\u0647\u0627\u0632" +} \ No newline at end of file diff --git a/homeassistant/components/device_tracker/translations/bg.json b/homeassistant/components/device_tracker/translations/bg.json new file mode 100644 index 0000000000000..a687290cdacea --- /dev/null +++ b/homeassistant/components/device_tracker/translations/bg.json @@ -0,0 +1,15 @@ +{ + "device_automation": { + "condition_type": { + "is_home": "{entity_name} \u0435 \u0443 \u0434\u043e\u043c\u0430", + "is_not_home": "{entity_name} \u043d\u0435 \u0435 \u0443 \u0434\u043e\u043c\u0430" + } + }, + "state": { + "_": { + "home": "\u0412\u043a\u044a\u0449\u0438", + "not_home": "\u041e\u0442\u0441\u044a\u0441\u0442\u0432\u0430" + } + }, + "title": "\u041f\u0440\u043e\u0441\u043b\u0435\u0434\u044f\u0432\u0430\u043d\u0435 \u043d\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430" +} \ No newline at end of file diff --git a/homeassistant/components/device_tracker/translations/bs.json b/homeassistant/components/device_tracker/translations/bs.json new file mode 100644 index 0000000000000..3221e7edc84a3 --- /dev/null +++ b/homeassistant/components/device_tracker/translations/bs.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "home": "Kod ku\u0107e", + "not_home": "Odsutan" + } + }, + "title": "Pra\u0107enje ure\u0111aja" +} \ No newline at end of file diff --git a/homeassistant/components/device_tracker/translations/ca.json b/homeassistant/components/device_tracker/translations/ca.json new file mode 100644 index 0000000000000..d2fc210369154 --- /dev/null +++ b/homeassistant/components/device_tracker/translations/ca.json @@ -0,0 +1,15 @@ +{ + "device_automation": { + "condition_type": { + "is_home": "{entity_name} \u00e9s a casa", + "is_not_home": "{entity_name} no \u00e9s a casa" + } + }, + "state": { + "_": { + "home": "A casa", + "not_home": "Fora" + } + }, + "title": "Seguiment de dispositius" +} \ No newline at end of file diff --git a/homeassistant/components/device_tracker/translations/cs.json b/homeassistant/components/device_tracker/translations/cs.json new file mode 100644 index 0000000000000..ed1923ceb075d --- /dev/null +++ b/homeassistant/components/device_tracker/translations/cs.json @@ -0,0 +1,15 @@ +{ + "device_automation": { + "condition_type": { + "is_home": "{entity_name} je doma", + "is_not_home": "{entity_name} nen\u00ed doma" + } + }, + "state": { + "_": { + "home": "Doma", + "not_home": "Pry\u010d" + } + }, + "title": "Sledova\u010d za\u0159\u00edzen\u00ed" +} \ No newline at end of file diff --git a/homeassistant/components/device_tracker/translations/cy.json b/homeassistant/components/device_tracker/translations/cy.json new file mode 100644 index 0000000000000..373281bf897be --- /dev/null +++ b/homeassistant/components/device_tracker/translations/cy.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "home": "Gartref", + "not_home": "Diim gartref" + } + }, + "title": "Traciwr dyfais" +} \ No newline at end of file diff --git a/homeassistant/components/device_tracker/translations/da.json b/homeassistant/components/device_tracker/translations/da.json new file mode 100644 index 0000000000000..c663c9028a0df --- /dev/null +++ b/homeassistant/components/device_tracker/translations/da.json @@ -0,0 +1,15 @@ +{ + "device_automation": { + "condition_type": { + "is_home": "{entity_name} er hjemme", + "is_not_home": "{entity_name} er ikke hjemme" + } + }, + "state": { + "_": { + "home": "Hjemme", + "not_home": "Ude" + } + }, + "title": "Enhedssporing" +} \ No newline at end of file diff --git a/homeassistant/components/device_tracker/translations/de.json b/homeassistant/components/device_tracker/translations/de.json new file mode 100644 index 0000000000000..651805dcb14b8 --- /dev/null +++ b/homeassistant/components/device_tracker/translations/de.json @@ -0,0 +1,15 @@ +{ + "device_automation": { + "condition_type": { + "is_home": "{entity_name} ist Zuhause", + "is_not_home": "{entity_name} ist nicht zu Hause" + } + }, + "state": { + "_": { + "home": "Zu Hause", + "not_home": "Abwesend" + } + }, + "title": "Ger\u00e4te-Tracker" +} \ No newline at end of file diff --git a/homeassistant/components/device_tracker/translations/el.json b/homeassistant/components/device_tracker/translations/el.json new file mode 100644 index 0000000000000..6a6c0969eb2c1 --- /dev/null +++ b/homeassistant/components/device_tracker/translations/el.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "home": "\u03a3\u03c0\u03af\u03c4\u03b9", + "not_home": "\u0395\u03ba\u03c4\u03cc\u03c2 \u03a3\u03c0\u03b9\u03c4\u03b9\u03bf\u03cd" + } + }, + "title": "\u03a3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae \u03b1\u03bd\u03b9\u03c7\u03bd\u03b5\u03c5\u03c4\u03ae" +} \ No newline at end of file diff --git a/homeassistant/components/device_tracker/translations/en.json b/homeassistant/components/device_tracker/translations/en.json new file mode 100644 index 0000000000000..dad3e1d52b774 --- /dev/null +++ b/homeassistant/components/device_tracker/translations/en.json @@ -0,0 +1,15 @@ +{ + "device_automation": { + "condition_type": { + "is_home": "{entity_name} is home", + "is_not_home": "{entity_name} is not home" + } + }, + "state": { + "_": { + "home": "Home", + "not_home": "Away" + } + }, + "title": "Device tracker" +} \ No newline at end of file diff --git a/homeassistant/components/device_tracker/translations/es-419.json b/homeassistant/components/device_tracker/translations/es-419.json new file mode 100644 index 0000000000000..8a8b7197dcb0e --- /dev/null +++ b/homeassistant/components/device_tracker/translations/es-419.json @@ -0,0 +1,15 @@ +{ + "device_automation": { + "condition_type": { + "is_home": "{entity_name} est\u00e1 en casa", + "is_not_home": "{entity_name} no est\u00e1 en casa" + } + }, + "state": { + "_": { + "home": "En Casa", + "not_home": "Fuera de Casa" + } + }, + "title": "Rastreador de dispositivos" +} \ No newline at end of file diff --git a/homeassistant/components/device_tracker/translations/es.json b/homeassistant/components/device_tracker/translations/es.json new file mode 100644 index 0000000000000..60bb86fbd273c --- /dev/null +++ b/homeassistant/components/device_tracker/translations/es.json @@ -0,0 +1,15 @@ +{ + "device_automation": { + "condition_type": { + "is_home": "{entity_name} est\u00e1 en casa", + "is_not_home": "{entity_name} no est\u00e1 en casa" + } + }, + "state": { + "_": { + "home": "En casa", + "not_home": "Fuera de casa" + } + }, + "title": "Rastreador de dispositivo" +} \ No newline at end of file diff --git a/homeassistant/components/device_tracker/translations/et.json b/homeassistant/components/device_tracker/translations/et.json new file mode 100644 index 0000000000000..340c03665ff23 --- /dev/null +++ b/homeassistant/components/device_tracker/translations/et.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "home": "Kodus", + "not_home": "Eemal" + } + }, + "title": "Seadme tr\u00e4kker" +} \ No newline at end of file diff --git a/homeassistant/components/device_tracker/translations/eu.json b/homeassistant/components/device_tracker/translations/eu.json new file mode 100644 index 0000000000000..179a4448f5fb8 --- /dev/null +++ b/homeassistant/components/device_tracker/translations/eu.json @@ -0,0 +1,8 @@ +{ + "state": { + "_": { + "home": "Etxean", + "not_home": "Kanpoan" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/device_tracker/translations/fa.json b/homeassistant/components/device_tracker/translations/fa.json new file mode 100644 index 0000000000000..2354717bdcd2b --- /dev/null +++ b/homeassistant/components/device_tracker/translations/fa.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "home": "\u062e\u0627\u0646\u0647", + "not_home": "\u0628\u06cc\u0631\u0648\u0646" + } + }, + "title": "\u0631\u062f\u06cc\u0627\u0628" +} \ No newline at end of file diff --git a/homeassistant/components/device_tracker/translations/fi.json b/homeassistant/components/device_tracker/translations/fi.json new file mode 100644 index 0000000000000..922e5eb5560d7 --- /dev/null +++ b/homeassistant/components/device_tracker/translations/fi.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "home": "Kotona", + "not_home": "Poissa" + } + }, + "title": "Laiteseuranta" +} \ No newline at end of file diff --git a/homeassistant/components/device_tracker/translations/fr.json b/homeassistant/components/device_tracker/translations/fr.json new file mode 100644 index 0000000000000..14cbd04871c68 --- /dev/null +++ b/homeassistant/components/device_tracker/translations/fr.json @@ -0,0 +1,15 @@ +{ + "device_automation": { + "condition_type": { + "is_home": "{entity_name} est \u00e0 la maison", + "is_not_home": "{entity_name} n'est pas \u00e0 la maison" + } + }, + "state": { + "_": { + "home": "Pr\u00e9sent", + "not_home": "Absent" + } + }, + "title": "Dispositif de suivi" +} \ No newline at end of file diff --git a/homeassistant/components/device_tracker/translations/gsw.json b/homeassistant/components/device_tracker/translations/gsw.json new file mode 100644 index 0000000000000..2f52ef016e655 --- /dev/null +++ b/homeassistant/components/device_tracker/translations/gsw.json @@ -0,0 +1,8 @@ +{ + "state": { + "_": { + "home": "Dahei", + "not_home": "Nid Dahei" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/device_tracker/translations/he.json b/homeassistant/components/device_tracker/translations/he.json new file mode 100644 index 0000000000000..5db22ed4071f1 --- /dev/null +++ b/homeassistant/components/device_tracker/translations/he.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "home": "\u05d1\u05d1\u05d9\u05ea", + "not_home": "\u05dc\u05d0 \u05d1\u05d1\u05d9\u05ea" + } + }, + "title": "\u05de\u05e2\u05e7\u05d1 \u05de\u05db\u05e9\u05d9\u05e8" +} \ No newline at end of file diff --git a/homeassistant/components/device_tracker/translations/hi.json b/homeassistant/components/device_tracker/translations/hi.json new file mode 100644 index 0000000000000..df8c83c109139 --- /dev/null +++ b/homeassistant/components/device_tracker/translations/hi.json @@ -0,0 +1,8 @@ +{ + "state": { + "_": { + "home": "\u0918\u0930" + } + }, + "title": "\u0921\u093f\u0935\u093e\u0907\u0938 \u091f\u094d\u0930\u0948\u0915\u0930" +} \ No newline at end of file diff --git a/homeassistant/components/device_tracker/translations/hr.json b/homeassistant/components/device_tracker/translations/hr.json new file mode 100644 index 0000000000000..eaef3d43c4f1d --- /dev/null +++ b/homeassistant/components/device_tracker/translations/hr.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "home": "Doma", + "not_home": "Odsutan" + } + }, + "title": "Pra\u0107enje ure\u0111aja" +} \ No newline at end of file diff --git a/homeassistant/components/device_tracker/translations/hu.json b/homeassistant/components/device_tracker/translations/hu.json new file mode 100644 index 0000000000000..2954376e314ef --- /dev/null +++ b/homeassistant/components/device_tracker/translations/hu.json @@ -0,0 +1,15 @@ +{ + "device_automation": { + "condition_type": { + "is_home": "{entity_name} otthon van", + "is_not_home": "{entity_name} nincs otthon" + } + }, + "state": { + "_": { + "home": "Otthon", + "not_home": "T\u00e1vol" + } + }, + "title": "Eszk\u00f6z nyomk\u00f6vet\u0151" +} \ No newline at end of file diff --git a/homeassistant/components/device_tracker/translations/hy.json b/homeassistant/components/device_tracker/translations/hy.json new file mode 100644 index 0000000000000..48730500a19e6 --- /dev/null +++ b/homeassistant/components/device_tracker/translations/hy.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "home": "\u054f\u0578\u0582\u0576", + "not_home": "\u0540\u0565\u057c\u0578\u0582" + } + }, + "title": "\u054d\u0561\u0580\u0584\u056b \u0578\u0580\u0578\u0576\u056b\u0579" +} \ No newline at end of file diff --git a/homeassistant/components/device_tracker/translations/id.json b/homeassistant/components/device_tracker/translations/id.json new file mode 100644 index 0000000000000..99baa5e1a76c5 --- /dev/null +++ b/homeassistant/components/device_tracker/translations/id.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "home": "Rumah", + "not_home": "Keluar" + } + }, + "title": "Pelacak perangkat" +} \ No newline at end of file diff --git a/homeassistant/components/device_tracker/translations/is.json b/homeassistant/components/device_tracker/translations/is.json new file mode 100644 index 0000000000000..433d2a6afb8a0 --- /dev/null +++ b/homeassistant/components/device_tracker/translations/is.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "home": "Heima", + "not_home": "Fjarverandi" + } + }, + "title": "Rekja t\u00e6ki" +} \ No newline at end of file diff --git a/homeassistant/components/device_tracker/translations/it.json b/homeassistant/components/device_tracker/translations/it.json new file mode 100644 index 0000000000000..bcb9753631065 --- /dev/null +++ b/homeassistant/components/device_tracker/translations/it.json @@ -0,0 +1,15 @@ +{ + "device_automation": { + "condition_type": { + "is_home": "{entity_name} \u00e8 in casa", + "is_not_home": "{entity_name} non \u00e8 in casa" + } + }, + "state": { + "_": { + "home": "A casa", + "not_home": "Fuori casa" + } + }, + "title": "Tracciatore dispositivo" +} \ No newline at end of file diff --git a/homeassistant/components/device_tracker/translations/ja.json b/homeassistant/components/device_tracker/translations/ja.json new file mode 100644 index 0000000000000..6679d6cca0644 --- /dev/null +++ b/homeassistant/components/device_tracker/translations/ja.json @@ -0,0 +1,8 @@ +{ + "state": { + "_": { + "home": "\u5728\u5b85", + "not_home": "\u5916\u51fa" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/device_tracker/translations/ko.json b/homeassistant/components/device_tracker/translations/ko.json new file mode 100644 index 0000000000000..e3e72d49c89e0 --- /dev/null +++ b/homeassistant/components/device_tracker/translations/ko.json @@ -0,0 +1,15 @@ +{ + "device_automation": { + "condition_type": { + "is_home": "{entity_name} \uc774(\uac00) \uc9d1\uc5d0 \uc788\uc73c\uba74", + "is_not_home": "{entity_name} \uc774(\uac00) \uc678\ucd9c \uc911\uc774\uba74" + } + }, + "state": { + "_": { + "home": "\uc7ac\uc2e4", + "not_home": "\uc678\ucd9c" + } + }, + "title": "\ucd94\uc801 \uae30\uae30" +} \ No newline at end of file diff --git a/homeassistant/components/device_tracker/translations/lb.json b/homeassistant/components/device_tracker/translations/lb.json new file mode 100644 index 0000000000000..88d1b40b7ba1f --- /dev/null +++ b/homeassistant/components/device_tracker/translations/lb.json @@ -0,0 +1,15 @@ +{ + "device_automation": { + "condition_type": { + "is_home": "{entity_name} ass doheem", + "is_not_home": "{entity_name} ass net doheem" + } + }, + "state": { + "_": { + "home": "Doheem", + "not_home": "\u00cbnnerwee" + } + }, + "title": "Apparat Verfolgung" +} \ No newline at end of file diff --git a/homeassistant/components/device_tracker/translations/lv.json b/homeassistant/components/device_tracker/translations/lv.json new file mode 100644 index 0000000000000..5ebb6b9997992 --- /dev/null +++ b/homeassistant/components/device_tracker/translations/lv.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "home": "M\u0101j\u0101s", + "not_home": "Prom" + } + }, + "title": "Ier\u012b\u010du izsekot\u0101js" +} \ No newline at end of file diff --git a/homeassistant/components/device_tracker/translations/nb.json b/homeassistant/components/device_tracker/translations/nb.json new file mode 100644 index 0000000000000..6a9e073ca7d8a --- /dev/null +++ b/homeassistant/components/device_tracker/translations/nb.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "home": "Hjemme", + "not_home": "Borte" + } + }, + "title": "Enhetssporing" +} \ No newline at end of file diff --git a/homeassistant/components/device_tracker/translations/nl.json b/homeassistant/components/device_tracker/translations/nl.json new file mode 100644 index 0000000000000..99c0652d98226 --- /dev/null +++ b/homeassistant/components/device_tracker/translations/nl.json @@ -0,0 +1,15 @@ +{ + "device_automation": { + "condition_type": { + "is_home": "{entity_name} is thuis", + "is_not_home": "{entity_name} is niet thuis" + } + }, + "state": { + "_": { + "home": "Thuis", + "not_home": "Afwezig" + } + }, + "title": "Apparaat tracker" +} \ No newline at end of file diff --git a/homeassistant/components/device_tracker/translations/nn.json b/homeassistant/components/device_tracker/translations/nn.json new file mode 100644 index 0000000000000..6dff562699238 --- /dev/null +++ b/homeassistant/components/device_tracker/translations/nn.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "home": "Heime", + "not_home": "Borte" + } + }, + "title": "Einingssporing" +} \ No newline at end of file diff --git a/homeassistant/components/device_tracker/translations/no.json b/homeassistant/components/device_tracker/translations/no.json new file mode 100644 index 0000000000000..56ef663e6c6c7 --- /dev/null +++ b/homeassistant/components/device_tracker/translations/no.json @@ -0,0 +1,15 @@ +{ + "device_automation": { + "condition_type": { + "is_home": "{entity_name} er hjemme", + "is_not_home": "{entity_name} er ikke hjemme" + } + }, + "state": { + "_": { + "home": "Hjemme", + "not_home": "Borte" + } + }, + "title": "Enhetssporing" +} \ No newline at end of file diff --git a/homeassistant/components/device_tracker/translations/pl.json b/homeassistant/components/device_tracker/translations/pl.json new file mode 100644 index 0000000000000..94cc3d97138b4 --- /dev/null +++ b/homeassistant/components/device_tracker/translations/pl.json @@ -0,0 +1,15 @@ +{ + "device_automation": { + "condition_type": { + "is_home": "urz\u0105dzenie {entity_name} jest w domu", + "is_not_home": "urz\u0105dzenie {entity_name} jest poza domem" + } + }, + "state": { + "_": { + "home": "w domu", + "not_home": "poza domem" + } + }, + "title": "\u015aledzenie urz\u0105dze\u0144" +} \ No newline at end of file diff --git a/homeassistant/components/device_tracker/translations/pt-BR.json b/homeassistant/components/device_tracker/translations/pt-BR.json new file mode 100644 index 0000000000000..c20638a4a6131 --- /dev/null +++ b/homeassistant/components/device_tracker/translations/pt-BR.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "home": "Em casa", + "not_home": "Ausente" + } + }, + "title": "Rastreador de dispositivo" +} \ No newline at end of file diff --git a/homeassistant/components/device_tracker/translations/pt.json b/homeassistant/components/device_tracker/translations/pt.json new file mode 100644 index 0000000000000..420a2a5ed3636 --- /dev/null +++ b/homeassistant/components/device_tracker/translations/pt.json @@ -0,0 +1,15 @@ +{ + "device_automation": { + "condition_type": { + "is_home": "{entity_name} est\u00e1 em casa", + "is_not_home": "{entity_name} n\u00e3o est\u00e1 em casa" + } + }, + "state": { + "_": { + "home": "Casa", + "not_home": "Fora" + } + }, + "title": "Monitorizador de dispositivos" +} \ No newline at end of file diff --git a/homeassistant/components/device_tracker/translations/ro.json b/homeassistant/components/device_tracker/translations/ro.json new file mode 100644 index 0000000000000..a2f6bb3d08c20 --- /dev/null +++ b/homeassistant/components/device_tracker/translations/ro.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "home": "Acas\u0103", + "not_home": "Plecat" + } + }, + "title": "Dispozitiv tracker" +} \ No newline at end of file diff --git a/homeassistant/components/device_tracker/translations/ru.json b/homeassistant/components/device_tracker/translations/ru.json new file mode 100644 index 0000000000000..8ea3398e3e20b --- /dev/null +++ b/homeassistant/components/device_tracker/translations/ru.json @@ -0,0 +1,15 @@ +{ + "device_automation": { + "condition_type": { + "is_home": "{entity_name} \u0434\u043e\u043c\u0430", + "is_not_home": "{entity_name} \u043d\u0435 \u0434\u043e\u043c\u0430" + } + }, + "state": { + "_": { + "home": "\u0414\u043e\u043c\u0430", + "not_home": "\u041d\u0435 \u0434\u043e\u043c\u0430" + } + }, + "title": "\u041e\u0442\u0441\u043b\u0435\u0436\u0438\u0432\u0430\u043d\u0438\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432" +} \ No newline at end of file diff --git a/homeassistant/components/device_tracker/translations/sk.json b/homeassistant/components/device_tracker/translations/sk.json new file mode 100644 index 0000000000000..9d52c35e2cb6c --- /dev/null +++ b/homeassistant/components/device_tracker/translations/sk.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "home": "Doma", + "not_home": "Pre\u010d" + } + }, + "title": "Sledovanie zariadenia" +} \ No newline at end of file diff --git a/homeassistant/components/device_tracker/translations/sl.json b/homeassistant/components/device_tracker/translations/sl.json new file mode 100644 index 0000000000000..d5ba84f5e29cb --- /dev/null +++ b/homeassistant/components/device_tracker/translations/sl.json @@ -0,0 +1,15 @@ +{ + "device_automation": { + "condition_type": { + "is_home": "{entity_name} je doma", + "is_not_home": "{entity_name} ni doma" + } + }, + "state": { + "_": { + "home": "Doma", + "not_home": "Odsoten" + } + }, + "title": "Sledilnik naprave" +} \ No newline at end of file diff --git a/homeassistant/components/device_tracker/translations/sv.json b/homeassistant/components/device_tracker/translations/sv.json new file mode 100644 index 0000000000000..7ef1cc7b2f874 --- /dev/null +++ b/homeassistant/components/device_tracker/translations/sv.json @@ -0,0 +1,15 @@ +{ + "device_automation": { + "condition_type": { + "is_home": "{entity_name} \u00e4r hemma", + "is_not_home": "{entity_name} \u00e4r inte hemma" + } + }, + "state": { + "_": { + "home": "Hemma", + "not_home": "Borta" + } + }, + "title": "Enhetssp\u00e5rare" +} \ No newline at end of file diff --git a/homeassistant/components/device_tracker/translations/ta.json b/homeassistant/components/device_tracker/translations/ta.json new file mode 100644 index 0000000000000..f3c6966ab99c6 --- /dev/null +++ b/homeassistant/components/device_tracker/translations/ta.json @@ -0,0 +1,8 @@ +{ + "state": { + "_": { + "home": "\u0bae\u0bc1\u0b95\u0baa\u0bcd\u0baa\u0bc1", + "not_home": "\u0ba4\u0bca\u0bb2\u0bc8\u0bb5\u0bbf\u0bb2\u0bcd" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/device_tracker/translations/te.json b/homeassistant/components/device_tracker/translations/te.json new file mode 100644 index 0000000000000..89bc428d2f9c9 --- /dev/null +++ b/homeassistant/components/device_tracker/translations/te.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "home": "\u0c07\u0c02\u0c1f", + "not_home": "\u0c2c\u0c2f\u0c1f" + } + }, + "title": "\u0c2a\u0c30\u0c3f\u0c15\u0c30\u0c02 \u0c1f\u0c4d\u0c30\u0c3e\u0c15\u0c30\u0c4d" +} \ No newline at end of file diff --git a/homeassistant/components/device_tracker/translations/th.json b/homeassistant/components/device_tracker/translations/th.json new file mode 100644 index 0000000000000..26db63ab4d475 --- /dev/null +++ b/homeassistant/components/device_tracker/translations/th.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "home": "\u0e2d\u0e22\u0e39\u0e48\u0e1a\u0e49\u0e32\u0e19", + "not_home": "\u0e44\u0e21\u0e48\u0e2d\u0e22\u0e39\u0e48" + } + }, + "title": "\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c\u0e15\u0e34\u0e14\u0e15\u0e32\u0e21" +} \ No newline at end of file diff --git a/homeassistant/components/device_tracker/translations/tr.json b/homeassistant/components/device_tracker/translations/tr.json new file mode 100644 index 0000000000000..6bb5ae1460373 --- /dev/null +++ b/homeassistant/components/device_tracker/translations/tr.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "home": "Evde", + "not_home": "D\u0131\u015far\u0131da" + } + }, + "title": "Cihaz izleyici" +} \ No newline at end of file diff --git a/homeassistant/components/device_tracker/translations/uk.json b/homeassistant/components/device_tracker/translations/uk.json new file mode 100644 index 0000000000000..f49c7acc0e393 --- /dev/null +++ b/homeassistant/components/device_tracker/translations/uk.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "home": "\u0412\u0434\u043e\u043c\u0430", + "not_home": "\u0412\u0456\u0434\u0441\u0443\u0442\u043d\u0456\u0439" + } + }, + "title": "\u0422\u0440\u0435\u043a\u0435\u0440 \u043f\u0440\u0438\u0441\u0442\u0440\u043e\u044e" +} \ No newline at end of file diff --git a/homeassistant/components/device_tracker/translations/vi.json b/homeassistant/components/device_tracker/translations/vi.json new file mode 100644 index 0000000000000..7b0be26d4ade9 --- /dev/null +++ b/homeassistant/components/device_tracker/translations/vi.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "home": "\u1ede nh\u00e0", + "not_home": "\u0110i v\u1eafng" + } + }, + "title": "Tr\u00ecnh theo d\u00f5i thi\u1ebft b\u1ecb" +} \ No newline at end of file diff --git a/homeassistant/components/device_tracker/translations/zh-Hans.json b/homeassistant/components/device_tracker/translations/zh-Hans.json new file mode 100644 index 0000000000000..28adcdbdd1a99 --- /dev/null +++ b/homeassistant/components/device_tracker/translations/zh-Hans.json @@ -0,0 +1,15 @@ +{ + "device_automation": { + "condition_type": { + "is_home": "{entity_name} \u5728\u5bb6", + "is_not_home": "{entity_name} \u4e0d\u5728\u5bb6" + } + }, + "state": { + "_": { + "home": "\u5728\u5bb6", + "not_home": "\u79bb\u5f00" + } + }, + "title": "\u8bbe\u5907\u8ddf\u8e2a\u5668" +} \ No newline at end of file diff --git a/homeassistant/components/device_tracker/translations/zh-Hant.json b/homeassistant/components/device_tracker/translations/zh-Hant.json new file mode 100644 index 0000000000000..b1d3e1a17799e --- /dev/null +++ b/homeassistant/components/device_tracker/translations/zh-Hant.json @@ -0,0 +1,15 @@ +{ + "device_automation": { + "condition_type": { + "is_home": "{entity_name}\u5728\u5bb6", + "is_not_home": "{entity_name}\u4e0d\u5728\u5bb6" + } + }, + "state": { + "_": { + "home": "\u5728\u5bb6", + "not_home": "\u96e2\u5bb6" + } + }, + "title": "\u8a2d\u5099\u8ffd\u8e64\u5668" +} \ No newline at end of file diff --git a/homeassistant/components/dht/manifest.json b/homeassistant/components/dht/manifest.json index 05889bdd32610..5e747d9473272 100644 --- a/homeassistant/components/dht/manifest.json +++ b/homeassistant/components/dht/manifest.json @@ -1,10 +1,7 @@ { "domain": "dht", - "name": "Dht", - "documentation": "https://www.home-assistant.io/components/dht", - "requirements": [ - "Adafruit-DHT==1.4.0" - ], - "dependencies": [], + "name": "DHT Sensor", + "documentation": "https://www.home-assistant.io/integrations/dht", + "requirements": ["Adafruit-DHT==1.4.0"], "codeowners": [] } diff --git a/homeassistant/components/dht/sensor.py b/homeassistant/components/dht/sensor.py index d544bfa74e85a..6f3e58d4ad48e 100644 --- a/homeassistant/components/dht/sensor.py +++ b/homeassistant/components/dht/sensor.py @@ -1,52 +1,61 @@ """Support for Adafruit DHT temperature and humidity sensor.""" -import logging from datetime import timedelta +import logging +import Adafruit_DHT # pylint: disable=import-error 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) + CONF_MONITORED_CONDITIONS, + CONF_NAME, + TEMP_FAHRENHEIT, + UNIT_PERCENTAGE, +) +import homeassistant.helpers.config_validation as cv 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' +CONF_PIN = "pin" +CONF_SENSOR = "sensor" +CONF_HUMIDITY_OFFSET = "humidity_offset" +CONF_TEMPERATURE_OFFSET = "temperature_offset" -DEFAULT_NAME = 'DHT Sensor' +DEFAULT_NAME = "DHT Sensor" # DHT11 is able to deliver data once per second, DHT22 once every two MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30) -SENSOR_TEMPERATURE = 'temperature' -SENSOR_HUMIDITY = 'humidity' +SENSOR_TEMPERATURE = "temperature" +SENSOR_HUMIDITY = "humidity" SENSOR_TYPES = { - SENSOR_TEMPERATURE: ['Temperature', None], - SENSOR_HUMIDITY: ['Humidity', '%'] + SENSOR_TEMPERATURE: ["Temperature", None], + SENSOR_HUMIDITY: ["Humidity", UNIT_PERCENTAGE], } -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)) -}) +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 = { @@ -54,10 +63,10 @@ def setup_platform(hass, config, add_entities, discovery_info=None): "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) + sensor = available_sensors.get(config[CONF_SENSOR]) + pin = config[CONF_PIN] + temperature_offset = config[CONF_TEMPERATURE_OFFSET] + humidity_offset = config[CONF_HUMIDITY_OFFSET] if not sensor: _LOGGER.error("DHT sensor type is not supported") @@ -65,13 +74,20 @@ def setup_platform(hass, config, add_entities, discovery_info=None): data = DHTClient(Adafruit_DHT, sensor, pin) dev = [] - name = config.get(CONF_NAME) + name = config[CONF_NAME] try: for variable in config[CONF_MONITORED_CONDITIONS]: - dev.append(DHTSensor( - data, variable, SENSOR_TYPES[variable][1], name, - temperature_offset, humidity_offset)) + dev.append( + DHTSensor( + data, + variable, + SENSOR_TYPES[variable][1], + name, + temperature_offset, + humidity_offset, + ) + ) except KeyError: pass @@ -81,8 +97,15 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class DHTSensor(Entity): """Implementation of the DHT sensor.""" - def __init__(self, dht_client, sensor_type, temp_unit, name, - temperature_offset, humidity_offset): + 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] @@ -97,7 +120,7 @@ def __init__(self, dht_client, sensor_type, temp_unit, name, @property def name(self): """Return the name of the sensor.""" - return '{} {}'.format(self.client_name, self._name) + return f"{self.client_name} {self._name}" @property def state(self): @@ -118,16 +141,18 @@ def update(self): if self.type == SENSOR_TEMPERATURE and SENSOR_TEMPERATURE in data: temperature = data[SENSOR_TEMPERATURE] - _LOGGER.debug("Temperature %.1f \u00b0C + offset %.1f", - temperature, temperature_offset) + _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) + _LOGGER.debug("Humidity %.1f%% + offset %.1f", humidity, humidity_offset) if 0 <= humidity <= 100: self._state = round(humidity + humidity_offset, 1) @@ -140,13 +165,12 @@ def __init__(self, adafruit_dht, sensor, pin): self.adafruit_dht = adafruit_dht self.sensor = sensor self.pin = pin - self.data = dict() + self.data = {} @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) + humidity, temperature = self.adafruit_dht.read_retry(self.sensor, self.pin) if temperature: self.data[SENSOR_TEMPERATURE] = temperature if humidity: diff --git a/homeassistant/components/dialogflow/.translations/bg.json b/homeassistant/components/dialogflow/.translations/bg.json deleted file mode 100644 index 6f06d5c00c628..0000000000000 --- a/homeassistant/components/dialogflow/.translations/bg.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "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 deleted file mode 100644 index 0967b1c158e7d..0000000000000 --- a/homeassistant/components/dialogflow/.translations/ca.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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 deleted file mode 100644 index 21da9b4823b6e..0000000000000 --- a/homeassistant/components/dialogflow/.translations/cs.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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 deleted file mode 100644 index 2fb203450a5eb..0000000000000 --- a/homeassistant/components/dialogflow/.translations/da.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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 deleted file mode 100644 index f585799391e2a..0000000000000 --- a/homeassistant/components/dialogflow/.translations/de.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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 deleted file mode 100644 index 9e1cbbb636ef6..0000000000000 --- a/homeassistant/components/dialogflow/.translations/en.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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 deleted file mode 100644 index 41a66b038f5a2..0000000000000 --- a/homeassistant/components/dialogflow/.translations/es-419.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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 deleted file mode 100644 index 1d6a849f3a870..0000000000000 --- a/homeassistant/components/dialogflow/.translations/es.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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 deleted file mode 100644 index 53edb21b8e82c..0000000000000 --- a/homeassistant/components/dialogflow/.translations/fr.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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 deleted file mode 100644 index 89889fd60481c..0000000000000 --- a/homeassistant/components/dialogflow/.translations/hu.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "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 deleted file mode 100644 index cc1a7ac851074..0000000000000 --- a/homeassistant/components/dialogflow/.translations/it.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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 deleted file mode 100644 index 33c465bf0e74e..0000000000000 --- a/homeassistant/components/dialogflow/.translations/ko.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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 deleted file mode 100644 index 752acbdecd3ac..0000000000000 --- a/homeassistant/components/dialogflow/.translations/lb.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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 deleted file mode 100644 index 9871df0d26259..0000000000000 --- a/homeassistant/components/dialogflow/.translations/nl.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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 deleted file mode 100644 index e27d59a40e359..0000000000000 --- a/homeassistant/components/dialogflow/.translations/no.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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 deleted file mode 100644 index 3395b31b4c79e..0000000000000 --- a/homeassistant/components/dialogflow/.translations/pl.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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 deleted file mode 100644 index de754080f1744..0000000000000 --- a/homeassistant/components/dialogflow/.translations/pt.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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 deleted file mode 100644 index d8b7db09a78c6..0000000000000 --- a/homeassistant/components/dialogflow/.translations/ru.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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 deleted file mode 100644 index 18a476b6870eb..0000000000000 --- a/homeassistant/components/dialogflow/.translations/sl.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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 deleted file mode 100644 index 07fe5e112172c..0000000000000 --- a/homeassistant/components/dialogflow/.translations/sv.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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 deleted file mode 100644 index 8a542dd0d6293..0000000000000 --- a/homeassistant/components/dialogflow/.translations/zh-Hans.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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 deleted file mode 100644 index 18d3d92e16b55..0000000000000 --- a/homeassistant/components/dialogflow/.translations/zh-Hant.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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 index a6134d4b19c04..ae3c0288aed58 100644 --- a/homeassistant/components/dialogflow/__init__.py +++ b/homeassistant/components/dialogflow/__init__.py @@ -1,22 +1,23 @@ """Support for Dialogflow webhook.""" import logging -import voluptuous as vol from aiohttp import web +import voluptuous as vol from homeassistant.const import CONF_WEBHOOK_ID from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import intent, template, config_entry_flow +from homeassistant.helpers import config_entry_flow, intent, template -_LOGGER = logging.getLogger(__name__) +from .const import DOMAIN -DOMAIN = 'dialogflow' +_LOGGER = logging.getLogger(__name__) SOURCE = "Home Assistant Dialogflow" -CONFIG_SCHEMA = vol.Schema({ - DOMAIN: {} -}, extra=vol.ALLOW_EXTRA) +CONFIG_SCHEMA = vol.Schema({DOMAIN: {}}, extra=vol.ALLOW_EXTRA) + +V1 = 1 +V2 = 2 class DialogFlowError(HomeAssistantError): @@ -36,7 +37,7 @@ async def handle_webhook(hass, webhook_id, request): try: response = await async_handle_message(hass, message) - return b'' if response is None else web.json_response(response) + return b"" if response is None else web.json_response(response) except DialogFlowError as err: _LOGGER.warning(str(err)) @@ -46,8 +47,7 @@ async def handle_webhook(hass, webhook_id, request): _LOGGER.warning(str(err)) return web.json_response( dialogflow_error_response( - message, - "This intent is not yet configured within Home Assistant." + message, "This intent is not yet configured within Home Assistant." ) ) @@ -55,21 +55,22 @@ async def handle_webhook(hass, webhook_id, request): _LOGGER.warning(str(err)) return web.json_response( dialogflow_error_response( - message, - "Invalid slot information received for this intent." + 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.")) + 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) + DOMAIN, "DialogFlow", entry.data[CONF_WEBHOOK_ID], handle_webhook + ) return True @@ -83,48 +84,62 @@ async def async_unload_entry(hass, entry): async_remove_entry = config_entry_flow.webhook_async_remove_entry -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/' - } -) - - def dialogflow_error_response(message, error): """Return a response saying the error message.""" - dialogflow_response = DialogflowResponse(message['result']['parameters']) + api_version = get_api_version(message) + if api_version is V1: + parameters = message["result"]["parameters"] + elif api_version is V2: + parameters = message["queryResult"]["parameters"] + dialogflow_response = DialogflowResponse(parameters, api_version) 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'] +def get_api_version(message): + """Get API version of Dialogflow message.""" + if message.get("id") is not None: + return V1 + if message.get("responseId") is not None: + return V2 - if action_incomplete: - return None - action = req.get('action', '') - parameters = req.get('parameters').copy() +async def async_handle_message(hass, message): + """Handle a DialogFlow message.""" + _api_version = get_api_version(message) + if _api_version is V1: + _LOGGER.warning( + "Dialogflow V1 API will be removed on October 23, 2019. Please change your DialogFlow settings to use the V2 api" + ) + req = message.get("result") + action_incomplete = req.get("actionIncomplete", True) + if action_incomplete: + return + + elif _api_version is V2: + req = message.get("queryResult") + if req.get("allRequiredParamsPresent", False) is False: + return + + action = req.get("action", "") + parameters = req.get("parameters").copy() parameters["dialogflow_query"] = message - dialogflow_response = DialogflowResponse(parameters) + dialogflow_response = DialogflowResponse(parameters, _api_version) if action == "": raise DialogFlowError( - "You have not defined an action in your Dialogflow intent.") + "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()}) + 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']) + if "plain" in intent_response.speech: + dialogflow_response.add_speech(intent_response.speech["plain"]["speech"]) return dialogflow_response.as_dict() @@ -132,13 +147,14 @@ async def async_handle_message(hass, message): class DialogflowResponse: """Help generating the response for Dialogflow.""" - def __init__(self, parameters): + def __init__(self, parameters, api_version): """Initialize the Dialogflow response.""" self.speech = None self.parameters = {} + self.api_version = api_version # Parameter names replace '.' and '-' for '_' for key, value in parameters.items(): - underscored_key = key.replace('.', '_').replace('-', '_') + underscored_key = key.replace(".", "_").replace("-", "_") self.parameters[underscored_key] = value def add_speech(self, text): @@ -152,8 +168,8 @@ def add_speech(self, text): def as_dict(self): """Return response in a Dialogflow valid dictionary.""" - return { - 'speech': self.speech, - 'displayText': self.speech, - 'source': SOURCE, - } + if self.api_version is V1: + return {"speech": self.speech, "displayText": self.speech, "source": SOURCE} + + if self.api_version is V2: + return {"fulfillmentText": 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..fee99898ccc17 --- /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/integrations/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 index d136b8a984d53..53aed42afaae5 100644 --- a/homeassistant/components/dialogflow/manifest.json +++ b/homeassistant/components/dialogflow/manifest.json @@ -1,10 +1,8 @@ { "domain": "dialogflow", "name": "Dialogflow", - "documentation": "https://www.home-assistant.io/components/dialogflow", - "requirements": [], - "dependencies": [ - "webhook" - ], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/dialogflow", + "dependencies": ["webhook"], "codeowners": [] } diff --git a/homeassistant/components/dialogflow/strings.json b/homeassistant/components/dialogflow/strings.json index 4a3e91a3e501e..d1a691dc92bf6 100644 --- a/homeassistant/components/dialogflow/strings.json +++ b/homeassistant/components/dialogflow/strings.json @@ -1,6 +1,5 @@ { "config": { - "title": "Dialogflow", "step": { "user": { "title": "Set up the Dialogflow Webhook", diff --git a/homeassistant/components/dialogflow/translations/bg.json b/homeassistant/components/dialogflow/translations/bg.json new file mode 100644 index 0000000000000..069545d93c8f7 --- /dev/null +++ b/homeassistant/components/dialogflow/translations/bg.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Home Assistant \u0442\u0440\u044f\u0431\u0432\u0430 \u0434\u0430 \u0435 \u0434\u043e\u0441\u0442\u044a\u043f\u0435\u043d \u043e\u0442 \u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442 \u0437\u0430 \u0434\u0430 \u043f\u043e\u043b\u0443\u0447\u0430\u0432\u0430 \u0441\u044a\u043e\u0431\u0449\u0435\u043d\u0438\u044f \u043e\u0442 Dialogflow.", + "one_instance_allowed": "\u041d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u0430 \u0435 \u0441\u0430\u043c\u043e \u0435\u0434\u043d\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f." + }, + "create_entry": { + "default": "\u0417\u0430 \u0434\u0430 \u0438\u0437\u043f\u0440\u0430\u0449\u0430\u0442\u0435 \u0441\u044a\u0431\u0438\u0442\u0438\u044f \u0434\u043e Home Assistant, \u0449\u0435 \u0442\u0440\u044f\u0431\u0432\u0430 \u0434\u0430 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u0435 [\u0444\u0443\u043d\u043a\u0446\u0438\u044f\u0442\u0430 webhook \u0432 Dialogflow]({dialogflow_url}). \n\n \u041f\u043e\u043f\u044a\u043b\u043d\u0435\u0442\u0435 \u0441\u043b\u0435\u0434\u043d\u0430\u0442\u0430 \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044f: \n\n - URL: ` {webhook_url} ` \n - Method: POST\n - Content Type: application/json\n\n \u0412\u0438\u0436\u0442\u0435 [\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u0446\u0438\u044f\u0442\u0430]({docs_url}) \u0437\u0430 \u043f\u043e\u0432\u0435\u0447\u0435 \u043f\u043e\u0434\u0440\u043e\u0431\u043d\u043e\u0441\u0442\u0438." + }, + "step": { + "user": { + "description": "\u0421\u0438\u0433\u0443\u0440\u043d\u0438 \u043b\u0438 \u0441\u0442\u0435, \u0447\u0435 \u0438\u0441\u043a\u0430\u0442\u0435 \u0434\u0430 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u0435 Dialogflow?", + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0432\u0430\u043d\u0435 \u043d\u0430 Dialogflow Webhook" + } + } + } +} \ 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..17dd38ffb203f --- /dev/null +++ b/homeassistant/components/dialogflow/translations/ca.json @@ -0,0 +1,17 @@ +{ + "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 de 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..3ad8ba78c72ef --- /dev/null +++ b/homeassistant/components/dialogflow/translations/cs.json @@ -0,0 +1,17 @@ +{ + "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: application/json\n\n Podrobn\u011bj\u0161\u00ed informace naleznete v [dokumentaci]({docs_url})." + }, + "step": { + "user": { + "description": "Opravdu chcete nastavit Dialogflow?", + "title": "Nastavit Dialogflow Webhook" + } + } + } +} \ 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..bcab485a51ad3 --- /dev/null +++ b/homeassistant/components/dialogflow/translations/da.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Din Home Assistant-instans skal v\u00e6re tilg\u00e6ngelig 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 h\u00e6ndelser til Home Assistant skal du konfigurere [webhook-integration med Dialogflow]({dialogflow_url}).\n\n Udfyld f\u00f8lgende oplysninger: \n\n - Webadresse: `{webhook_url}`\n - Metode: POST\n - Indholdstype: application/json\n\nSe [dokumentationen]({docs_url}) for yderligere oplysninger." + }, + "step": { + "user": { + "description": "Er du sikker p\u00e5 at du vil konfigurere Dialogflow?", + "title": "Konfigurer Dialogflow Webhook" + } + } + } +} \ 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..cc65084d5aef7 --- /dev/null +++ b/homeassistant/components/dialogflow/translations/de.json @@ -0,0 +1,17 @@ +{ + "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, musst du [Webhook-Integration von Dialogflow]({dialogflow_url}) einrichten. \n\nF\u00fclle die folgenden Informationen aus: \n\n - URL: ` {webhook_url} ` \n - Methode: POST \n - Inhaltstyp: application / json \n\nWeitere Informationen findest du in der [Dokumentation]({docs_url})." + }, + "step": { + "user": { + "description": "M\u00f6chtest du Dialogflow wirklich einrichten?", + "title": "Dialogflow Webhook einrichten" + } + } + } +} \ 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..cc9eda6a96803 --- /dev/null +++ b/homeassistant/components/dialogflow/translations/en.json @@ -0,0 +1,17 @@ +{ + "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" + } + } + } +} \ 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..1aa3d23cb7ef1 --- /dev/null +++ b/homeassistant/components/dialogflow/translations/es-419.json @@ -0,0 +1,17 @@ +{ + "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" + } + } + } +} \ 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..f71313484994e --- /dev/null +++ b/homeassistant/components/dialogflow/translations/es.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Tu instancia de Home Assistant debe ser accesible desde Internet para recibir mensajes de Dialogflow.", + "one_instance_allowed": "S\u00f3lo se necesita una instancia." + }, + "create_entry": { + "default": "Para enviar eventos a Home Assistant, necesitas configurar [Integraci\u00f3n 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 m\u00e1s detalles." + }, + "step": { + "user": { + "description": "\u00bfEst\u00e1s seguro de que quieres configurar Dialogflow?", + "title": "Configurar el Webhook de 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..81de11edbd5bb --- /dev/null +++ b/homeassistant/components/dialogflow/translations/fr.json @@ -0,0 +1,17 @@ +{ + "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 Dialogflow] ( {dialogflow_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" + } + } + } +} \ 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..d44e7f60cc6fd --- /dev/null +++ b/homeassistant/components/dialogflow/translations/hu.json @@ -0,0 +1,14 @@ +{ + "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" + } + } + } +} \ 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..fe31d88e5c416 --- /dev/null +++ b/homeassistant/components/dialogflow/translations/it.json @@ -0,0 +1,17 @@ +{ + "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" + } + } + } +} \ 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..f9ee10ceee751 --- /dev/null +++ b/homeassistant/components/dialogflow/translations/ko.json @@ -0,0 +1,17 @@ +{ + "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 \uc6f9 \ud6c5]({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 \uc6f9 \ud6c5 \uc124\uc815\ud558\uae30" + } + } + } +} \ 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..a10adda670252 --- /dev/null +++ b/homeassistant/components/dialogflow/translations/lb.json @@ -0,0 +1,17 @@ +{ + "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" + } + } + } +} \ 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..4739537179769 --- /dev/null +++ b/homeassistant/components/dialogflow/translations/nl.json @@ -0,0 +1,17 @@ +{ + "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" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dialogflow/translations/nn.json b/homeassistant/components/dialogflow/translations/nn.json new file mode 100644 index 0000000000000..81b7a05690d62 --- /dev/null +++ b/homeassistant/components/dialogflow/translations/nn.json @@ -0,0 +1,3 @@ +{ + "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..8abbc221b7cc1 --- /dev/null +++ b/homeassistant/components/dialogflow/translations/no.json @@ -0,0 +1,17 @@ +{ + "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 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" + } + } + } +} \ 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..3b939a9f36937 --- /dev/null +++ b/homeassistant/components/dialogflow/translations/pl.json @@ -0,0 +1,17 @@ +{ + "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]({dialogflow_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": "Na pewno chcesz skonfigurowa\u0107 Dialogflow?", + "title": "Konfiguracja Dialogflow Webhook" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dialogflow/translations/pt-BR.json b/homeassistant/components/dialogflow/translations/pt-BR.json new file mode 100644 index 0000000000000..3d4ad4ca34bb9 --- /dev/null +++ b/homeassistant/components/dialogflow/translations/pt-BR.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Sua inst\u00e2ncia do Home Assistant precisa estar acess\u00edvel na Internet para receber mensagens da Dialogflow.", + "one_instance_allowed": "Apenas uma \u00fanica inst\u00e2ncia \u00e9 necess\u00e1ria." + }, + "create_entry": { + "default": "Para enviar eventos para o Home Assistant, voc\u00ea precisar\u00e1 configurar [Integra\u00e7\u00e3o do webhook da Dialogflow] ( {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 mais detalhes." + }, + "step": { + "user": { + "description": "Tem certeza de que deseja configurar o Dialogflow?", + "title": "Configurar o Dialogflow Webhook" + } + } + } +} \ 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..e6ed255e6c084 --- /dev/null +++ b/homeassistant/components/dialogflow/translations/pt.json @@ -0,0 +1,17 @@ +{ + "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" + } + } + } +} \ 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..15ecb63392e0a --- /dev/null +++ b/homeassistant/components/dialogflow/translations/ru.json @@ -0,0 +1,17 @@ +{ + "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 Webhook \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 [\u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u044f\u043c\u0438]({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" + } + } + } +} \ 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..742302dcd1786 --- /dev/null +++ b/homeassistant/components/dialogflow/translations/sl.json @@ -0,0 +1,17 @@ +{ + "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" + } + } + } +} \ 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..bd3ae17ae7438 --- /dev/null +++ b/homeassistant/components/dialogflow/translations/sv.json @@ -0,0 +1,17 @@ +{ + "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" + } + } + } +} \ 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..8ae8cb6f78e54 --- /dev/null +++ b/homeassistant/components/dialogflow/translations/zh-Hans.json @@ -0,0 +1,17 @@ +{ + "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" + } + } + } +} \ 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..a9c9316e60033 --- /dev/null +++ b/homeassistant/components/dialogflow/translations/zh-Hant.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Home Assistant \u7269\u4ef6\u5fc5\u9808\u80fd\u5920\u7531\u7db2\u969b\u7db2\u8def\u5b58\u53d6\uff0c\u65b9\u80fd\u63a5\u53d7 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" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/digital_ocean/__init__.py b/homeassistant/components/digital_ocean/__init__.py index 9e034b2428dda..9ae61ed9b8491 100644 --- a/homeassistant/components/digital_ocean/__init__.py +++ b/homeassistant/components/digital_ocean/__init__.py @@ -1,48 +1,47 @@ """Support for Digital Ocean.""" -import logging from datetime import timedelta +import logging +import digitalocean import voluptuous as vol from homeassistant.const import CONF_ACCESS_TOKEN -from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv +from homeassistant.util import Throttle _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' +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' +ATTRIBUTION = "Data provided by Digital Ocean" -CONF_DROPLETS = 'droplets' +CONF_DROPLETS = "droplets" -DATA_DIGITAL_OCEAN = 'data_do' -DIGITAL_OCEAN_PLATFORMS = ['switch', 'binary_sensor'] -DOMAIN = 'digital_ocean' +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) +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) + access_token = conf[CONF_ACCESS_TOKEN] digital = DigitalOcean(access_token) @@ -64,7 +63,6 @@ class DigitalOcean: def __init__(self, access_token): """Initialize the Digital Ocean connection.""" - import digitalocean self._access_token = access_token self.data = None diff --git a/homeassistant/components/digital_ocean/binary_sensor.py b/homeassistant/components/digital_ocean/binary_sensor.py index 83406247a07e9..d076dae9210f2 100644 --- a/homeassistant/components/digital_ocean/binary_sensor.py +++ b/homeassistant/components/digital_ocean/binary_sensor.py @@ -3,23 +3,32 @@ import voluptuous as vol -from homeassistant.components.binary_sensor import ( - PLATFORM_SCHEMA, BinarySensorDevice) +from homeassistant.components.binary_sensor import PLATFORM_SCHEMA, BinarySensorEntity 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) + 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]), -}) +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): @@ -28,7 +37,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): if not digital: return False - droplets = config.get(CONF_DROPLETS) + droplets = config[CONF_DROPLETS] dev = [] for droplet in droplets: @@ -41,7 +50,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): add_entities(dev, True) -class DigitalOceanBinarySensor(BinarySensorDevice): +class DigitalOceanBinarySensor(BinarySensorEntity): """Representation of a Digital Ocean droplet sensor.""" def __init__(self, do, droplet_id): @@ -59,7 +68,7 @@ def name(self): @property def is_on(self): """Return true if the binary sensor is on.""" - return self.data.status == 'active' + return self.data.status == "active" @property def device_class(self): @@ -78,7 +87,7 @@ def device_state_attributes(self): 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_REGION: self.data.region["name"], ATTR_VCPUS: self.data.vcpus, } diff --git a/homeassistant/components/digital_ocean/manifest.json b/homeassistant/components/digital_ocean/manifest.json index 2ef940f60bd96..217803ef19572 100644 --- a/homeassistant/components/digital_ocean/manifest.json +++ b/homeassistant/components/digital_ocean/manifest.json @@ -1,12 +1,7 @@ { "domain": "digital_ocean", - "name": "Digital ocean", - "documentation": "https://www.home-assistant.io/components/digital_ocean", - "requirements": [ - "python-digitalocean==1.13.2" - ], - "dependencies": [], - "codeowners": [ - "@fabaff" - ] + "name": "Digital Ocean", + "documentation": "https://www.home-assistant.io/integrations/digital_ocean", + "requirements": ["python-digitalocean==1.13.2"], + "codeowners": ["@fabaff"] } diff --git a/homeassistant/components/digital_ocean/switch.py b/homeassistant/components/digital_ocean/switch.py index 8016ccef0ea86..811b844e35cdf 100644 --- a/homeassistant/components/digital_ocean/switch.py +++ b/homeassistant/components/digital_ocean/switch.py @@ -3,22 +3,32 @@ import voluptuous as vol -from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchDevice +from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchEntity 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) + 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_NAME = "Droplet" -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_DROPLETS): vol.All(cv.ensure_list, [cv.string]), -}) +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): @@ -27,7 +37,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): if not digital: return False - droplets = config.get(CONF_DROPLETS) + droplets = config[CONF_DROPLETS] dev = [] for droplet in droplets: @@ -40,7 +50,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): add_entities(dev, True) -class DigitalOceanSwitch(SwitchDevice): +class DigitalOceanSwitch(SwitchEntity): """Representation of a Digital Ocean droplet switch.""" def __init__(self, do, droplet_id): @@ -58,7 +68,7 @@ def name(self): @property def is_on(self): """Return true if switch is on.""" - return self.data.status == 'active' + return self.data.status == "active" @property def device_state_attributes(self): @@ -72,18 +82,18 @@ def device_state_attributes(self): 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_REGION: self.data.region["name"], ATTR_VCPUS: self.data.vcpus, } def turn_on(self, **kwargs): """Boot-up the droplet.""" - if self.data.status != 'active': + if self.data.status != "active": self.data.power_on() def turn_off(self, **kwargs): """Shutdown the droplet.""" - if self.data.status == 'active': + if self.data.status == "active": self.data.power_off() def update(self): diff --git a/homeassistant/components/digitalloggers/manifest.json b/homeassistant/components/digitalloggers/manifest.json index 990b39b21a5fd..9e6bd5b7e5f93 100644 --- a/homeassistant/components/digitalloggers/manifest.json +++ b/homeassistant/components/digitalloggers/manifest.json @@ -1,10 +1,7 @@ { "domain": "digitalloggers", - "name": "Digitalloggers", - "documentation": "https://www.home-assistant.io/components/digitalloggers", - "requirements": [ - "dlipower==0.7.165" - ], - "dependencies": [], + "name": "Digital Loggers", + "documentation": "https://www.home-assistant.io/integrations/digitalloggers", + "requirements": ["dlipower==0.7.165"], "codeowners": [] } diff --git a/homeassistant/components/digitalloggers/switch.py b/homeassistant/components/digitalloggers/switch.py index 4d1a87c44f90f..7448b9fbcf384 100644 --- a/homeassistant/components/digitalloggers/switch.py +++ b/homeassistant/components/digitalloggers/switch.py @@ -1,53 +1,61 @@ """Support for Digital Loggers DIN III Relays.""" -import logging from datetime import timedelta +import logging +import dlipower import voluptuous as vol -from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA) +from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchEntity from homeassistant.const import ( - CONF_HOST, CONF_NAME, CONF_USERNAME, CONF_PASSWORD, CONF_TIMEOUT) + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_TIMEOUT, + CONF_USERNAME, +) import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle _LOGGER = logging.getLogger(__name__) -CONF_CYCLETIME = 'cycletime' +CONF_CYCLETIME = "cycletime" -DEFAULT_NAME = 'DINRelay' -DEFAULT_USERNAME = 'admin' -DEFAULT_PASSWORD = 'admin' +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)), -}) +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) + host = config[CONF_HOST] + controller_name = config[CONF_NAME] + user = config[CONF_USERNAME] + pswd = config[CONF_PASSWORD] + tout = config[CONF_TIMEOUT] + cycl = config[CONF_CYCLETIME] power_switch = dlipower.PowerSwitch( - hostname=host, userid=user, password=pswd, - timeout=tout, cycletime=cycl + hostname=host, userid=user, password=pswd, timeout=tout, cycletime=cycl ) if not power_switch.verify(): @@ -58,14 +66,13 @@ def setup_platform(hass, config, add_entities, discovery_info=None): parent_device = DINRelayDevice(power_switch) outlets.extend( - DINRelay(controller_name, parent_device, outlet) - for outlet in power_switch[0:] + DINRelay(controller_name, parent_device, outlet) for outlet in power_switch[0:] ) add_entities(outlets) -class DINRelay(SwitchDevice): +class DINRelay(SwitchEntity): """Representation of an individual DIN III relay port.""" def __init__(self, controller_name, parent_device, outlet): @@ -76,15 +83,12 @@ def __init__(self, controller_name, parent_device, outlet): self._outlet_number = self._outlet.outlet_number self._name = self._outlet.description - self._state = self._outlet.state == 'ON' + self._state = self._outlet.state == "ON" @property def name(self): """Return the display name of this relay.""" - return '{}_{}'.format( - self._controller_name, - self._name - ) + return f"{self._controller_name}_{self._name}" @property def is_on(self): @@ -108,11 +112,10 @@ 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) + outlet_status = self._parent_device.get_outlet_status(self._outlet_number) self._name = outlet_status[1] - self._state = outlet_status[2] == 'ON' + self._state = outlet_status[2] == "ON" class DINRelayDevice: diff --git a/homeassistant/components/directv/__init__.py b/homeassistant/components/directv/__init__.py index 5934e1b6c5129..677487945be87 100644 --- a/homeassistant/components/directv/__init__.py +++ b/homeassistant/components/directv/__init__.py @@ -1 +1,116 @@ -"""The directv component.""" +"""The DirecTV integration.""" +import asyncio +from datetime import timedelta +from typing import Any, Dict + +from directv import DIRECTV, DIRECTVError +import voluptuous as vol + +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import ATTR_NAME, CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.entity import Entity + +from .const import ( + ATTR_IDENTIFIERS, + ATTR_MANUFACTURER, + ATTR_MODEL, + ATTR_SOFTWARE_VERSION, + ATTR_VIA_DEVICE, + DOMAIN, +) + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.All( + cv.ensure_list, [vol.Schema({vol.Required(CONF_HOST): cv.string})] + ) + }, + extra=vol.ALLOW_EXTRA, +) + +PLATFORMS = ["media_player", "remote"] +SCAN_INTERVAL = timedelta(seconds=30) + + +async def async_setup(hass: HomeAssistant, config: Dict) -> bool: + """Set up the DirecTV component.""" + hass.data.setdefault(DOMAIN, {}) + + if DOMAIN in config: + for entry_config in config[DOMAIN]: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=entry_config, + ) + ) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up DirecTV from a config entry.""" + dtv = DIRECTV(entry.data[CONF_HOST], session=async_get_clientsession(hass)) + + try: + await dtv.update() + except DIRECTVError: + raise ConfigEntryNotReady + + hass.data[DOMAIN][entry.entry_id] = dtv + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ] + ) + ) + + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +class DIRECTVEntity(Entity): + """Defines a base DirecTV entity.""" + + def __init__(self, *, dtv: DIRECTV, name: str, address: str = "0") -> None: + """Initialize the DirecTV entity.""" + self._address = address + self._device_id = address if address != "0" else dtv.device.info.receiver_id + self._is_client = address != "0" + self._name = name + self.dtv = dtv + + @property + def name(self) -> str: + """Return the name of the entity.""" + return self._name + + @property + def device_info(self) -> Dict[str, Any]: + """Return device information about this DirecTV receiver.""" + return { + ATTR_IDENTIFIERS: {(DOMAIN, self._device_id)}, + ATTR_NAME: self.name, + ATTR_MANUFACTURER: self.dtv.device.info.brand, + ATTR_MODEL: None, + ATTR_SOFTWARE_VERSION: self.dtv.device.info.version, + ATTR_VIA_DEVICE: (DOMAIN, self.dtv.device.info.receiver_id), + } diff --git a/homeassistant/components/directv/config_flow.py b/homeassistant/components/directv/config_flow.py new file mode 100644 index 0000000000000..406f2628ee47f --- /dev/null +++ b/homeassistant/components/directv/config_flow.py @@ -0,0 +1,133 @@ +"""Config flow for DirecTV.""" +import logging +from typing import Any, Dict, Optional +from urllib.parse import urlparse + +from directv import DIRECTV, DIRECTVError +import voluptuous as vol + +from homeassistant.components.ssdp import ATTR_SSDP_LOCATION, ATTR_UPNP_SERIAL +from homeassistant.config_entries import CONN_CLASS_LOCAL_POLL, ConfigFlow +from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.typing import ( + ConfigType, + DiscoveryInfoType, + HomeAssistantType, +) + +from .const import CONF_RECEIVER_ID +from .const import DOMAIN # pylint: disable=unused-import + +_LOGGER = logging.getLogger(__name__) + +ERROR_CANNOT_CONNECT = "cannot_connect" +ERROR_UNKNOWN = "unknown" + + +async def validate_input(hass: HomeAssistantType, data: dict) -> Dict[str, Any]: + """Validate the user input allows us to connect. + + Data has the keys from DATA_SCHEMA with values provided by the user. + """ + session = async_get_clientsession(hass) + directv = DIRECTV(data[CONF_HOST], session=session) + device = await directv.update() + + return {CONF_RECEIVER_ID: device.info.receiver_id} + + +class DirecTVConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for DirecTV.""" + + VERSION = 1 + CONNECTION_CLASS = CONN_CLASS_LOCAL_POLL + + def __init__(self): + """Set up the instance.""" + self.discovery_info = {} + + async def async_step_import( + self, user_input: Optional[ConfigType] = None + ) -> Dict[str, Any]: + """Handle a flow initiated by configuration file.""" + return await self.async_step_user(user_input) + + async def async_step_user( + self, user_input: Optional[ConfigType] = None + ) -> Dict[str, Any]: + """Handle a flow initiated by the user.""" + if user_input is None: + return self._show_setup_form() + + try: + info = await validate_input(self.hass, user_input) + except DIRECTVError: + return self._show_setup_form({"base": ERROR_CANNOT_CONNECT}) + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + return self.async_abort(reason=ERROR_UNKNOWN) + + user_input[CONF_RECEIVER_ID] = info[CONF_RECEIVER_ID] + + await self.async_set_unique_id(user_input[CONF_RECEIVER_ID]) + self._abort_if_unique_id_configured(updates={CONF_HOST: user_input[CONF_HOST]}) + + return self.async_create_entry(title=user_input[CONF_HOST], data=user_input) + + async def async_step_ssdp( + self, discovery_info: DiscoveryInfoType + ) -> Dict[str, Any]: + """Handle SSDP discovery.""" + host = urlparse(discovery_info[ATTR_SSDP_LOCATION]).hostname + receiver_id = None + + if discovery_info.get(ATTR_UPNP_SERIAL): + receiver_id = discovery_info[ATTR_UPNP_SERIAL][4:] # strips off RID- + + # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 + self.context.update({"title_placeholders": {"name": host}}) + + self.discovery_info.update( + {CONF_HOST: host, CONF_NAME: host, CONF_RECEIVER_ID: receiver_id} + ) + + try: + info = await validate_input(self.hass, self.discovery_info) + except DIRECTVError: + return self.async_abort(reason=ERROR_CANNOT_CONNECT) + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + return self.async_abort(reason=ERROR_UNKNOWN) + + self.discovery_info[CONF_RECEIVER_ID] = info[CONF_RECEIVER_ID] + + await self.async_set_unique_id(self.discovery_info[CONF_RECEIVER_ID]) + self._abort_if_unique_id_configured( + updates={CONF_HOST: self.discovery_info[CONF_HOST]} + ) + + return await self.async_step_ssdp_confirm() + + async def async_step_ssdp_confirm( + self, user_input: ConfigType = None + ) -> Dict[str, Any]: + """Handle a confirmation flow initiated by SSDP.""" + if user_input is None: + return self.async_show_form( + step_id="ssdp_confirm", + description_placeholders={"name": self.discovery_info[CONF_NAME]}, + errors={}, + ) + + return self.async_create_entry( + title=self.discovery_info[CONF_NAME], data=self.discovery_info, + ) + + def _show_setup_form(self, errors: Optional[Dict] = None) -> Dict[str, Any]: + """Show the setup form to the user.""" + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({vol.Required(CONF_HOST): str}), + errors=errors or {}, + ) diff --git a/homeassistant/components/directv/const.py b/homeassistant/components/directv/const.py new file mode 100644 index 0000000000000..9ad01a0179b57 --- /dev/null +++ b/homeassistant/components/directv/const.py @@ -0,0 +1,20 @@ +"""Constants for the DirecTV integration.""" + +DOMAIN = "directv" + +# Attributes +ATTR_IDENTIFIERS = "identifiers" +ATTR_MANUFACTURER = "manufacturer" +ATTR_MEDIA_CURRENTLY_RECORDING = "media_currently_recording" +ATTR_MEDIA_RATING = "media_rating" +ATTR_MEDIA_RECORDED = "media_recorded" +ATTR_MEDIA_START_TIME = "media_start_time" +ATTR_MODEL = "model" +ATTR_SOFTWARE_VERSION = "sw_version" +ATTR_VIA_DEVICE = "via_device" + +CONF_RECEIVER_ID = "receiver_id" + +DEFAULT_DEVICE = "0" +DEFAULT_NAME = "DirecTV Receiver" +DEFAULT_PORT = 8080 diff --git a/homeassistant/components/directv/manifest.json b/homeassistant/components/directv/manifest.json index 7dbe6122ac1e3..e4be9cc3e25dd 100644 --- a/homeassistant/components/directv/manifest.json +++ b/homeassistant/components/directv/manifest.json @@ -1,10 +1,15 @@ { "domain": "directv", - "name": "Directv", - "documentation": "https://www.home-assistant.io/components/directv", - "requirements": [ - "directpy==0.5" - ], - "dependencies": [], - "codeowners": [] + "name": "DirecTV", + "documentation": "https://www.home-assistant.io/integrations/directv", + "requirements": ["directv==0.3.0"], + "codeowners": ["@ctalkington"], + "quality_scale": "gold", + "config_flow": true, + "ssdp": [ + { + "manufacturer": "DIRECTV", + "deviceType": "urn:schemas-upnp-org:device:MediaServer:1" + } + ] } diff --git a/homeassistant/components/directv/media_player.py b/homeassistant/components/directv/media_player.py index aaffd44d57241..205503fe17f7d 100644 --- a/homeassistant/components/directv/media_player.py +++ b/homeassistant/components/directv/media_player.py @@ -1,208 +1,127 @@ """Support for the DirecTV receivers.""" import logging -import requests -import voluptuous as vol +from typing import Callable, List -from homeassistant.components.media_player import ( - MediaPlayerDevice, PLATFORM_SCHEMA) +from directv import DIRECTV + +from homeassistant.components.media_player import MediaPlayerEntity 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 + MEDIA_TYPE_CHANNEL, + MEDIA_TYPE_MOVIE, + MEDIA_TYPE_MUSIC, + 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.config_entries import ConfigEntry +from homeassistant.const import STATE_OFF, STATE_PAUSED, STATE_PLAYING +from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.util import dt as dt_util + +from . import DIRECTVEntity +from .const import ( + ATTR_MEDIA_CURRENTLY_RECORDING, + ATTR_MEDIA_RATING, + ATTR_MEDIA_RECORDED, + ATTR_MEDIA_START_TIME, + DOMAIN, +) _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): +KNOWN_MEDIA_TYPES = [MEDIA_TYPE_MOVIE, MEDIA_TYPE_MUSIC, MEDIA_TYPE_TVSHOW] + +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 +) + + +async def async_setup_entry( + hass: HomeAssistantType, + entry: ConfigEntry, + async_add_entities: Callable[[List, bool], None], +) -> bool: + """Set up the DirecTV config entry.""" + dtv = hass.data[DOMAIN][entry.entry_id] + entities = [] + + for location in dtv.device.locations: + entities.append( + DIRECTVMediaPlayer( + dtv=dtv, name=str.title(location.name), address=location.address, + ) + ) + + async_add_entities(entities, True) + + +class DIRECTVMediaPlayer(DIRECTVEntity, MediaPlayerEntity): """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 + def __init__(self, *, dtv: DIRECTV, name: str, address: str = "0") -> None: + """Initialize DirecTV media player.""" + super().__init__( + dtv=dtv, name=name, address=address, + ) + + self._assumed_state = None + self._available = False + self._is_recorded = None self._is_standby = True - self._current = None + self._last_position = 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 + self._program = None + self._state = 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): + async def async_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 + self._state = await self.dtv.state(self._address) + self._available = self._state.available + self._is_standby = self._state.standby + self._program = self._state.program - return self._available + if self._is_standby: + self._assumed_state = False + self._is_recorded = None + self._last_position = None + self._last_update = None + self._paused = None + elif self._program is not None: + self._paused = self._last_position == self._program.position + self._is_recorded = self._program.recorded + self._last_position = self._program.position + self._last_update = self._state.at + self._assumed_state = self._is_recorded @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_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 @@ -214,7 +133,15 @@ def name(self): """Return the name of the device.""" return self._name - # MediaPlayerDevice properties and methods + @property + def unique_id(self): + """Return a unique ID to use for this media player.""" + if self._address == "0": + return self.dtv.device.info.receiver_id + + return self._address + + # MediaPlayerEntity properties and methods @property def state(self): """Return the state of the device.""" @@ -242,29 +169,29 @@ def assumed_state(self): @property def media_content_id(self): """Return the content ID of current playing media.""" - if self._is_standby: + if self._is_standby or self._program is None: return None - return self._current['programId'] + return self._program.program_id @property def media_content_type(self): """Return the content type of current playing media.""" - if self._is_standby: + if self._is_standby or self._program is None: return None - if 'episodeTitle' in self._current: - return MEDIA_TYPE_TVSHOW + if self._program.program_type in KNOWN_MEDIA_TYPES: + return self._program.program_type return MEDIA_TYPE_MOVIE @property def media_duration(self): """Return the duration of current playing media in seconds.""" - if self._is_standby: + if self._is_standby or self._program is None: return None - return self._current['duration'] + return self._program.duration @property def media_position(self): @@ -276,10 +203,7 @@ def media_position(self): @property def media_position_updated_at(self): - """When was the position of the current playing media valid. - - Returns value from homeassistant.util.dt.utcnow(). - """ + """When was the position of the current playing media valid.""" if self._is_standby: return None @@ -288,35 +212,53 @@ def media_position_updated_at(self): @property def media_title(self): """Return the title of current playing media.""" - if self._is_standby: + if self._is_standby or self._program is None: + return None + + if self.media_content_type == MEDIA_TYPE_MUSIC: + return self._program.music_title + + return self._program.title + + @property + def media_artist(self): + """Artist of current playing media, music track only.""" + if self._is_standby or self._program is None: + return None + + return self._program.music_artist + + @property + def media_album_name(self): + """Album name of current playing media, music track only.""" + if self._is_standby or self._program is None: return None - return self._current['title'] + return self._program.music_album @property def media_series_title(self): """Return the title of current episode of TV show.""" - if self._is_standby: + if self._is_standby or self._program is None: return None - return self._current.get('episodeTitle') + return self._program.episode_title @property def media_channel(self): """Return the channel current playing media.""" - if self._is_standby: + if self._is_standby or self._program is None: return None - return "{} ({})".format( - self._current['callsign'], self._current['major']) + return f"{self._program.channel_name} ({self._program.channel})" @property def source(self): """Name of the current input source.""" - if self._is_standby: + if self._is_standby or self._program is None: return None - return self._current['major'] + return self._program.channel @property def supported_features(self): @@ -326,18 +268,18 @@ def supported_features(self): @property def media_currently_recording(self): """If the media is currently being recorded or not.""" - if self._is_standby: + if self._is_standby or self._program is None: return None - return self._current['isRecording'] + return self._program.recording @property def media_rating(self): """TV Rating of the current playing media.""" - if self._is_standby: + if self._is_standby or self._program is None: return None - return self._current['rating'] + return self._program.rating @property def media_recorded(self): @@ -350,59 +292,61 @@ def media_recorded(self): @property def media_start_time(self): """Start time the program aired.""" - if self._is_standby: + if self._is_standby or self._program is None: return None - return dt_util.as_local( - dt_util.utc_from_timestamp(self._current['startTime'])) + return dt_util.as_local(self._program.start_time) - def turn_on(self): + async def async_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') + await self.dtv.remote("poweron", self._address) - def turn_off(self): + async def async_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') + await self.dtv.remote("poweroff", self._address) - def media_play(self): + async def async_media_play(self): """Send play command.""" _LOGGER.debug("Play on %s", self._name) - self.dtv.key_press('play') + await self.dtv.remote("play", self._address) - def media_pause(self): + async def async_media_pause(self): """Send pause command.""" _LOGGER.debug("Pause on %s", self._name) - self.dtv.key_press('pause') + await self.dtv.remote("pause", self._address) - def media_stop(self): + async def async_media_stop(self): """Send stop command.""" _LOGGER.debug("Stop on %s", self._name) - self.dtv.key_press('stop') + await self.dtv.remote("stop", self._address) - def media_previous_track(self): + async def async_media_previous_track(self): """Send rewind command.""" _LOGGER.debug("Rewind on %s", self._name) - self.dtv.key_press('rew') + await self.dtv.remote("rew", self._address) - def media_next_track(self): + async def async_media_next_track(self): """Send fast forward command.""" _LOGGER.debug("Fast forward on %s", self._name) - self.dtv.key_press('ffwd') + await self.dtv.remote("ffwd", self._address) - def play_media(self, media_type, media_id, **kwargs): + async def async_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) + _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) + await self.dtv.tune(media_id, self._address) diff --git a/homeassistant/components/directv/remote.py b/homeassistant/components/directv/remote.py new file mode 100644 index 0000000000000..9665b0aea17fa --- /dev/null +++ b/homeassistant/components/directv/remote.py @@ -0,0 +1,109 @@ +"""Support for the DIRECTV remote.""" +from datetime import timedelta +import logging +from typing import Any, Callable, Iterable, List + +from directv import DIRECTV, DIRECTVError + +from homeassistant.components.remote import ATTR_NUM_REPEATS, RemoteEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.typing import HomeAssistantType + +from . import DIRECTVEntity +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(minutes=2) + + +async def async_setup_entry( + hass: HomeAssistantType, + entry: ConfigEntry, + async_add_entities: Callable[[List, bool], None], +) -> bool: + """Load DirecTV remote based on a config entry.""" + dtv = hass.data[DOMAIN][entry.entry_id] + entities = [] + + for location in dtv.device.locations: + entities.append( + DIRECTVRemote( + dtv=dtv, name=str.title(location.name), address=location.address, + ) + ) + + async_add_entities(entities, True) + + +class DIRECTVRemote(DIRECTVEntity, RemoteEntity): + """Device that sends commands to a DirecTV receiver.""" + + def __init__(self, *, dtv: DIRECTV, name: str, address: str = "0") -> None: + """Initialize DirecTV remote.""" + super().__init__( + dtv=dtv, name=name, address=address, + ) + + self._available = False + self._is_on = True + + @property + def available(self): + """Return if able to retrieve information from device or not.""" + return self._available + + @property + def unique_id(self): + """Return a unique ID.""" + if self._address == "0": + return self.dtv.device.info.receiver_id + + return self._address + + @property + def is_on(self) -> bool: + """Return True if entity is on.""" + return self._is_on + + async def async_update(self) -> None: + """Update device state.""" + status = await self.dtv.status(self._address) + + if status in ("active", "standby"): + self._available = True + self._is_on = status == "active" + else: + self._available = False + self._is_on = False + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the device on.""" + await self.dtv.remote("poweron", self._address) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the device off.""" + await self.dtv.remote("poweroff", self._address) + + async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> None: + """Send a command to a device. + + Supported keys: power, poweron, poweroff, format, + pause, rew, replay, stop, advance, ffwd, record, + play, guide, active, list, exit, back, menu, info, + up, down, left, right, select, red, green, yellow, + blue, chanup, chandown, prev, 0, 1, 2, 3, 4, 5, + 6, 7, 8, 9, dash, enter + """ + num_repeats = kwargs[ATTR_NUM_REPEATS] + + for _ in range(num_repeats): + for single_command in command: + try: + await self.dtv.remote(single_command, self._address) + except DIRECTVError: + _LOGGER.exception( + "Sending command %s to device %s failed", + single_command, + self._device_id, + ) diff --git a/homeassistant/components/directv/strings.json b/homeassistant/components/directv/strings.json new file mode 100644 index 0000000000000..24b97165513c6 --- /dev/null +++ b/homeassistant/components/directv/strings.json @@ -0,0 +1,19 @@ +{ + "config": { + "flow_title": "DirecTV: {name}", + "step": { + "ssdp_confirm": { + "data": {}, + "description": "Do you want to set up {name}?" + }, + "user": { + "data": { "host": "Host or IP address" } + } + }, + "error": { "cannot_connect": "Failed to connect, please try again" }, + "abort": { + "already_configured": "DirecTV receiver is already configured", + "unknown": "Unexpected error" + } + } +} diff --git a/homeassistant/components/directv/translations/ca.json b/homeassistant/components/directv/translations/ca.json new file mode 100644 index 0000000000000..98156c3c701d2 --- /dev/null +++ b/homeassistant/components/directv/translations/ca.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "El receptor DirecTV ja est\u00e0 configurat", + "unknown": "Error inesperat" + }, + "error": { + "cannot_connect": "No s'ha pogut connectar, torna-ho a provar" + }, + "flow_title": "DirecTV: {name}", + "step": { + "ssdp_confirm": { + "description": "Vols configurar {name}?", + "title": "Connexi\u00f3 amb el receptor DirecTV" + }, + "user": { + "data": { + "host": "Amfitri\u00f3 o adre\u00e7a IP" + }, + "title": "Connexi\u00f3 amb el receptor DirecTV" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/directv/translations/de.json b/homeassistant/components/directv/translations/de.json new file mode 100644 index 0000000000000..0b3fa8f29e85a --- /dev/null +++ b/homeassistant/components/directv/translations/de.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "Der DirecTV-Empf\u00e4nger ist bereits konfiguriert", + "unknown": "Unerwarteter Fehler" + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen, versuchen Sie es erneut" + }, + "flow_title": "DirecTV: {name}", + "step": { + "ssdp_confirm": { + "data": { + "one": "eins", + "other": "andere" + }, + "description": "M\u00f6chten Sie {name} einrichten?", + "title": "Stellen Sie eine Verbindung zum DirecTV-Empf\u00e4nger her" + }, + "user": { + "data": { + "host": "Host oder IP-Adresse" + }, + "title": "Schlie\u00dfen Sie den DirecTV-Empf\u00e4nger an" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/directv/translations/en.json b/homeassistant/components/directv/translations/en.json new file mode 100644 index 0000000000000..e271497ae3413 --- /dev/null +++ b/homeassistant/components/directv/translations/en.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "DirecTV receiver is already configured", + "unknown": "Unexpected error" + }, + "error": { + "cannot_connect": "Failed to connect, please try again" + }, + "flow_title": "DirecTV: {name}", + "step": { + "ssdp_confirm": { + "description": "Do you want to set up {name}?", + "title": "Connect to the DirecTV receiver" + }, + "user": { + "data": { + "host": "Host or IP address" + }, + "title": "Connect to the DirecTV receiver" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/directv/translations/es.json b/homeassistant/components/directv/translations/es.json new file mode 100644 index 0000000000000..a69cc9c32dd78 --- /dev/null +++ b/homeassistant/components/directv/translations/es.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "El receptor DirecTV ya est\u00e1 configurado", + "unknown": "Error inesperado" + }, + "error": { + "cannot_connect": "No se ha podido conectar, por favor, int\u00e9ntalo de nuevo." + }, + "flow_title": "DirecTV: {name}", + "step": { + "ssdp_confirm": { + "description": "\u00bfQuieres configurar {name}?", + "title": "Conectar con el receptor DirecTV" + }, + "user": { + "data": { + "host": "Host o direcci\u00f3n IP" + }, + "title": "Conectar con el receptor DirecTV" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/directv/translations/fr.json b/homeassistant/components/directv/translations/fr.json new file mode 100644 index 0000000000000..d165476de662d --- /dev/null +++ b/homeassistant/components/directv/translations/fr.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "Le r\u00e9cepteur DirecTV est d\u00e9j\u00e0 configur\u00e9", + "unknown": "Erreur inattendue" + }, + "error": { + "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer" + }, + "flow_title": "DirecTV: {name}", + "step": { + "ssdp_confirm": { + "description": "Voulez-vous configurer {name} ?", + "title": "Connectez-vous au r\u00e9cepteur DirecTV" + }, + "user": { + "data": { + "host": "H\u00f4te ou adresse IP" + }, + "title": "Connectez-vous au r\u00e9cepteur DirecTV" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/directv/translations/it.json b/homeassistant/components/directv/translations/it.json new file mode 100644 index 0000000000000..3c740c8966cb8 --- /dev/null +++ b/homeassistant/components/directv/translations/it.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "Il ricevitore DirecTV \u00e8 gi\u00e0 configurato", + "unknown": "Errore imprevisto" + }, + "error": { + "cannot_connect": "Impossibile connettersi, si prega di riprovare" + }, + "flow_title": "DirecTV: {name}", + "step": { + "ssdp_confirm": { + "data": { + "one": "uno", + "other": "altri" + }, + "description": "Vuoi impostare {name} ?", + "title": "Connettersi al ricevitore DirecTV" + }, + "user": { + "data": { + "host": "Host o indirizzo IP" + }, + "title": "Collegamento al ricevitore DirecTV" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/directv/translations/ko.json b/homeassistant/components/directv/translations/ko.json new file mode 100644 index 0000000000000..8c3bbb94a8db3 --- /dev/null +++ b/homeassistant/components/directv/translations/ko.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "DirecTV \ub9ac\uc2dc\ubc84\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" + }, + "error": { + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694." + }, + "flow_title": "DirecTV: {name}", + "step": { + "ssdp_confirm": { + "description": "{name} \uc744(\ub97c) \uc124\uc815\ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?", + "title": "DirecTV \ub9ac\uc2dc\ubc84\uc5d0 \uc5f0\uacb0\ud558\uae30" + }, + "user": { + "data": { + "host": "\ud638\uc2a4\ud2b8 \ub610\ub294 IP \uc8fc\uc18c" + }, + "title": "DirecTV \ub9ac\uc2dc\ubc84\uc5d0 \uc5f0\uacb0\ud558\uae30" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/directv/translations/lb.json b/homeassistant/components/directv/translations/lb.json new file mode 100644 index 0000000000000..1fb8b72cde820 --- /dev/null +++ b/homeassistant/components/directv/translations/lb.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "DirecTV ass scho konfigur\u00e9iert", + "unknown": "Onerwaarte Feeler" + }, + "error": { + "cannot_connect": "Feeler beim verbannen, prob\u00e9ier w.e.g. nach emol." + }, + "flow_title": "DirecTV: {name}", + "step": { + "ssdp_confirm": { + "data": { + "one": "Een", + "other": "Aner" + }, + "description": "Soll {name} konfigur\u00e9iert ginn?", + "title": "Mam DirecTV Receiver verbannen" + }, + "user": { + "data": { + "host": "Numm oder IP Adresse" + }, + "title": "Mam DirecTV Receiver verbannen" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/directv/translations/nl.json b/homeassistant/components/directv/translations/nl.json new file mode 100644 index 0000000000000..b6635311064bb --- /dev/null +++ b/homeassistant/components/directv/translations/nl.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "DirecTV-ontvanger is al geconfigureerd", + "unknown": "Onverwachte fout" + }, + "error": { + "cannot_connect": "Verbinding mislukt, probeer het opnieuw" + }, + "flow_title": "DirecTV: {name}", + "step": { + "ssdp_confirm": { + "description": "Wilt u {name} instellen?", + "title": "Maak verbinding met de DirecTV-ontvanger" + }, + "user": { + "data": { + "host": "Host- of IP-adres" + }, + "title": "Maak verbinding met de DirecTV-ontvanger" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/directv/translations/no.json b/homeassistant/components/directv/translations/no.json new file mode 100644 index 0000000000000..9e0906ea2ac6a --- /dev/null +++ b/homeassistant/components/directv/translations/no.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "DirecTV-mottaker er allerede konfigurert", + "unknown": "Uventet feil" + }, + "error": { + "cannot_connect": "Klarte ikke \u00e5 koble til, vennligst pr\u00f8v igjen" + }, + "flow_title": "", + "step": { + "ssdp_confirm": { + "description": "Vil du sette opp {name} ?", + "title": "Koble til DirecTV-mottakeren" + }, + "user": { + "data": { + "host": "Vert eller IP-adresse" + }, + "title": "Koble til DirecTV-mottakeren" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/directv/translations/pl.json b/homeassistant/components/directv/translations/pl.json new file mode 100644 index 0000000000000..23010c90c1f94 --- /dev/null +++ b/homeassistant/components/directv/translations/pl.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "Odbiornik DirecTV jest ju\u017c skonfigurowany.", + "unknown": "Niespodziewany b\u0142\u0105d." + }, + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia, spr\u00f3buj ponownie." + }, + "flow_title": "DirecTV: {name}", + "step": { + "ssdp_confirm": { + "data": { + "few": "kilka", + "many": "wiele", + "one": "jeden", + "other": "inne" + }, + "description": "Czy chcesz skonfigurowa\u0107 {name}?", + "title": "Po\u0142\u0105czenie z odbiornikiem DirecTV" + }, + "user": { + "data": { + "host": "Nazwa hosta lub adres IP" + }, + "title": "Po\u0142\u0105czenie z odbiornikiem DirecTV" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/directv/translations/ru.json b/homeassistant/components/directv/translations/ru.json new file mode 100644 index 0000000000000..a45380994802d --- /dev/null +++ b/homeassistant/components/directv/translations/ru.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u044d\u0442\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f, \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0435\u0449\u0435 \u0440\u0430\u0437." + }, + "flow_title": "DirecTV: {name}", + "step": { + "ssdp_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 {name}?", + "title": "DirecTV" + }, + "user": { + "data": { + "host": "\u0414\u043e\u043c\u0435\u043d\u043d\u043e\u0435 \u0438\u043c\u044f \u0438\u043b\u0438 IP-\u0430\u0434\u0440\u0435\u0441" + }, + "title": "DirecTV" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/directv/translations/sl.json b/homeassistant/components/directv/translations/sl.json new file mode 100644 index 0000000000000..19387c68db05f --- /dev/null +++ b/homeassistant/components/directv/translations/sl.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "Sprejemnik DirecTV je \u017ee konfiguriran", + "unknown": "Nepri\u010dakovana napaka" + }, + "error": { + "cannot_connect": "Povezava ni uspela, poskusite znova" + }, + "flow_title": "DirecTV: {name}", + "step": { + "ssdp_confirm": { + "data": { + "few": "nekaj", + "one": "ena", + "other": "drugo", + "two": "dva" + }, + "description": "Ali \u017eelite nastaviti {name} ?", + "title": "Pove\u017eite se s sprejemnikom DirecTV" + }, + "user": { + "data": { + "host": "Gostitelj ali IP naslov" + }, + "title": "Pove\u017eite se s sprejemnikom DirecTV" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/directv/translations/sv.json b/homeassistant/components/directv/translations/sv.json new file mode 100644 index 0000000000000..c42c03d9944c4 --- /dev/null +++ b/homeassistant/components/directv/translations/sv.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "unknown": "Ov\u00e4ntat fel" + }, + "error": { + "cannot_connect": "Det gick inte att ansluta, f\u00f6rs\u00f6k igen" + }, + "step": { + "ssdp_confirm": { + "description": "Do vill du konfigurera {name}?" + }, + "user": { + "data": { + "host": "V\u00e4rd eller IP-adress" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/directv/translations/zh-Hant.json b/homeassistant/components/directv/translations/zh-Hant.json new file mode 100644 index 0000000000000..6546dafc133a7 --- /dev/null +++ b/homeassistant/components/directv/translations/zh-Hant.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "DirectTV \u63a5\u6536\u5668\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557\uff0c\u8acb\u518d\u8a66\u4e00\u6b21" + }, + "flow_title": "DirecTV\uff1a{name}", + "step": { + "ssdp_confirm": { + "description": "\u662f\u5426\u8981\u8a2d\u5b9a {name}\uff1f", + "title": "\u9023\u7dda\u81f3 DirecTV \u63a5\u6536\u5668" + }, + "user": { + "data": { + "host": "\u4e3b\u6a5f\u6216 IP \u4f4d\u5740" + }, + "title": "\u9023\u7dda\u81f3 DirecTV \u63a5\u6536\u5668" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/discogs/manifest.json b/homeassistant/components/discogs/manifest.json index ca304bce88bcf..53dc30d6b3926 100644 --- a/homeassistant/components/discogs/manifest.json +++ b/homeassistant/components/discogs/manifest.json @@ -1,12 +1,7 @@ { "domain": "discogs", "name": "Discogs", - "documentation": "https://www.home-assistant.io/components/discogs", - "requirements": [ - "discogs_client==2.2.1" - ], - "dependencies": [], - "codeowners": [ - "@thibmaek" - ] + "documentation": "https://www.home-assistant.io/integrations/discogs", + "requirements": ["discogs_client==2.2.2"], + "codeowners": ["@thibmaek"] } diff --git a/homeassistant/components/discogs/sensor.py b/homeassistant/components/discogs/sensor.py index f9f821668f9d5..40f27135be137 100644 --- a/homeassistant/components/discogs/sensor.py +++ b/homeassistant/components/discogs/sensor.py @@ -3,82 +3,87 @@ import logging import random +import discogs_client import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - ATTR_ATTRIBUTION, CONF_MONITORED_CONDITIONS, CONF_NAME, CONF_TOKEN) + 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' +ATTR_IDENTITY = "identity" ATTRIBUTION = "Data provided by Discogs" -DEFAULT_NAME = 'Discogs' +DEFAULT_NAME = "Discogs" -ICON_RECORD = 'mdi:album' -ICON_PLAYER = 'mdi:record-player' -UNIT_RECORDS = 'records' +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' +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 + "name": "Collection", + "icon": ICON_RECORD, + "unit_of_measurement": UNIT_RECORDS, }, SENSOR_WANTLIST_TYPE: { - 'name': 'Wantlist', - 'icon': ICON_RECORD, - 'unit_of_measurement': UNIT_RECORDS + "name": "Wantlist", + "icon": ICON_RECORD, + "unit_of_measurement": UNIT_RECORDS, }, SENSOR_RANDOM_RECORD_TYPE: { - 'name': 'Random Record', - 'icon': ICON_PLAYER, - 'unit_of_measurement': None + "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)]) -}) +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_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 + "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): + for sensor_type in config[CONF_MONITORED_CONDITIONS]: sensors.append(DiscogsSensor(discogs_data, name, sensor_type)) add_entities(sensors, True) @@ -98,7 +103,7 @@ def __init__(self, discogs_data, name, sensor_type): @property def name(self): """Return the name of the sensor.""" - return "{} {}".format(self._name, SENSORS[self._type]['name']) + return f"{self._name} {SENSORS[self._type]['name']}" @property def state(self): @@ -108,12 +113,12 @@ def state(self): @property def icon(self): """Return the icon to use in the frontend, if any.""" - return SENSORS[self._type]['icon'] + 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'] + return SENSORS[self._type]["unit_of_measurement"] @property def device_state_attributes(self): @@ -124,38 +129,34 @@ def device_state_attributes(self): if self._type != SENSOR_RANDOM_RECORD_TYPE: return { ATTR_ATTRIBUTION: ATTRIBUTION, - ATTR_IDENTITY: self._discogs_data['user'], + 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'], + "cat_no": self._attrs["labels"][0]["catno"], + "cover_image": self._attrs["cover_image"], + "format": f"{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'], + 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] + 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']) + return f"{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'] + self._state = self._discogs_data["collection_count"] elif self._type == SENSOR_WANTLIST_TYPE: - self._state = self._discogs_data['wantlist_count'] + 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 index a3cd87bc895f6..67b9f1b39ba30 100644 --- a/homeassistant/components/discord/__init__.py +++ b/homeassistant/components/discord/__init__.py @@ -1 +1 @@ -"""The discord component.""" +"""The discord integration.""" diff --git a/homeassistant/components/discord/manifest.json b/homeassistant/components/discord/manifest.json index 05b2a3c8e0621..7f03e52ca626d 100644 --- a/homeassistant/components/discord/manifest.json +++ b/homeassistant/components/discord/manifest.json @@ -1,10 +1,7 @@ { "domain": "discord", "name": "Discord", - "documentation": "https://www.home-assistant.io/components/discord", - "requirements": [ - "discord.py==1.0.1" - ], - "dependencies": [], + "documentation": "https://www.home-assistant.io/integrations/discord", + "requirements": ["discord.py==1.3.3"], "codeowners": [] } diff --git a/homeassistant/components/discord/notify.py b/homeassistant/components/discord/notify.py index 5a9cb77877dc0..11f83d80179a4 100644 --- a/homeassistant/components/discord/notify.py +++ b/homeassistant/components/discord/notify.py @@ -2,27 +2,28 @@ import logging import os.path +import discord import voluptuous as vol +from homeassistant.components.notify import ( + ATTR_DATA, + ATTR_TARGET, + PLATFORM_SCHEMA, + BaseNotificationService, +) 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 -}) +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({vol.Required(CONF_TOKEN): cv.string}) -ATTR_IMAGES = 'images' +ATTR_IMAGES = "images" def get_service(hass, config, discovery_info=None): """Get the Discord notification service.""" - token = config.get(CONF_TOKEN) + token = config[CONF_TOKEN] return DiscordNotificationService(hass, token) @@ -38,36 +39,32 @@ 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(loop=self.hass.loop) + 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_DATA in kwargs: - data = kwargs.get(ATTR_DATA) - - if ATTR_IMAGES in data: - images = list() + if ATTR_IMAGES in data: + images = [] - for image in data.get(ATTR_IMAGES): - image_exists = await self.hass.async_add_executor_job( - self.file_exists, - image) + 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) + if image_exists: + images.append(image) + else: + _LOGGER.warning("Image not found: %s", image) # pylint: disable=unused-variable @discord_bot.event @@ -76,24 +73,21 @@ async def on_ready(): try: for channelid in kwargs[ATTR_TARGET]: channelid = int(channelid) - channel = discord_bot.get_channel(channelid) + channel = discord_bot.get_channel( + channelid + ) or discord_bot.get_user(channelid) if channel is None: - _LOGGER.warning( - "Channel not found for id: %s", - channelid) + _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() + files = [] for image in images: files.append(discord.File(image)) - await channel.send(message, files=files) - except (discord.errors.HTTPException, - discord.errors.NotFound) as error: + except (discord.errors.HTTPException, discord.errors.NotFound) as error: _LOGGER.warning("Communication error: %s", error) await discord_bot.logout() await discord_bot.close() diff --git a/homeassistant/components/discovery/__init__.py b/homeassistant/components/discovery/__init__.py index 900cbda74d403..227995db971c8 100644 --- a/homeassistant/components/discovery/__init__.py +++ b/homeassistant/components/discovery/__init__.py @@ -6,132 +6,130 @@ 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 json import logging +from netdisco.discovery import NetworkDiscovery import voluptuous as vol from homeassistant import config_entries -from homeassistant.core import callback from homeassistant.const import EVENT_HOMEASSISTANT_START +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.discovery import async_discover, async_load_platform 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' +DOMAIN = "discovery" SCAN_INTERVAL = timedelta(seconds=300) -SERVICE_APPLE_TV = 'apple_tv' -SERVICE_AXIS = 'axis' -SERVICE_DAIKIN = 'daikin' -SERVICE_DECONZ = 'deconz' -SERVICE_DLNA_DMR = 'dlna_dmr' -SERVICE_ENIGMA2 = 'enigma2' -SERVICE_FREEBOX = 'freebox' -SERVICE_HASS_IOS_APP = 'hass_ios' -SERVICE_HASSIO = 'hassio' -SERVICE_HOMEKIT = 'homekit' -SERVICE_HEOS = 'heos' -SERVICE_HUE = 'philips_hue' -SERVICE_IGD = 'igd' -SERVICE_IKEA_TRADFRI = 'ikea_tradfri' -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' +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_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_AXIS: 'axis', - SERVICE_DAIKIN: 'daikin', - SERVICE_DECONZ: 'deconz', - 'esphome': 'esphome', - 'google_cast': 'cast', - SERVICE_HEOS: 'heos', - SERVICE_HUE: 'hue', - SERVICE_TELLDUSLIVE: 'tellduslive', - SERVICE_IKEA_TRADFRI: 'tradfri', - 'sonos': 'sonos', - SERVICE_IGD: 'upnp', + SERVICE_DAIKIN: "daikin", + SERVICE_TELLDUSLIVE: "tellduslive", + SERVICE_IGD: "upnp", } SERVICE_HANDLERS = { - SERVICE_MOBILE_APP: ('mobile_app', None), - SERVICE_HASS_IOS_APP: ('ios', None), - SERVICE_NETGEAR: ('device_tracker', None), - SERVICE_WEMO: ('wemo', 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_HOMEKIT: ('homekit_controller', None), - SERVICE_DLNA_DMR: ('media_player', 'dlna_dmr'), + 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_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), + "yamaha": ("media_player", "yamaha"), + "logitech_mediaserver": ("media_player", "squeezebox"), + "denonavr": ("media_player", "denonavr"), + "frontier_silicon": ("media_player", "frontier_silicon"), + "openhome": ("media_player", "openhome"), + "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"), } -DEFAULT_ENABLED = list(CONFIG_ENTRY_HANDLERS) + list(SERVICE_HANDLERS) -DEFAULT_DISABLED = list(OPTIONAL_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) +OPTIONAL_SERVICE_HANDLERS = {SERVICE_DLNA_DMR: ("media_player", "dlna_dmr")} + +MIGRATED_SERVICE_HANDLERS = [ + "axis", + "deconz", + "esphome", + "google_cast", + SERVICE_HEOS, + "harmony", + "homekit", + "ikea_tradfri", + "philips_hue", + "sonos", + 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) + logging.getLogger("zeroconf").setLevel(logging.CRITICAL) if DOMAIN in config: # Platforms ignore by config @@ -153,6 +151,9 @@ async def async_setup(hass, config): 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 @@ -167,8 +168,8 @@ async def new_service_found(service, info): 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 + context={"source": config_entries.SOURCE_DISCOVERY}, + data=info, ) return @@ -189,8 +190,7 @@ async def new_service_found(service, info): if platform is None: await async_discover(hass, service, info, component, config) else: - await async_load_platform( - hass, component, platform, info, config) + await async_load_platform(hass, component, platform, info, config) async def scan_devices(now): """Scan for devices.""" @@ -203,7 +203,8 @@ async def scan_devices(now): logger.error("Network is unreachable") async_track_point_in_utc_time( - hass, scan_devices, dt_util.utcnow() + SCAN_INTERVAL) + hass, scan_devices, dt_util.utcnow() + SCAN_INTERVAL + ) @callback def schedule_first(event): diff --git a/homeassistant/components/discovery/manifest.json b/homeassistant/components/discovery/manifest.json index 845e1af15d405..76e4ff701c526 100644 --- a/homeassistant/components/discovery/manifest.json +++ b/homeassistant/components/discovery/manifest.json @@ -1,10 +1,8 @@ { "domain": "discovery", "name": "Discovery", - "documentation": "https://www.home-assistant.io/components/discovery", - "requirements": [ - "netdisco==2.6.0" - ], - "dependencies": [], - "codeowners": [] + "documentation": "https://www.home-assistant.io/integrations/discovery", + "requirements": ["netdisco==2.6.0"], + "codeowners": [], + "quality_scale": "internal" } diff --git a/homeassistant/components/dlib_face_detect/image_processing.py b/homeassistant/components/dlib_face_detect/image_processing.py index 0bc657a615d7f..9e56668eb3efc 100644 --- a/homeassistant/components/dlib_face_detect/image_processing.py +++ b/homeassistant/components/dlib_face_detect/image_processing.py @@ -1,25 +1,34 @@ """Component that will help set the Dlib face detect processing.""" -import logging import io +import logging + +import face_recognition # pylint: disable=import-error +from homeassistant.components.image_processing import ( + CONF_ENTITY_ID, + CONF_NAME, + CONF_SOURCE, + ImageProcessingFaceEntity, +) 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) +from homeassistant.components.image_processing import ( # noqa: F401, isort:skip + PLATFORM_SCHEMA, +) _LOGGER = logging.getLogger(__name__) -ATTR_LOCATION = 'location' +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) - )) + entities.append( + DlibFaceDetectEntity(camera[CONF_ENTITY_ID], camera.get(CONF_NAME)) + ) add_entities(entities) @@ -36,8 +45,7 @@ def __init__(self, camera_entity, name=None): if name: self._name = name else: - self._name = "Dlib Face {0}".format( - split_entity_id(camera_entity)[1]) + self._name = f"Dlib Face {split_entity_id(camera_entity)[1]}" @property def camera_entity(self): @@ -51,16 +59,14 @@ def name(self): 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.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] + 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 index c2ede62ee5b01..e7bd53560bf61 100644 --- a/homeassistant/components/dlib_face_detect/manifest.json +++ b/homeassistant/components/dlib_face_detect/manifest.json @@ -1,10 +1,7 @@ { "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": [], + "name": "Dlib Face Detect", + "documentation": "https://www.home-assistant.io/integrations/dlib_face_detect", + "requirements": ["face_recognition==1.2.3"], "codeowners": [] } diff --git a/homeassistant/components/dlib_face_identify/image_processing.py b/homeassistant/components/dlib_face_identify/image_processing.py index 569b1ecece2bc..32c2aa5868c00 100644 --- a/homeassistant/components/dlib_face_identify/image_processing.py +++ b/homeassistant/components/dlib_face_identify/image_processing.py @@ -1,32 +1,47 @@ """Component that will help set the Dlib face detect processing.""" -import logging import io +import logging +# pylint: disable=import-error +import face_recognition 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) + CONF_CONFIDENCE, + CONF_ENTITY_ID, + CONF_NAME, + CONF_SOURCE, + PLATFORM_SCHEMA, + ImageProcessingFaceEntity, +) +from homeassistant.core import split_entity_id import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -ATTR_NAME = 'name' -CONF_FACES = 'faces' +ATTR_NAME = "name" +CONF_FACES = "faces" -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_FACES): {cv.string: cv.isfile}, -}) +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_FACES): {cv.string: cv.isfile}, + vol.Optional(CONF_CONFIDENCE, default=0.6): vol.Coerce(float), + } +) 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) - )) + entities.append( + DlibFaceIdentifyEntity( + camera[CONF_ENTITY_ID], + config[CONF_FACES], + camera.get(CONF_NAME), + config[CONF_CONFIDENCE], + ) + ) add_entities(entities) @@ -34,10 +49,9 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class DlibFaceIdentifyEntity(ImageProcessingFaceEntity): """Dlib Face API entity for identify.""" - def __init__(self, camera_entity, faces, name=None): + def __init__(self, camera_entity, faces, name, tolerance): """Initialize Dlib face identify entry.""" - # pylint: disable=import-error - import face_recognition + super().__init__() self._camera = camera_entity @@ -45,18 +59,18 @@ def __init__(self, camera_entity, faces, name=None): if name: self._name = name else: - self._name = "Dlib Face {0}".format( - split_entity_id(camera_entity)[1]) + self._name = f"Dlib Face {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] + 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) + self._tolerance = tolerance + @property def camera_entity(self): """Return camera entity id from process pictures.""" @@ -69,11 +83,9 @@ def name(self): 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.name = "snapshot.jpg" fak_file.seek(0) image = face_recognition.load_image_file(fak_file) @@ -82,10 +94,10 @@ def process_image(self, image): found = [] for unknown_face in unknowns: for name, face in self._faces.items(): - result = face_recognition.compare_faces([face], unknown_face) + result = face_recognition.compare_faces( + [face], unknown_face, tolerance=self._tolerance + ) if result[0]: - found.append({ - ATTR_NAME: name - }) + 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 index 388017f78bb45..a1e47f967c028 100644 --- a/homeassistant/components/dlib_face_identify/manifest.json +++ b/homeassistant/components/dlib_face_identify/manifest.json @@ -1,10 +1,7 @@ { "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": [], + "name": "Dlib Face Identify", + "documentation": "https://www.home-assistant.io/integrations/dlib_face_identify", + "requirements": ["face_recognition==1.2.3"], "codeowners": [] } diff --git a/homeassistant/components/dlink/manifest.json b/homeassistant/components/dlink/manifest.json index 8f7d07eb0db39..81a89c8e397e6 100644 --- a/homeassistant/components/dlink/manifest.json +++ b/homeassistant/components/dlink/manifest.json @@ -1,10 +1,7 @@ { "domain": "dlink", - "name": "Dlink", - "documentation": "https://www.home-assistant.io/components/dlink", - "requirements": [ - "pyW215==0.6.0" - ], - "dependencies": [], + "name": "D-Link Wi-Fi Smart Plugs", + "documentation": "https://www.home-assistant.io/integrations/dlink", + "requirements": ["pyW215==0.7.0"], "codeowners": [] } diff --git a/homeassistant/components/dlink/switch.py b/homeassistant/components/dlink/switch.py index 7164bb2310a91..c173c879ad180 100644 --- a/homeassistant/components/dlink/switch.py +++ b/homeassistant/components/dlink/switch.py @@ -3,45 +3,52 @@ import logging import urllib +from pyW215.pyW215 import SmartPlug import voluptuous as vol -from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchDevice +from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchEntity from homeassistant.const import ( - ATTR_TEMPERATURE, CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME, - TEMP_CELSIUS) + 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' +ATTR_TOTAL_CONSUMPTION = "total_consumption" -CONF_USE_LEGACY_PROTOCOL = 'use_legacy_protocol' +CONF_USE_LEGACY_PROTOCOL = "use_legacy_protocol" DEFAULT_NAME = "D-Link Smart Plug W215" -DEFAULT_PASSWORD = '' -DEFAULT_USERNAME = 'admin' +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, -}) +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) + host = config[CONF_HOST] + username = config[CONF_USERNAME] + password = config[CONF_PASSWORD] + use_legacy_protocol = config[CONF_USE_LEGACY_PROTOCOL] + name = config[CONF_NAME] smartplug = SmartPlug(host, password, username, use_legacy_protocol) data = SmartPlugData(smartplug) @@ -49,7 +56,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): add_entities([SmartPlugSwitch(hass, data, name)], True) -class SmartPlugSwitch(SwitchDevice): +class SmartPlugSwitch(SwitchEntity): """Representation of a D-Link Smart Plug switch.""" def __init__(self, hass, data, name): @@ -67,8 +74,7 @@ def name(self): def device_state_attributes(self): """Return the state attributes of the device.""" try: - ui_temp = self.units.temperature( - int(self.data.temperature), TEMP_CELSIUS) + ui_temp = self.units.temperature(int(self.data.temperature), TEMP_CELSIUS) temperature = ui_temp except (ValueError, TypeError): temperature = None @@ -96,15 +102,15 @@ def current_power_w(self): @property def is_on(self): """Return true if switch is on.""" - return self.data.state == 'ON' + return self.data.state == "ON" def turn_on(self, **kwargs): """Turn the switch on.""" - self.data.smartplug.state = 'ON' + self.data.smartplug.state = "ON" def turn_off(self, **kwargs): """Turn the switch off.""" - self.data.smartplug.state = 'OFF' + self.data.smartplug.state = "OFF" def update(self): """Get the latest data from the smart plug and updates the states.""" @@ -133,20 +139,20 @@ def __init__(self, smartplug): 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 + 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' + _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': + if _state == "unknown": self._n_tried += 1 self.available = False _LOGGER.warning("Failed to connect to D-Link switch") diff --git a/homeassistant/components/dlna_dmr/manifest.json b/homeassistant/components/dlna_dmr/manifest.json index be2e655454e81..ac7a4b22e5880 100644 --- a/homeassistant/components/dlna_dmr/manifest.json +++ b/homeassistant/components/dlna_dmr/manifest.json @@ -1,10 +1,7 @@ { "domain": "dlna_dmr", - "name": "Dlna dmr", - "documentation": "https://www.home-assistant.io/components/dlna_dmr", - "requirements": [ - "async-upnp-client==0.14.7" - ], - "dependencies": [], + "name": "DLNA Digital Media Renderer", + "documentation": "https://www.home-assistant.io/integrations/dlna_dmr", + "requirements": ["async-upnp-client==0.14.13"], "codeowners": [] } diff --git a/homeassistant/components/dlna_dmr/media_player.py b/homeassistant/components/dlna_dmr/media_player.py index 6f29bd65d5677..75d88d59c328c 100644 --- a/homeassistant/components/dlna_dmr/media_player.py +++ b/homeassistant/components/dlna_dmr/media_player.py @@ -1,83 +1,108 @@ """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 +from async_upnp_client import UpnpFactory +from async_upnp_client.aiohttp import AiohttpNotifyServer, AiohttpSessionRequester +from async_upnp_client.profiles.dlna import DeviceState, DmrDevice import voluptuous as vol -from homeassistant.components.media_player import ( - MediaPlayerDevice, PLATFORM_SCHEMA) +from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerEntity 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) + 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) + 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.helpers.typing import HomeAssistantType from homeassistant.util import get_local_ip +import homeassistant.util.dt as dt_util _LOGGER = logging.getLogger(__name__) -DLNA_DMR_DATA = 'dlna_dmr' +DLNA_DMR_DATA = "dlna_dmr" -DEFAULT_NAME = 'DLNA Digital Media Renderer' +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' +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, -}) +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', + 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.playlistItem", } -UPNP_CLASS_DEFAULT = 'object.item' +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/*', + 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): + async def wrapper(self, *args, **kwargs): """Catch asyncio.TimeoutError, aiohttp.ClientError errors.""" try: - return func(self, *args, **kwargs) + return await func(self, *args, **kwargs) except (asyncio.TimeoutError, aiohttp.ClientError): _LOGGER.error("Error during call %s", func.__name__) @@ -87,76 +112,73 @@ def wrapper(self, *args, **kwargs): async def async_start_event_handler( - hass: HomeAssistantType, - server_host: str, - server_port: int, - requester, - callback_url_override: Optional[str] = None): + 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'] + 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, - loop=hass.loop, - callback_url=callback_url_override) + 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 + _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') + _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'] + return hass_data["event_handler"] async def async_setup_platform( - hass: HomeAssistantType, - config, - async_add_entities, - discovery_info=None): + 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') + 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() + 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']: + 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) + 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) @@ -164,7 +186,6 @@ async def async_setup_platform( raise PlatformNotReady() # wrap with DmrDevice - from async_upnp_client.profiles.dlna import DmrDevice dlna_device = DmrDevice(upnp_device, event_handler) # create our own device @@ -173,11 +194,11 @@ async def async_setup_platform( async_add_entities([device], True) -class DlnaDmrDevice(MediaPlayerDevice): +class DlnaDmrDevice(MediaPlayerEntity): """Representation of a DLNA DMR device.""" def __init__(self, dmr_device, name=None): - """Initializer.""" + """Initialize DLNA DMR device.""" self._device = dmr_device self._name = name @@ -190,8 +211,7 @@ async def async_added_to_hass(self): # Register unsubscribe on stop bus = self.hass.bus - bus.async_listen_once( - EVENT_HOMEASSISTANT_STOP, self._async_on_hass_stop) + bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._async_on_hass_stop) @property def available(self): @@ -199,8 +219,8 @@ def available(self): 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']: + """Event handler on Home Assistant stop.""" + with await self.hass.data[DLNA_DMR_DATA]["lock"]: await self._device.async_unsubscribe_services() async def async_update(self): @@ -216,14 +236,14 @@ async def async_update(self): 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: + now = dt_util.utcnow() + 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 + self._subscription_renew_time = dt_util.utcnow() + timeout / 2 except (asyncio.TimeoutError, aiohttp.ClientError): self._available = False _LOGGER.debug("Could not (re)subscribe") @@ -283,7 +303,7 @@ async def async_mute_volume(self, mute): async def async_media_pause(self): """Send pause command.""" if not self._device.can_pause: - _LOGGER.debug('Cannot do Pause') + _LOGGER.debug("Cannot do Pause") return await self._device.async_pause() @@ -292,7 +312,7 @@ async def async_media_pause(self): async def async_media_play(self): """Send play command.""" if not self._device.can_play: - _LOGGER.debug('Cannot do Play') + _LOGGER.debug("Cannot do Play") return await self._device.async_play() @@ -301,7 +321,7 @@ async def async_media_play(self): async def async_media_stop(self): """Send stop command.""" if not self._device.can_stop: - _LOGGER.debug('Cannot do Stop') + _LOGGER.debug("Cannot do Stop") return await self._device.async_stop() @@ -310,7 +330,7 @@ async def async_media_stop(self): 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') + _LOGGER.debug("Cannot do Seek/rel_time") return time = timedelta(seconds=position) @@ -320,10 +340,10 @@ async def async_media_seek(self, position): 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) + 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: @@ -331,11 +351,11 @@ async def async_play_media(self, media_type, media_id, **kwargs): # Queue media await self._device.async_set_transport_uri( - media_id, title, mime_type, upnp_class) + 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 @@ -346,7 +366,7 @@ async def async_play_media(self, media_type, media_id, **kwargs): async def async_media_previous_track(self): """Send previous track command.""" if not self._device.can_previous: - _LOGGER.debug('Cannot do Previous') + _LOGGER.debug("Cannot do Previous") return await self._device.async_previous() @@ -355,7 +375,7 @@ async def async_media_previous_track(self): async def async_media_next_track(self): """Send next track command.""" if not self._device.can_next: - _LOGGER.debug('Cannot do Next') + _LOGGER.debug("Cannot do Next") return await self._device.async_next() @@ -376,7 +396,6 @@ def state(self): 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: diff --git a/homeassistant/components/dnsip/manifest.json b/homeassistant/components/dnsip/manifest.json index 544ac9b0fbafa..6aeac70b4f39d 100644 --- a/homeassistant/components/dnsip/manifest.json +++ b/homeassistant/components/dnsip/manifest.json @@ -1,10 +1,7 @@ { "domain": "dnsip", - "name": "Dnsip", - "documentation": "https://www.home-assistant.io/components/dnsip", - "requirements": [ - "aiodns==2.0.0" - ], - "dependencies": [], + "name": "DNS IP", + "documentation": "https://www.home-assistant.io/integrations/dnsip", + "requirements": ["aiodns==2.0.0"], "codeowners": [] } diff --git a/homeassistant/components/dnsip/sensor.py b/homeassistant/components/dnsip/sensor.py index a29a0513cee94..b202ff8485c79 100644 --- a/homeassistant/components/dnsip/sensor.py +++ b/homeassistant/components/dnsip/sensor.py @@ -2,6 +2,8 @@ from datetime import timedelta import logging +import aiodns +from aiodns.error import DNSError import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA @@ -11,46 +13,46 @@ _LOGGER = logging.getLogger(__name__) -CONF_HOSTNAME = 'hostname' -CONF_IPV6 = 'ipv6' -CONF_RESOLVER = 'resolver' -CONF_RESOLVER_IPV6 = 'resolver_ipv6' +CONF_HOSTNAME = "hostname" +CONF_IPV6 = "ipv6" +CONF_RESOLVER = "resolver" +CONF_RESOLVER_IPV6 = "resolver_ipv6" -DEFAULT_HOSTNAME = 'myip.opendns.com' +DEFAULT_HOSTNAME = "myip.opendns.com" DEFAULT_IPV6 = False -DEFAULT_NAME = 'myip' -DEFAULT_RESOLVER = '208.67.222.222' -DEFAULT_RESOLVER_IPV6 = '2620:0:ccc::2' +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, -}) +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): +async def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the DNS IP sensor.""" - hostname = config.get(CONF_HOSTNAME) + hostname = config[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) + ipv6 = config[CONF_IPV6] if ipv6: - resolver = config.get(CONF_RESOLVER_IPV6) + resolver = config[CONF_RESOLVER_IPV6] else: - resolver = config.get(CONF_RESOLVER) + resolver = config[CONF_RESOLVER] - async_add_devices([WanIpSensor( - hass, name, hostname, resolver, ipv6)], True) + async_add_devices([WanIpSensor(hass, name, hostname, resolver, ipv6)], True) class WanIpSensor(Entity): @@ -58,14 +60,13 @@ class WanIpSensor(Entity): 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(loop=self.hass.loop) + self.resolver = aiodns.DNSResolver() self.resolver.nameservers = [resolver] - self.querytype = 'AAAA' if ipv6 else 'A' + self.querytype = "AAAA" if ipv6 else "A" self._state = None @property @@ -80,11 +81,9 @@ def state(self): 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) + response = await self.resolver.query(self.hostname, self.querytype) except DNSError as err: _LOGGER.warning("Exception while resolving host: %s", err) response = None diff --git a/homeassistant/components/dominos/__init__.py b/homeassistant/components/dominos/__init__.py index 3c5cb3ed6ecc5..d39773842552e 100644 --- a/homeassistant/components/dominos/__init__.py +++ b/homeassistant/components/dominos/__init__.py @@ -2,6 +2,8 @@ from datetime import timedelta import logging +from pizzapi import Address, Customer, Order +from pizzapi.address import StoreException import voluptuous as vol from homeassistant.components import http @@ -14,42 +16,50 @@ _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' +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) +_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): @@ -61,7 +71,7 @@ def setup(hass, config): entities = [] conf = config[DOMAIN] - hass.services.register(DOMAIN, 'order', dominos.handle_order) + hass.services.register(DOMAIN, "order", dominos.handle_order) if conf.get(ATTR_SHOW_MENU): hass.http.register_view(DominosProductListView(dominos)) @@ -77,24 +87,24 @@ def setup(hass, config): return True -class Dominos(): +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)) + conf.get(ATTR_ADDRESS), + ) self.address = Address( - *self.customer.address.split(','), - country=conf.get(ATTR_COUNTRY)) + *self.customer.address.split(","), country=conf.get(ATTR_COUNTRY) + ) self.country = conf.get(ATTR_COUNTRY) try: self.closest_store = self.address.closest_store() @@ -103,10 +113,13 @@ def __init__(self, hass, config): def handle_order(self, call): """Handle ordering pizza.""" - entity_ids = call.data.get(ATTR_ORDER_ENTITY, None) + entity_ids = call.data.get(ATTR_ORDER_ENTITY) - target_orders = [order for order in self.hass.data[DOMAIN]['entities'] - if order.entity_id in entity_ids] + 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() @@ -114,7 +127,6 @@ def handle_order(self, call): @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 @@ -126,19 +138,19 @@ 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') + _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']) + 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 + variants = product.menu_data["Variants"] + item["name"] = product.name + item["variants"] = variants product_entries.append(item) return product_entries @@ -147,7 +159,7 @@ def get_menu(self): class DominosProductListView(http.HomeAssistantView): """View to retrieve product list content.""" - url = '/api/dominos' + url = "/api/dominos" name = "api:dominos" def __init__(self, dominos): @@ -165,8 +177,8 @@ class DominosOrder(Entity): def __init__(self, order_info, dominos): """Set up the entity.""" - self._name = order_info['name'] - self._product_codes = order_info['codes'] + self._name = order_info["name"] + self._product_codes = order_info["codes"] self._orderable = False self.dominos = dominos @@ -189,13 +201,12 @@ def orderable(self): 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' + 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: @@ -211,9 +222,6 @@ def update(self): 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 @@ -221,7 +229,8 @@ def order(self): self.dominos.closest_store, self.dominos.customer, self.dominos.address, - self.dominos.country) + self.dominos.country, + ) for code in self._product_codes: order.add_item(code) @@ -230,11 +239,11 @@ def order(self): 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') + "Attempted to order Dominos - Order invalid or store closed" + ) diff --git a/homeassistant/components/dominos/manifest.json b/homeassistant/components/dominos/manifest.json index f8d13b49f9332..0137cafc169b6 100644 --- a/homeassistant/components/dominos/manifest.json +++ b/homeassistant/components/dominos/manifest.json @@ -1,12 +1,8 @@ { "domain": "dominos", - "name": "Dominos", - "documentation": "https://www.home-assistant.io/components/dominos", - "requirements": [ - "pizzapi==0.0.3" - ], - "dependencies": [ - "http" - ], + "name": "Dominos Pizza", + "documentation": "https://www.home-assistant.io/integrations/dominos", + "requirements": ["pizzapi==0.0.3"], + "dependencies": ["http"], "codeowners": [] } diff --git a/homeassistant/components/dominos/services.yaml b/homeassistant/components/dominos/services.yaml index e69de29bb2d1d..93f8b2851f17c 100644 --- a/homeassistant/components/dominos/services.yaml +++ b/homeassistant/components/dominos/services.yaml @@ -0,0 +1,6 @@ +order: + description: Places a set of orders with Dominos Pizza. + fields: + order_entity_id: + description: The ID (as specified in the configuration) of an order to place. If provided as an array, all of the identified orders will be placed. + example: dominos.medium_pan diff --git a/homeassistant/components/doods/__init__.py b/homeassistant/components/doods/__init__.py new file mode 100644 index 0000000000000..b6edb9be87bda --- /dev/null +++ b/homeassistant/components/doods/__init__.py @@ -0,0 +1 @@ +"""The doods component.""" diff --git a/homeassistant/components/doods/image_processing.py b/homeassistant/components/doods/image_processing.py new file mode 100644 index 0000000000000..4130f67ec138d --- /dev/null +++ b/homeassistant/components/doods/image_processing.py @@ -0,0 +1,382 @@ +"""Support for the DOODS service.""" +import io +import logging +import time + +from PIL import Image, ImageDraw, UnidentifiedImageError +from pydoods import PyDOODS +import voluptuous as vol + +from homeassistant.components.image_processing import ( + CONF_CONFIDENCE, + CONF_ENTITY_ID, + CONF_NAME, + CONF_SOURCE, + PLATFORM_SCHEMA, + ImageProcessingEntity, +) +from homeassistant.const import CONF_TIMEOUT +from homeassistant.core import split_entity_id +from homeassistant.helpers import template +import homeassistant.helpers.config_validation as cv +from homeassistant.util.pil import draw_box + +_LOGGER = logging.getLogger(__name__) + +ATTR_MATCHES = "matches" +ATTR_SUMMARY = "summary" +ATTR_TOTAL_MATCHES = "total_matches" + +CONF_URL = "url" +CONF_AUTH_KEY = "auth_key" +CONF_DETECTOR = "detector" +CONF_LABELS = "labels" +CONF_AREA = "area" +CONF_COVERS = "covers" +CONF_TOP = "top" +CONF_BOTTOM = "bottom" +CONF_RIGHT = "right" +CONF_LEFT = "left" +CONF_FILE_OUT = "file_out" + +AREA_SCHEMA = vol.Schema( + { + vol.Optional(CONF_BOTTOM, default=1): cv.small_float, + vol.Optional(CONF_LEFT, default=0): cv.small_float, + vol.Optional(CONF_RIGHT, default=1): cv.small_float, + vol.Optional(CONF_TOP, default=0): cv.small_float, + vol.Optional(CONF_COVERS, default=True): cv.boolean, + } +) + +LABEL_SCHEMA = vol.Schema( + { + vol.Required(CONF_NAME): cv.string, + vol.Optional(CONF_AREA): AREA_SCHEMA, + vol.Optional(CONF_CONFIDENCE): vol.Range(min=0, max=100), + } +) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_URL): cv.string, + vol.Required(CONF_DETECTOR): cv.string, + vol.Required(CONF_TIMEOUT, default=90): cv.positive_int, + vol.Optional(CONF_AUTH_KEY, default=""): cv.string, + vol.Optional(CONF_FILE_OUT, default=[]): vol.All(cv.ensure_list, [cv.template]), + vol.Optional(CONF_CONFIDENCE, default=0.0): vol.Range(min=0, max=100), + vol.Optional(CONF_LABELS, default=[]): vol.All( + cv.ensure_list, [vol.Any(cv.string, LABEL_SCHEMA)] + ), + vol.Optional(CONF_AREA): AREA_SCHEMA, + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Doods client.""" + url = config[CONF_URL] + auth_key = config[CONF_AUTH_KEY] + detector_name = config[CONF_DETECTOR] + timeout = config[CONF_TIMEOUT] + + doods = PyDOODS(url, auth_key, timeout) + response = doods.get_detectors() + if not isinstance(response, dict): + _LOGGER.warning("Could not connect to doods server: %s", url) + return + + detector = {} + for server_detector in response["detectors"]: + if server_detector["name"] == detector_name: + detector = server_detector + break + + if not detector: + _LOGGER.warning( + "Detector %s is not supported by doods server %s", detector_name, url + ) + return + + entities = [] + for camera in config[CONF_SOURCE]: + entities.append( + Doods( + hass, + camera[CONF_ENTITY_ID], + camera.get(CONF_NAME), + doods, + detector, + config, + ) + ) + add_entities(entities) + + +class Doods(ImageProcessingEntity): + """Doods image processing service client.""" + + def __init__(self, hass, camera_entity, name, doods, detector, config): + """Initialize the DOODS entity.""" + self.hass = hass + self._camera_entity = camera_entity + if name: + self._name = name + else: + name = split_entity_id(camera_entity)[1] + self._name = f"Doods {name}" + self._doods = doods + self._file_out = config[CONF_FILE_OUT] + self._detector_name = detector["name"] + + # detector config and aspect ratio + self._width = None + self._height = None + self._aspect = None + if detector["width"] and detector["height"]: + self._width = detector["width"] + self._height = detector["height"] + self._aspect = self._width / self._height + + # the base confidence + dconfig = {} + confidence = config[CONF_CONFIDENCE] + + # handle labels and specific detection areas + labels = config[CONF_LABELS] + self._label_areas = {} + self._label_covers = {} + for label in labels: + if isinstance(label, dict): + label_name = label[CONF_NAME] + if label_name not in detector["labels"] and label_name != "*": + _LOGGER.warning("Detector does not support label %s", label_name) + continue + + # If label confidence is not specified, use global confidence + label_confidence = label.get(CONF_CONFIDENCE) + if not label_confidence: + label_confidence = confidence + if label_name not in dconfig or dconfig[label_name] > label_confidence: + dconfig[label_name] = label_confidence + + # Label area + label_area = label.get(CONF_AREA) + self._label_areas[label_name] = [0, 0, 1, 1] + self._label_covers[label_name] = True + if label_area: + self._label_areas[label_name] = [ + label_area[CONF_TOP], + label_area[CONF_LEFT], + label_area[CONF_BOTTOM], + label_area[CONF_RIGHT], + ] + self._label_covers[label_name] = label_area[CONF_COVERS] + else: + if label not in detector["labels"] and label != "*": + _LOGGER.warning("Detector does not support label %s", label) + continue + self._label_areas[label] = [0, 0, 1, 1] + self._label_covers[label] = True + if label not in dconfig or dconfig[label] > confidence: + dconfig[label] = confidence + + if not dconfig: + dconfig["*"] = confidence + + # Handle global detection area + self._area = [0, 0, 1, 1] + self._covers = True + area_config = config.get(CONF_AREA) + if area_config: + self._area = [ + area_config[CONF_TOP], + area_config[CONF_LEFT], + area_config[CONF_BOTTOM], + area_config[CONF_RIGHT], + ] + self._covers = area_config[CONF_COVERS] + + template.attach(hass, self._file_out) + + self._dconfig = dconfig + self._matches = {} + self._total_matches = 0 + self._last_image = None + + @property + def camera_entity(self): + """Return camera entity id from process pictures.""" + return self._camera_entity + + @property + def name(self): + """Return the name of the image processor.""" + return self._name + + @property + def state(self): + """Return the state of the entity.""" + return self._total_matches + + @property + def device_state_attributes(self): + """Return device specific state attributes.""" + return { + ATTR_MATCHES: self._matches, + ATTR_SUMMARY: { + label: len(values) for label, values in self._matches.items() + }, + ATTR_TOTAL_MATCHES: self._total_matches, + } + + def _save_image(self, image, matches, paths): + img = Image.open(io.BytesIO(bytearray(image))).convert("RGB") + img_width, img_height = img.size + draw = ImageDraw.Draw(img) + + # Draw custom global region/area + if self._area != [0, 0, 1, 1]: + draw_box( + draw, self._area, img_width, img_height, "Detection Area", (0, 255, 255) + ) + + for label, values in matches.items(): + + # Draw custom label regions/areas + if label in self._label_areas and self._label_areas[label] != [0, 0, 1, 1]: + box_label = f"{label.capitalize()} Detection Area" + draw_box( + draw, + self._label_areas[label], + img_width, + img_height, + box_label, + (0, 255, 0), + ) + + # Draw detected objects + for instance in values: + box_label = f'{label} {instance["score"]:.1f}%' + # Already scaled, use 1 for width and height + draw_box( + draw, + instance["box"], + img_width, + img_height, + box_label, + (255, 255, 0), + ) + + for path in paths: + _LOGGER.info("Saving results image to %s", path) + img.save(path) + + def process_image(self, image): + """Process the image.""" + try: + img = Image.open(io.BytesIO(bytearray(image))).convert("RGB") + except UnidentifiedImageError: + _LOGGER.warning("Unable to process image, bad data") + return + img_width, img_height = img.size + + if self._aspect and abs((img_width / img_height) - self._aspect) > 0.1: + _LOGGER.debug( + "The image aspect: %s and the detector aspect: %s differ by more than 0.1", + (img_width / img_height), + self._aspect, + ) + + # Run detection + start = time.monotonic() + response = self._doods.detect( + image, dconfig=self._dconfig, detector_name=self._detector_name + ) + _LOGGER.debug( + "doods detect: %s response: %s duration: %s", + self._dconfig, + response, + time.monotonic() - start, + ) + + matches = {} + total_matches = 0 + + if not response or "error" in response: + if "error" in response: + _LOGGER.error(response["error"]) + self._matches = matches + self._total_matches = total_matches + return + + for detection in response["detections"]: + score = detection["confidence"] + boxes = [ + detection["top"], + detection["left"], + detection["bottom"], + detection["right"], + ] + label = detection["label"] + + # Exclude unlisted labels + if "*" not in self._dconfig and label not in self._dconfig: + continue + + # Exclude matches outside global area definition + if self._covers: + if ( + boxes[0] < self._area[0] + or boxes[1] < self._area[1] + or boxes[2] > self._area[2] + or boxes[3] > self._area[3] + ): + continue + else: + if ( + boxes[0] > self._area[2] + or boxes[1] > self._area[3] + or boxes[2] < self._area[0] + or boxes[3] < self._area[1] + ): + continue + + # Exclude matches outside label specific area definition + if self._label_areas.get(label): + if self._label_covers[label]: + if ( + boxes[0] < self._label_areas[label][0] + or boxes[1] < self._label_areas[label][1] + or boxes[2] > self._label_areas[label][2] + or boxes[3] > self._label_areas[label][3] + ): + continue + else: + if ( + boxes[0] > self._label_areas[label][2] + or boxes[1] > self._label_areas[label][3] + or boxes[2] < self._label_areas[label][0] + or boxes[3] < self._label_areas[label][1] + ): + continue + + if label not in matches: + matches[label] = [] + matches[label].append({"score": float(score), "box": boxes}) + total_matches += 1 + + # Save Images + if total_matches and self._file_out: + paths = [] + for path_template in self._file_out: + if isinstance(path_template, template.Template): + paths.append( + path_template.render(camera_entity=self._camera_entity) + ) + else: + paths.append(path_template) + self._save_image(image, matches, paths) + + self._matches = matches + self._total_matches = total_matches diff --git a/homeassistant/components/doods/manifest.json b/homeassistant/components/doods/manifest.json new file mode 100644 index 0000000000000..f363f46d2d717 --- /dev/null +++ b/homeassistant/components/doods/manifest.json @@ -0,0 +1,7 @@ +{ + "domain": "doods", + "name": "DOODS - Distributed Outside Object Detection Service", + "documentation": "https://www.home-assistant.io/integrations/doods", + "requirements": ["pydoods==1.0.2", "pillow==7.1.2"], + "codeowners": [] +} diff --git a/homeassistant/components/doorbird/__init__.py b/homeassistant/components/doorbird/__init__.py index 477d96770bc52..b70b0a3061cf4 100644 --- a/homeassistant/components/doorbird/__init__.py +++ b/homeassistant/components/doorbird/__init__.py @@ -1,138 +1,93 @@ """Support for DoorBird devices.""" +import asyncio import logging +import urllib from urllib.error import HTTPError +from aiohttp import web +from doorbirdpy import DoorBird import voluptuous as vol from homeassistant.components.http import HomeAssistantView +from homeassistant.components.logbook import log_entry +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( - CONF_DEVICES, CONF_HOST, CONF_MONITORED_CONDITIONS, CONF_NAME, - CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME) + CONF_DEVICES, + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_TOKEN, + CONF_USERNAME, + HTTP_OK, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryNotReady import homeassistant.helpers.config_validation as cv from homeassistant.util import dt as dt_util, slugify +from .const import CONF_EVENTS, DOMAIN, DOOR_STATION, DOOR_STATION_INFO, PLATFORMS +from .util import get_doorstation_by_token + _LOGGER = logging.getLogger(__name__) -DOMAIN = 'doorbird' +API_URL = f"/api/{DOMAIN}" -API_URL = '/api/{}'.format(DOMAIN) +CONF_CUSTOM_URL = "hass_url_override" -CONF_CUSTOM_URL = 'hass_url_override' -CONF_DOORBELL_EVENTS = 'doorbell_events' -CONF_DOORBELL_NUMS = 'doorbell_numbers' -CONF_RELAY_NUMS = 'relay_numbers' -CONF_MOTION_EVENTS = 'motion_events' +RESET_DEVICE_FAVORITES = "doorbird_reset_favorites" -SENSOR_TYPES = { - 'doorbell': { - 'name': 'Button', - 'device_class': 'occupancy', - }, - 'motion': { - 'name': 'Motion', - 'device_class': 'motion', - }, - 'relay': { - 'name': 'Relay', - 'device_class': 'relay', - } -} - -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.Optional(CONF_DOORBELL_NUMS, default=[1]): vol.All( - cv.ensure_list, [cv.positive_int]), - vol.Optional(CONF_RELAY_NUMS, default=[1]): vol.All( - cv.ensure_list, [cv.positive_int]), - vol.Optional(CONF_CUSTOM_URL): cv.string, - vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_MONITORED_CONDITIONS, default=[]): - vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), -}) - -CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ +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.Required(CONF_DEVICES): vol.All(cv.ensure_list, [DEVICE_SCHEMA]) - }), -}, extra=vol.ALLOW_EXTRA) + 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): +async def async_setup(hass: HomeAssistant, config: dict): """Set up the DoorBird component.""" - from doorbirdpy import DoorBird - - token = config[DOMAIN].get(CONF_TOKEN) + hass.data.setdefault(DOMAIN, {}) # Provide an endpoint for the doorstations to call to trigger events - hass.http.register_view(DoorBirdRequestView(token)) - - # Provide an endpoint for the user to call to clear device changes - hass.http.register_view(DoorBirdCleanupView(token)) - - 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) - doorbell_nums = doorstation_config.get(CONF_DOORBELL_NUMS) - relay_nums = doorstation_config.get(CONF_RELAY_NUMS) - custom_url = doorstation_config.get(CONF_CUSTOM_URL) - events = doorstation_config.get(CONF_MONITORED_CONDITIONS) - 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, - doorbell_nums, relay_nums, 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.update_schedule(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') + hass.http.register_view(DoorBirdRequestView) - return False + if DOMAIN in config and CONF_DEVICES in config[DOMAIN]: + for index, doorstation_config in enumerate(config[DOMAIN][CONF_DEVICES]): + if CONF_NAME not in doorstation_config: + doorstation_config[CONF_NAME] = f"DoorBird {index + 1}" - hass.data[DOMAIN] = doorstations + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=doorstation_config + ) + ) def _reset_device_favorites_handler(event): """Handle clearing favorites on device.""" - slug = event.data.get('slug') + token = event.data.get("token") - if slug is None: + if token is None: return - doorstation = get_doorstation_by_slug(hass, slug) + doorstation = get_doorstation_by_token(hass, token) if doorstation is None: - _LOGGER.error('Device not found %s', format(slug)) + _LOGGER.error("Device not found for provided token.") + return # Clear webhooks favorites = doorstation.device.favorites() @@ -141,35 +96,137 @@ def _reset_device_favorites_handler(event): 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) + hass.bus.async_listen(RESET_DEVICE_FAVORITES, _reset_device_favorites_handler) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up DoorBird from a config entry.""" + + _async_import_options_from_data_if_missing(hass, entry) + + doorstation_config = entry.data + doorstation_options = entry.options + config_entry_id = entry.entry_id + + device_ip = doorstation_config[CONF_HOST] + username = doorstation_config[CONF_USERNAME] + password = doorstation_config[CONF_PASSWORD] + + device = DoorBird(device_ip, username, password) + try: + status = await hass.async_add_executor_job(device.ready) + info = await hass.async_add_executor_job(device.info) + except urllib.error.HTTPError as err: + if err.code == 401: + _LOGGER.error( + "Authorization rejected by DoorBird for %s@%s", username, device_ip + ) + return False + raise ConfigEntryNotReady + except OSError as oserr: + _LOGGER.error("Failed to setup doorbird at %s: %s", device_ip, oserr) + raise ConfigEntryNotReady + + if not status[0]: + _LOGGER.error( + "Could not connect to DoorBird as %s@%s: Error %s", + username, + device_ip, + str(status[1]), + ) + raise ConfigEntryNotReady + + token = doorstation_config.get(CONF_TOKEN, config_entry_id) + custom_url = doorstation_config.get(CONF_CUSTOM_URL) + name = doorstation_config.get(CONF_NAME) + events = doorstation_options.get(CONF_EVENTS, []) + doorstation = ConfiguredDoorBird(device, name, events, custom_url, token) + # Subscribe to doorbell or motion events + if not await _async_register_events(hass, doorstation): + raise ConfigEntryNotReady + + hass.data[DOMAIN][config_entry_id] = { + DOOR_STATION: doorstation, + DOOR_STATION_INFO: info, + } + + entry.add_update_listener(_update_listener) + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ] + ) + ) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +async def _async_register_events(hass, doorstation): + try: + await hass.async_add_executor_job(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 return True -def get_doorstation_by_slug(hass, slug): - """Get doorstation by slug.""" - for doorstation in hass.data[DOMAIN]: - if slugify(doorstation.name) in slug: - return doorstation +async def _update_listener(hass: HomeAssistant, entry: ConfigEntry): + """Handle options update.""" + config_entry_id = entry.entry_id + doorstation = hass.data[DOMAIN][config_entry_id][DOOR_STATION] + + doorstation.events = entry.options[CONF_EVENTS] + # Subscribe to doorbell or motion events + await _async_register_events(hass, doorstation) + +@callback +def _async_import_options_from_data_if_missing(hass: HomeAssistant, entry: ConfigEntry): + options = dict(entry.options) + modified = False + for importable_option in [CONF_EVENTS]: + if importable_option not in entry.options and importable_option in entry.data: + options[importable_option] = entry.data[importable_option] + modified = True -def handle_event(event): - """Handle dummy events.""" - return None + if modified: + hass.config_entries.async_update_entry(entry, options=options) -class ConfiguredDoorBird(): +class ConfiguredDoorBird: """Attach additional information to pass along with configured device.""" - def __init__(self, device, name, events, custom_url, doorbell_nums, - relay_nums, token): + def __init__(self, device, name, events, custom_url, token): """Initialize configured device.""" self._name = name self._device = device self._custom_url = custom_url - self._monitored_events = events - self._doorbell_nums = doorbell_nums - self._relay_nums = relay_nums + self.events = events self._token = token @property @@ -187,14 +244,13 @@ def custom_url(self): """Get custom url for device.""" return self._custom_url - def update_schedule(self, hass): - """Register monitored sensors and deregister others.""" - from doorbirdpy import DoorBirdScheduleEntrySchedule - - # Create a new schedule (24/7) - schedule = DoorBirdScheduleEntrySchedule() - schedule.add_weekday(0, 604800) # seconds in a week + @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 @@ -202,111 +258,53 @@ def update_schedule(self, hass): if self.custom_url is not None: hass_url = self.custom_url - # For all sensor types (enabled + disabled) - for sensor_type in SENSOR_TYPES: - name = '{} {}'.format(self.name, SENSOR_TYPES[sensor_type]['name']) - slug = slugify(name) - - url = '{}{}/{}?token={}'.format(hass_url, API_URL, slug, - self._token) - if sensor_type in self._monitored_events: - # Enabled -> register - self._register_event(url, sensor_type, schedule) - _LOGGER.info('Registered for %s pushes from DoorBird "%s". ' - 'Use the "%s_%s" event for automations.', - sensor_type, self.name, DOMAIN, slug) - - # Register a dummy listener so event is listed in GUI - hass.bus.listen('{}_{}'.format(DOMAIN, slug), handle_event) - else: - # Disabled -> deregister - self._deregister_event(url, sensor_type) - _LOGGER.info('Deregistered %s pushes from DoorBird "%s". ' - 'If any old favorites or schedules remain, ' - 'follow the instructions in the component ' - 'documentation to clear device registrations.', - sensor_type, self.name) - - def _register_event(self, hass_url, event, schedule): - """Add a schedule entry in the device for a sensor.""" - from doorbirdpy import DoorBirdScheduleEntryOutput - - # Register HA URL as webhook if not already, then get the ID - if not self.webhook_is_registered(hass_url): - self.device.change_favorite('http', 'Home Assistant ({} events)' - .format(event), hass_url) + for event in self.events: + event = self._get_event_name(event) - fav_id = self.get_webhook_id(hass_url) + self._register_event(hass_url, event) - if not fav_id: - _LOGGER.warning('Could not find favorite for URL "%s". ' - 'Skipping sensor "%s".', hass_url, event) - return + _LOGGER.info("Successfully registered URL for %s on %s", event, self.name) - # Add event handling to device schedule - output = DoorBirdScheduleEntryOutput(event='http', - param=fav_id, - schedule=schedule) - - if event == 'doorbell': - # Repeat edit for each monitored doorbell number - for doorbell in self._doorbell_nums: - entry = self.device.get_schedule_entry(event, str(doorbell)) - entry.output.append(output) - self.device.change_schedule(entry) - elif event == 'relay': - # Repeat edit for each monitored doorbell number - for relay in self._relay_nums: - entry = self.device.get_schedule_entry(event, str(relay)) - entry.output.append(output) - else: - entry = self.device.get_schedule_entry(event) - entry.output.append(output) - self.device.change_schedule(entry) - - def _deregister_event(self, hass_url, event): - """Remove the schedule entry in the device for a sensor.""" - # Find the right favorite and delete it - fav_id = self.get_webhook_id(hass_url) - if not fav_id: - return + @property + def slug(self): + """Get device slug.""" + return slugify(self._name) - self._device.delete_favorite('http', fav_id) + def _get_event_name(self, event): + return f"{self.slug}_{event}" - if event == 'doorbell': - # Delete the matching schedule for each doorbell number - for doorbell in self._doorbell_nums: - self._delete_schedule_action(event, fav_id, str(doorbell)) - else: - self._delete_schedule_action(event, fav_id) + def _register_event(self, hass_url, event): + """Add a schedule entry in the device for a sensor.""" + url = f"{hass_url}{API_URL}/{event}?token={self._token}" - def _delete_schedule_action(self, sensor, fav_id, param=""): - """Remove the HA output from a schedule.""" - entries = self._device.schedule() - for entry in entries: - if entry.input != sensor or entry.param != param: - continue + # Register HA URL as webhook if not already, then get the ID + if not self.webhook_is_registered(url): + self.device.change_favorite("http", f"Home Assistant ({event})", url) - for action in entry.output: - if action.event == 'http' and action.param == fav_id: - entry.output.remove(action) + fav_id = self.get_webhook_id(url) - self._device.change_schedule(entry) + if not fav_id: + _LOGGER.warning( + 'Could not find favorite for URL "%s". ' 'Skipping sensor "%s"', + url, + event, + ) + return - def webhook_is_registered(self, ha_url, favs=None) -> bool: + 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: + if "http" not in favs: return False - for fav in favs['http'].values(): - if fav['value'] == ha_url: + for fav in favs["http"].values(): + if fav["value"] == url: return True return False - def get_webhook_id(self, ha_url, favs=None) -> str or None: + def get_webhook_id(self, url, favs=None) -> str or None: """ Return the device favorite ID for the given URL. @@ -314,11 +312,11 @@ def get_webhook_id(self, ha_url, favs=None) -> str or None: """ favs = favs if favs else self.device.favorites() - if 'http' not in favs: + if "http" not in favs: return None - for fav_id in favs['http']: - if favs['http'][fav_id]['value'] == ha_url: + for fav_id in favs["http"]: + if favs["http"][fav_id]["value"] == url: return fav_id return None @@ -326,11 +324,11 @@ def get_webhook_id(self, ha_url, favs=None) -> str or 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 + "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, } @@ -339,73 +337,33 @@ class DoorBirdRequestView(HomeAssistantView): requires_auth = False url = API_URL - name = API_URL[1:].replace('/', ':') - extra_urls = [API_URL + '/{sensor}'] + name = API_URL[1:].replace("/", ":") + extra_urls = [API_URL + "/{event}"] - def __init__(self, token): - """Initialize view.""" - HomeAssistantView.__init__(self) - self._token = token - - # pylint: disable=no-self-use - async def get(self, request, sensor): + async def get(self, request, event): """Respond to requests from the device.""" - from aiohttp import web - hass = request.app['hass'] + hass = request.app["hass"] - request_token = request.query.get('token') + token = request.query.get("token") - authenticated = request_token == self._token + device = get_doorstation_by_token(hass, token) - if request_token == '' or not authenticated: - return web.Response(status=401, text='Unauthorized') - - doorstation = get_doorstation_by_slug(hass, sensor) + if device is None: + return web.Response(status=401, text="Invalid token provided.") - if doorstation: - event_data = doorstation.get_event_data() + if device: + event_data = device.get_event_data() else: event_data = {} - hass.bus.async_fire('{}_{}'.format(DOMAIN, sensor), event_data) - - return web.Response(status=200, text='OK') - - -class DoorBirdCleanupView(HomeAssistantView): - """Provide a URL to call to delete ALL webhooks/schedules.""" - - requires_auth = False - url = API_URL + '/clear/{slug}' - name = 'DoorBird Cleanup' - - def __init__(self, token): - """Initialize view.""" - HomeAssistantView.__init__(self) - self._token = token - - # pylint: disable=no-self-use - async def get(self, request, slug): - """Act on requests.""" - from aiohttp import web - hass = request.app['hass'] + if event == "clear": + hass.bus.async_fire(RESET_DEVICE_FAVORITES, {"token": token}) - request_token = request.query.get('token') + message = f"HTTP Favorites cleared for {device.slug}" + return web.Response(status=HTTP_OK, text=message) - authenticated = request_token == self._token - - if request_token == '' or not authenticated: - return web.Response(status=401, text='Unauthorized') - - device = get_doorstation_by_slug(hass, slug) - - # No matching device - if device is None: - return web.Response(status=404, - text='Device slug {} not found'.format(slug)) + hass.bus.async_fire(f"{DOMAIN}_{event}", event_data) - hass.bus.async_fire(RESET_DEVICE_FAVORITES, - {'slug': slug}) + log_entry(hass, f"Doorbird {event}", "event was fired.", DOMAIN) - message = 'Clearing schedule for {}'.format(slug) - return web.Response(status=200, text=message) + return web.Response(status=HTTP_OK, text="OK") diff --git a/homeassistant/components/doorbird/camera.py b/homeassistant/components/doorbird/camera.py index 9a20a91c75855..bf99948958936 100644 --- a/homeassistant/components/doorbird/camera.py +++ b/homeassistant/components/doorbird/camera.py @@ -6,48 +6,73 @@ import aiohttp import async_timeout -from homeassistant.components.camera import Camera, SUPPORT_STREAM +from homeassistant.components.camera import SUPPORT_STREAM, Camera from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.util.dt as dt_util -from . import DOMAIN as DOORBIRD_DOMAIN +from .const import DOMAIN, DOOR_STATION, DOOR_STATION_INFO +from .entity import DoorBirdEntity -_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) +_LAST_VISITOR_INTERVAL = datetime.timedelta(minutes=2) +_LAST_MOTION_INTERVAL = datetime.timedelta(seconds=30) +_LIVE_INTERVAL = datetime.timedelta(seconds=45) _LOGGER = logging.getLogger(__name__) -_TIMEOUT = 10 # seconds +_TIMEOUT = 15 # seconds -async def async_setup_platform(hass, config, async_add_entities, - discovery_info=None): +async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the DoorBird camera platform.""" - for doorstation in hass.data[DOORBIRD_DOMAIN]: - device = doorstation.device - async_add_entities([ + config_entry_id = config_entry.entry_id + doorstation = hass.data[DOMAIN][config_entry_id][DOOR_STATION] + doorstation_info = hass.data[DOMAIN][config_entry_id][DOOR_STATION_INFO] + device = doorstation.device + + async_add_entities( + [ DoorBirdCamera( + doorstation, + doorstation_info, device.live_image_url, - _CAMERA_LIVE.format(doorstation.name), + "live", + f"{doorstation.name} Live", _LIVE_INTERVAL, - device.rtsp_live_video_url), + device.rtsp_live_video_url, + ), DoorBirdCamera( - device.history_image_url(1, 'doorbell'), - _CAMERA_LAST_VISITOR.format(doorstation.name), - _LAST_VISITOR_INTERVAL), + doorstation, + doorstation_info, + device.history_image_url(1, "doorbell"), + "last_ring", + f"{doorstation.name} Last Ring", + _LAST_VISITOR_INTERVAL, + ), DoorBirdCamera( - device.history_image_url(1, 'motionsensor'), - _CAMERA_LAST_MOTION.format(doorstation.name), - _LAST_MOTION_INTERVAL), - ]) - - -class DoorBirdCamera(Camera): + doorstation, + doorstation_info, + device.history_image_url(1, "motionsensor"), + "last_motion", + f"{doorstation.name} Last Motion", + _LAST_MOTION_INTERVAL, + ), + ] + ) + + +class DoorBirdCamera(DoorBirdEntity, Camera): """The camera on a DoorBird device.""" - def __init__(self, url, name, interval=None, stream_url=None): + def __init__( + self, + doorstation, + doorstation_info, + url, + camera_id, + name, + interval=None, + stream_url=None, + ): """Initialize the camera on a DoorBird device.""" + super().__init__(doorstation, doorstation_info) self._url = url self._stream_url = stream_url self._name = name @@ -55,13 +80,17 @@ def __init__(self, url, name, interval=None, stream_url=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__() + self._unique_id = f"{self._mac_addr}_{camera_id}" - @property - def stream_source(self): + async def stream_source(self): """Return the stream source.""" return self._stream_url + @property + def unique_id(self): + """Camera Unique id.""" + return self._unique_id + @property def supported_features(self): """Return supported features.""" @@ -74,22 +103,24 @@ def name(self): async def async_camera_image(self): """Pull a still image from the camera.""" - now = datetime.datetime.now() + now = dt_util.utcnow() if self._last_image and now - self._last_update < self._interval: return self._last_image try: websession = async_get_clientsession(self.hass) - with async_timeout.timeout(_TIMEOUT, loop=self.hass.loop): + 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") + _LOGGER.error("DoorBird %s: Camera image timed out", self._name) return self._last_image except aiohttp.ClientError as error: - _LOGGER.error("Error getting camera image: %s", error) + _LOGGER.error( + "DoorBird %s: Error getting camera image: %s", self._name, error + ) return self._last_image diff --git a/homeassistant/components/doorbird/config_flow.py b/homeassistant/components/doorbird/config_flow.py new file mode 100644 index 0000000000000..52f9411634421 --- /dev/null +++ b/homeassistant/components/doorbird/config_flow.py @@ -0,0 +1,174 @@ +"""Config flow for DoorBird integration.""" +from ipaddress import ip_address +import logging +import urllib + +from doorbirdpy import DoorBird +import voluptuous as vol + +from homeassistant import config_entries, core, exceptions +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import callback +from homeassistant.util.network import is_link_local + +from .const import CONF_EVENTS, DOORBIRD_OUI +from .const import DOMAIN # pylint:disable=unused-import +from .util import get_mac_address_from_doorstation_info + +_LOGGER = logging.getLogger(__name__) + + +def _schema_with_defaults(host=None, name=None): + return vol.Schema( + { + vol.Required(CONF_HOST, default=host): str, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + vol.Optional(CONF_NAME, default=name): str, + } + ) + + +async def validate_input(hass: core.HomeAssistant, data): + """Validate the user input allows us to connect. + + Data has the keys from DATA_SCHEMA with values provided by the user. + """ + device = DoorBird(data[CONF_HOST], data[CONF_USERNAME], data[CONF_PASSWORD]) + try: + status = await hass.async_add_executor_job(device.ready) + info = await hass.async_add_executor_job(device.info) + except urllib.error.HTTPError as err: + if err.code == 401: + raise InvalidAuth + raise CannotConnect + except OSError: + raise CannotConnect + + if not status[0]: + raise CannotConnect + + mac_addr = get_mac_address_from_doorstation_info(info) + + # Return info that you want to store in the config entry. + return {"title": data[CONF_HOST], "mac_addr": mac_addr} + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for DoorBird.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH + + def __init__(self): + """Initialize the DoorBird config flow.""" + self.discovery_schema = {} + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + if user_input is not None: + info, errors = await self._async_validate_or_error(user_input) + if not errors: + await self.async_set_unique_id(info["mac_addr"]) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=info["title"], data=user_input) + + data = self.discovery_schema or _schema_with_defaults() + return self.async_show_form(step_id="user", data_schema=data, errors=errors) + + async def async_step_zeroconf(self, discovery_info): + """Prepare configuration for a discovered doorbird device.""" + macaddress = discovery_info["properties"]["macaddress"] + + if macaddress[:6] != DOORBIRD_OUI: + return self.async_abort(reason="not_doorbird_device") + if is_link_local(ip_address(discovery_info[CONF_HOST])): + return self.async_abort(reason="link_local_address") + + await self.async_set_unique_id(macaddress) + + self._abort_if_unique_id_configured( + updates={CONF_HOST: discovery_info[CONF_HOST]} + ) + + chop_ending = "._axis-video._tcp.local." + friendly_hostname = discovery_info["name"] + if friendly_hostname.endswith(chop_ending): + friendly_hostname = friendly_hostname[: -len(chop_ending)] + + # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 + self.context["title_placeholders"] = { + CONF_NAME: friendly_hostname, + CONF_HOST: discovery_info[CONF_HOST], + } + self.discovery_schema = _schema_with_defaults( + host=discovery_info[CONF_HOST], name=friendly_hostname + ) + + return await self.async_step_user() + + async def async_step_import(self, user_input): + """Handle import.""" + if user_input: + info, errors = await self._async_validate_or_error(user_input) + if not errors: + await self.async_set_unique_id( + info["mac_addr"], raise_on_progress=False + ) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=info["title"], data=user_input) + return await self.async_step_user(user_input) + + async def _async_validate_or_error(self, user_input): + """Validate doorbird or error.""" + errors = {} + info = {} + try: + info = await validate_input(self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + return info, errors + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get the options flow for this handler.""" + return OptionsFlowHandler(config_entry) + + +class OptionsFlowHandler(config_entries.OptionsFlow): + """Handle a option flow for doorbird.""" + + def __init__(self, config_entry: config_entries.ConfigEntry): + """Initialize options flow.""" + self.config_entry = config_entry + + async def async_step_init(self, user_input=None): + """Handle options flow.""" + if user_input is not None: + events = [event.strip() for event in user_input[CONF_EVENTS].split(",")] + + return self.async_create_entry(title="", data={CONF_EVENTS: events}) + + current_events = self.config_entry.options.get(CONF_EVENTS, []) + + # We convert to a comma separated list for the UI + # since there really isn't anything better + options_schema = vol.Schema( + {vol.Optional(CONF_EVENTS, default=", ".join(current_events)): str} + ) + return self.async_show_form(step_id="init", data_schema=options_schema) + + +class CannotConnect(exceptions.HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(exceptions.HomeAssistantError): + """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/doorbird/const.py b/homeassistant/components/doorbird/const.py new file mode 100644 index 0000000000000..3b639fc8dca86 --- /dev/null +++ b/homeassistant/components/doorbird/const.py @@ -0,0 +1,17 @@ +"""The DoorBird integration constants.""" + + +DOMAIN = "doorbird" +PLATFORMS = ["switch", "camera"] +DOOR_STATION = "door_station" +DOOR_STATION_INFO = "door_station_info" +CONF_EVENTS = "events" +MANUFACTURER = "Bird Home Automation Group" +DOORBIRD_OUI = "1CCAE3" + +DOORBIRD_INFO_KEY_FIRMWARE = "FIRMWARE" +DOORBIRD_INFO_KEY_BUILD_NUMBER = "BUILD_NUMBER" +DOORBIRD_INFO_KEY_DEVICE_TYPE = "DEVICE-TYPE" +DOORBIRD_INFO_KEY_RELAYS = "RELAYS" +DOORBIRD_INFO_KEY_PRIMARY_MAC_ADDR = "PRIMARY_MAC_ADDR" +DOORBIRD_INFO_KEY_WIFI_MAC_ADDR = "WIFI_MAC_ADDR" diff --git a/homeassistant/components/doorbird/entity.py b/homeassistant/components/doorbird/entity.py new file mode 100644 index 0000000000000..44cbb1f42deee --- /dev/null +++ b/homeassistant/components/doorbird/entity.py @@ -0,0 +1,36 @@ +"""The DoorBird integration base entity.""" + +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.entity import Entity + +from .const import ( + DOORBIRD_INFO_KEY_BUILD_NUMBER, + DOORBIRD_INFO_KEY_DEVICE_TYPE, + DOORBIRD_INFO_KEY_FIRMWARE, + MANUFACTURER, +) +from .util import get_mac_address_from_doorstation_info + + +class DoorBirdEntity(Entity): + """Base class for doorbird entities.""" + + def __init__(self, doorstation, doorstation_info): + """Initialize the entity.""" + super().__init__() + self._doorstation_info = doorstation_info + self._doorstation = doorstation + self._mac_addr = get_mac_address_from_doorstation_info(doorstation_info) + + @property + def device_info(self): + """Doorbird device info.""" + firmware = self._doorstation_info[DOORBIRD_INFO_KEY_FIRMWARE] + firmware_build = self._doorstation_info[DOORBIRD_INFO_KEY_BUILD_NUMBER] + return { + "connections": {(dr.CONNECTION_NETWORK_MAC, self._mac_addr)}, + "name": self._doorstation.name, + "manufacturer": MANUFACTURER, + "sw_version": f"{firmware} {firmware_build}", + "model": self._doorstation_info[DOORBIRD_INFO_KEY_DEVICE_TYPE], + } diff --git a/homeassistant/components/doorbird/manifest.json b/homeassistant/components/doorbird/manifest.json index 3fb9fdc753b7d..6c1c75ff328ed 100644 --- a/homeassistant/components/doorbird/manifest.json +++ b/homeassistant/components/doorbird/manifest.json @@ -1,12 +1,10 @@ { "domain": "doorbird", - "name": "Doorbird", - "documentation": "https://www.home-assistant.io/components/doorbird", - "requirements": [ - "doorbirdpy==2.0.8" - ], - "dependencies": [], - "codeowners": [ - "@oblogic7" - ] + "name": "DoorBird", + "documentation": "https://www.home-assistant.io/integrations/doorbird", + "requirements": ["doorbirdpy==2.0.8"], + "dependencies": ["http", "logbook"], + "zeroconf": ["_axis-video._tcp.local."], + "codeowners": ["@oblogic7", "@bdraco"], + "config_flow": true } diff --git a/homeassistant/components/doorbird/strings.json b/homeassistant/components/doorbird/strings.json new file mode 100644 index 0000000000000..e27083d2e0933 --- /dev/null +++ b/homeassistant/components/doorbird/strings.json @@ -0,0 +1,34 @@ +{ + "options": { + "step": { + "init": { + "data": { "events": "Comma separated list of events." }, + "description": "Add an comma separated event name for each event you wish to track. After entering them here, use the DoorBird app to assign them to a specific event. See the documentation at https://www.home-assistant.io/integrations/doorbird/#events. Example: somebody_pressed_the_button, motion" + } + } + }, + "config": { + "step": { + "user": { + "title": "Connect to the DoorBird", + "data": { + "password": "Password", + "host": "Host (IP Address)", + "name": "Device Name", + "username": "Username" + } + } + }, + "abort": { + "already_configured": "This DoorBird is already configured", + "link_local_address": "Link local addresses are not supported", + "not_doorbird_device": "This device is not a DoorBird" + }, + "flow_title": "DoorBird {name} ({host})", + "error": { + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error", + "cannot_connect": "Failed to connect, please try again" + } + } +} diff --git a/homeassistant/components/doorbird/switch.py b/homeassistant/components/doorbird/switch.py index f3b1f5f059e65..1e4cb81a5eb67 100644 --- a/homeassistant/components/doorbird/switch.py +++ b/homeassistant/components/doorbird/switch.py @@ -2,35 +2,41 @@ import datetime import logging -from homeassistant.components.switch import SwitchDevice +from homeassistant.components.switch import SwitchEntity +import homeassistant.util.dt as dt_util -from . import DOMAIN as DOORBIRD_DOMAIN +from .const import DOMAIN, DOOR_STATION, DOOR_STATION_INFO +from .entity import DoorBirdEntity _LOGGER = logging.getLogger(__name__) -IR_RELAY = '__ir_light__' +IR_RELAY = "__ir_light__" -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the DoorBird switch platform.""" - switches = [] + entities = [] + config_entry_id = config_entry.entry_id - for doorstation in hass.data[DOORBIRD_DOMAIN]: - relays = doorstation.device.info()['RELAYS'] - relays.append(IR_RELAY) + doorstation = hass.data[DOMAIN][config_entry_id][DOOR_STATION] + doorstation_info = hass.data[DOMAIN][config_entry_id][DOOR_STATION_INFO] - for relay in relays: - switch = DoorBirdSwitch(doorstation, relay) - switches.append(switch) + relays = doorstation_info["RELAYS"] + relays.append(IR_RELAY) - add_entities(switches) + for relay in relays: + switch = DoorBirdSwitch(doorstation, doorstation_info, relay) + entities.append(switch) + async_add_entities(entities) -class DoorBirdSwitch(SwitchDevice): + +class DoorBirdSwitch(DoorBirdEntity, SwitchEntity): """A relay in a DoorBird device.""" - def __init__(self, doorstation, relay): + def __init__(self, doorstation, doorstation_info, relay): """Initialize a relay in a DoorBird device.""" + super().__init__(doorstation, doorstation_info) self._doorstation = doorstation self._relay = relay self._state = False @@ -40,14 +46,20 @@ def __init__(self, doorstation, relay): self._time = datetime.timedelta(minutes=5) else: self._time = datetime.timedelta(seconds=5) + self._unique_id = f"{self._mac_addr}_{self._relay}" + + @property + def unique_id(self): + """Switch unique id.""" + return self._unique_id @property def name(self): """Return the name of the switch.""" if self._relay == IR_RELAY: - return "{} IR".format(self._doorstation.name) + return f"{self._doorstation.name} IR" - return "{} Relay {}".format(self._doorstation.name, self._relay) + return f"{self._doorstation.name} Relay {self._relay}" @property def icon(self): @@ -66,16 +78,15 @@ def turn_on(self, **kwargs): else: self._state = self._doorstation.device.energize_relay(self._relay) - now = datetime.datetime.now() + now = dt_util.utcnow() 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.") + 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(): + if self._state and self._assume_off <= dt_util.utcnow(): self._state = False self._assume_off = datetime.datetime.min diff --git a/homeassistant/components/doorbird/translations/ca.json b/homeassistant/components/doorbird/translations/ca.json new file mode 100644 index 0000000000000..2e87939f44e90 --- /dev/null +++ b/homeassistant/components/doorbird/translations/ca.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "Aquest dispositiu DoorBird ja est\u00e0 configurat", + "link_local_address": "L'enlla\u00e7 amb adreces locals no est\u00e0 perm\u00e8s", + "not_doorbird_device": "Aquest dispositiu no \u00e9s DoorBird" + }, + "error": { + "cannot_connect": "No s'ha pogut connectar, torna-ho a provar", + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", + "unknown": "Error inesperat" + }, + "flow_title": "DoorBird {name} ({host})", + "step": { + "user": { + "data": { + "host": "Amfitri\u00f3 (adre\u00e7a IP)", + "name": "Nom del dispositiu", + "password": "Contrasenya", + "username": "Nom d'usuari" + }, + "title": "Connexi\u00f3 amb DoorBird" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "events": "Llista d'esdeveniments separats per comes." + }, + "description": "Afegeix el/s noms del/s esdeveniment/s que vulguis seguir separats per comes. Despr\u00e9s d'introduir-los, utilitzeu l'aplicaci\u00f3 de DoorBird per assignar-los a un esdeveniment espec\u00edfic. Consulta la documentaci\u00f3 a https://www.home-assistant.io/integrations/doorbird/#events.\nExemple: algu_ha_premut_el_boto, moviment_detectat" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/doorbird/translations/da.json b/homeassistant/components/doorbird/translations/da.json new file mode 100644 index 0000000000000..3e66091d85109 --- /dev/null +++ b/homeassistant/components/doorbird/translations/da.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "Adgangskode", + "username": "Brugernavn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/doorbird/translations/de.json b/homeassistant/components/doorbird/translations/de.json new file mode 100644 index 0000000000000..ad9d99e555dd8 --- /dev/null +++ b/homeassistant/components/doorbird/translations/de.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "Dieser DoorBird ist bereits konfiguriert", + "link_local_address": "Lokale Linkadressen werden nicht unterst\u00fctzt", + "not_doorbird_device": "Dieses Ger\u00e4t ist kein DoorBird" + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen, versuchen Sie es erneut", + "invalid_auth": "Ung\u00fcltige Authentifizierung", + "unknown": "Unerwarteter Fehler" + }, + "flow_title": "DoorBird {name} ({host})", + "step": { + "user": { + "data": { + "host": "Host (IP-Adresse)", + "name": "Ger\u00e4tename", + "password": "Passwort", + "username": "Benutzername" + }, + "title": "Stellen Sie eine Verbindung zu DoorBird her" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "events": "Durch Kommas getrennte Liste von Ereignissen." + }, + "description": "F\u00fcgen Sie f\u00fcr jedes Ereignis, das Sie verfolgen m\u00f6chten, einen durch Kommas getrennten Ereignisnamen hinzu. Nachdem Sie sie hier eingegeben haben, verwenden Sie die DoorBird-App, um sie einem bestimmten Ereignis zuzuweisen. Weitere Informationen finden Sie in der Dokumentation unter https://www.home-assistant.io/integrations/doorbird/#events. Beispiel: jemand_hat_den_knopf_gedr\u00fcckt, bewegung" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/doorbird/translations/en.json b/homeassistant/components/doorbird/translations/en.json new file mode 100644 index 0000000000000..7e3ba8037715f --- /dev/null +++ b/homeassistant/components/doorbird/translations/en.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "This DoorBird is already configured", + "link_local_address": "Link local addresses are not supported", + "not_doorbird_device": "This device is not a DoorBird" + }, + "error": { + "cannot_connect": "Failed to connect, please try again", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "flow_title": "DoorBird {name} ({host})", + "step": { + "user": { + "data": { + "host": "Host (IP Address)", + "name": "Device Name", + "password": "Password", + "username": "Username" + }, + "title": "Connect to the DoorBird" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "events": "Comma separated list of events." + }, + "description": "Add an comma separated event name for each event you wish to track. After entering them here, use the DoorBird app to assign them to a specific event. See the documentation at https://www.home-assistant.io/integrations/doorbird/#events. Example: somebody_pressed_the_button, motion" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/doorbird/translations/es-419.json b/homeassistant/components/doorbird/translations/es-419.json new file mode 100644 index 0000000000000..1a412b38246e2 --- /dev/null +++ b/homeassistant/components/doorbird/translations/es-419.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "not_doorbird_device": "Este dispositivo no es un DoorBird" + }, + "error": { + "cannot_connect": "No se pudo conectar, intente nuevamente", + "unknown": "Error inesperado" + }, + "step": { + "user": { + "data": { + "name": "Nombre del dispositivo", + "password": "Contrase\u00f1a", + "username": "Nombre de usuario" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "events": "Lista de eventos separados por comas." + }, + "description": "Agregue un nombre de evento separado por comas para cada evento que desee rastrear. Despu\u00e9s de ingresarlos aqu\u00ed, use la aplicaci\u00f3n DoorBird para asignarlos a un evento espec\u00edfico. Consulte la documentaci\u00f3n en https://www.home-assistant.io/integrations/doorbird/#events. Ejemplo: somebody_pressed_the_button, motion" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/doorbird/translations/es.json b/homeassistant/components/doorbird/translations/es.json new file mode 100644 index 0000000000000..70717aef6ad8a --- /dev/null +++ b/homeassistant/components/doorbird/translations/es.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "DoorBird ya est\u00e1 configurado", + "link_local_address": "No se admiten direcciones locales", + "not_doorbird_device": "Este dispositivo no es un DoorBird" + }, + "error": { + "cannot_connect": "No se pudo conectar, por favor int\u00e9ntalo de nuevo", + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", + "unknown": "Error inesperado" + }, + "flow_title": "DoorBird {name} ({host})", + "step": { + "user": { + "data": { + "host": "Host (Direcci\u00f3n IP)", + "name": "Nombre del dispositivo", + "password": "Contrase\u00f1a", + "username": "Usuario" + }, + "title": "Conectar con DoorBird" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "events": "Lista de eventos separados por comas." + }, + "description": "A\u00f1ade un nombre de evento separado por comas para cada evento del que deseas realizar un seguimiento. Despu\u00e9s de introducirlos aqu\u00ed, utiliza la aplicaci\u00f3n DoorBird para asignarlos a un evento espec\u00edfico. Consulta la documentaci\u00f3n en https://www.home-assistant.io/integrations/doorbird/#events. Ejemplo: somebody_pressed_the_button, motion" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/doorbird/translations/fr.json b/homeassistant/components/doorbird/translations/fr.json new file mode 100644 index 0000000000000..c208e035d5ae0 --- /dev/null +++ b/homeassistant/components/doorbird/translations/fr.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "Ce DoorBird est d\u00e9j\u00e0 configur\u00e9", + "not_doorbird_device": "Cet appareil n'est pas un DoorBird" + }, + "error": { + "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer", + "invalid_auth": "Authentification non valide", + "unknown": "Erreur inattendue" + }, + "step": { + "user": { + "data": { + "host": "H\u00f4te (adresse IP)", + "name": "Nom de l'appareil", + "password": "Mot de passe", + "username": "Nom d'utilisateur" + }, + "title": "Connectez-vous au DoorBird" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "events": "Liste d'\u00e9v\u00e9nements s\u00e9par\u00e9s par des virgules." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/doorbird/translations/it.json b/homeassistant/components/doorbird/translations/it.json new file mode 100644 index 0000000000000..c08c666d6f87a --- /dev/null +++ b/homeassistant/components/doorbird/translations/it.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "Questo DoorBird \u00e8 gi\u00e0 configurato", + "link_local_address": "Gli indirizzi locali di collegamento non sono supportati", + "not_doorbird_device": "Questo dispositivo non \u00e8 un DoorBird" + }, + "error": { + "cannot_connect": "Impossibile connettersi, si prega di riprovare", + "invalid_auth": "Autenticazione non valida", + "unknown": "Errore imprevisto" + }, + "flow_title": "DoorBird {name} ({host})", + "step": { + "user": { + "data": { + "host": "Host (indirizzo IP)", + "name": "Nome del dispositivo", + "password": "Password", + "username": "Nome utente" + }, + "title": "Connetti a DoorBird" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "events": "Elenco di eventi separati da virgole." + }, + "description": "Aggiungere un nome di evento separato da virgola per ogni evento che si desidera monitorare. Dopo averli inseriti qui, usa l'applicazione DoorBird per assegnarli a un evento specifico. Consultare la documentazione su https://www.home-assistant.io/integrations/doorbird/#events. Esempio: qualcuno_premuto_il_pulsante, movimento" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/doorbird/translations/ko.json b/homeassistant/components/doorbird/translations/ko.json new file mode 100644 index 0000000000000..72632afed89b4 --- /dev/null +++ b/homeassistant/components/doorbird/translations/ko.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "\uc774 DoorBird \ub294 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "link_local_address": "\ub85c\uceec \uc8fc\uc18c \uc5f0\uacb0\uc740 \uc9c0\uc6d0\ub418\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4", + "not_doorbird_device": "\uc774 \uae30\uae30\ub294 DoorBird \uac00 \uc544\ub2d9\ub2c8\ub2e4" + }, + "error": { + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.", + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" + }, + "flow_title": "DoorBird {name} ({host})", + "step": { + "user": { + "data": { + "host": "\ud638\uc2a4\ud2b8 (IP \uc8fc\uc18c)", + "name": "\uae30\uae30 \uc774\ub984", + "password": "\ube44\ubc00\ubc88\ud638", + "username": "\uc0ac\uc6a9\uc790 \uc774\ub984" + }, + "title": "DoorBird \uc5d0 \uc5f0\uacb0\ud558\uae30" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "events": "\uc27c\ud45c\ub85c \uad6c\ubd84\ub41c \uc774\ubca4\ud2b8 \ubaa9\ub85d." + }, + "description": "\ucd94\uc801\ud558\ub824\ub294 \uac01 \uc774\ubca4\ud2b8\uc5d0 \ub300\ud574 \uc27c\ud45c\ub85c \uad6c\ubd84\ub41c \uc774\ubca4\ud2b8 \uc774\ub984\uc744 \ucd94\uac00\ud574\uc8fc\uc138\uc694. \uc5ec\uae30\uc5d0 \uc785\ub825\ud55c \ud6c4 DoorBird \uc571\uc744 \uc0ac\uc6a9\ud558\uc5ec \ud2b9\uc815 \uc774\ubca4\ud2b8\uc5d0 \ud560\ub2f9\ud574\uc8fc\uc138\uc694. \uc790\uc138\ud55c \ub0b4\uc6a9\uc740 https://www.home-assistant.io/integrations/doorbird/#event \uc124\uba85\uc11c\ub97c \ucc38\uc870\ud574\uc8fc\uc138\uc694. \uc608: someone_pressed_the_button, motion" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/doorbird/translations/lb.json b/homeassistant/components/doorbird/translations/lb.json new file mode 100644 index 0000000000000..b41931be828d2 --- /dev/null +++ b/homeassistant/components/doorbird/translations/lb.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "D\u00ebse DoorBird ass scho konfigur\u00e9iert", + "link_local_address": "Lokal Link Adressen ginn net \u00ebnnerst\u00ebtzt", + "not_doorbird_device": "D\u00ebsen Apparat ass kee DoorBird" + }, + "error": { + "cannot_connect": "Feeler beim verbannen, prob\u00e9ier w.e.g. nach emol.", + "invalid_auth": "Ong\u00eblteg Authentifikatioun", + "unknown": "Onerwaarte Feeler" + }, + "flow_title": "DoorBird {name} ({host})", + "step": { + "user": { + "data": { + "host": "Numm (IP Adresse)", + "name": "Numm vum Apparat", + "password": "Passwuert", + "username": "Benotzernumm" + }, + "title": "Mat DoorBird verbannen" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "events": "Komma getrennte L\u00ebscht vun Evenementer" + }, + "description": "Setzt ee mat Komma getrennten Evenement Numm fir all Evenement dob\u00e4i d\u00e9i sollt suiv\u00e9iert ginn. Wann's du se hei aginn hues, benotz d'DoorBird App fir se zu engem spezifeschen Evenement dob\u00e4i ze setzen. Kuckt d'Dokumentatioun op https://www.home-assistant.io/integrations/doorbird/#events. Beispill: somebody_pressed_the_button, motion" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/doorbird/translations/nl.json b/homeassistant/components/doorbird/translations/nl.json new file mode 100644 index 0000000000000..625367484b0ac --- /dev/null +++ b/homeassistant/components/doorbird/translations/nl.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "Deze DoorBird is al geconfigureerd", + "link_local_address": "Link-lokale adressen worden niet ondersteund", + "not_doorbird_device": "Dit apparaat is geen DoorBird" + }, + "error": { + "cannot_connect": "Verbinding mislukt, probeer het opnieuw", + "invalid_auth": "Ongeldige authenticatie", + "unknown": "Onverwachte fout" + }, + "flow_title": "DoorBird {name} ({host})", + "step": { + "user": { + "data": { + "host": "Host (IP-adres)", + "name": "Apparaatnaam", + "password": "Wachtwoord", + "username": "Gebruikersnaam" + }, + "title": "Maak verbinding met de DoorBird" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "events": "Door komma's gescheiden lijst met gebeurtenissen." + }, + "description": "Voeg een door komma's gescheiden evenementnaam toe voor elk evenement dat u wilt volgen. Nadat je ze hier hebt ingevoerd, gebruik je de DoorBird-app om ze toe te wijzen aan een specifiek evenement. Zie de documentatie op https://www.home-assistant.io/integrations/doorbird/#events. Voorbeeld: iemand_drukte_knop, beweging" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/doorbird/translations/no.json b/homeassistant/components/doorbird/translations/no.json new file mode 100644 index 0000000000000..158b783406a7e --- /dev/null +++ b/homeassistant/components/doorbird/translations/no.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "Denne DoorBird er allerede konfigurert", + "link_local_address": "Linking av lokale adresser st\u00f8ttes ikke", + "not_doorbird_device": "Denne enheten er ikke en DoorBird" + }, + "error": { + "cannot_connect": "Klarte ikke \u00e5 koble til, vennligst pr\u00f8v igjen", + "invalid_auth": "Ugyldig godkjenning", + "unknown": "Uventet feil" + }, + "flow_title": "DoorBird {name} ( {host} )", + "step": { + "user": { + "data": { + "host": "Vert (IP-adresse)", + "name": "Enhetsnavn", + "password": "Passord", + "username": "Brukernavn" + }, + "title": "Koble til DoorBird" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "events": "Kommaseparert liste over hendelser." + }, + "description": "Legg til et kommaseparert hendelsesnavn for hvert arrangement du \u00f8nsker \u00e5 spore. Etter \u00e5 ha skrevet dem inn her, bruker du DoorBird-appen til \u00e5 tilordne dem til en bestemt hendelse. Se dokumentasjonen p\u00e5 https://www.home-assistant.io/integrations/doorbird/#events. Eksempel: noen_trykket_knappen, bevegelse" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/doorbird/translations/pl.json b/homeassistant/components/doorbird/translations/pl.json new file mode 100644 index 0000000000000..6151129746815 --- /dev/null +++ b/homeassistant/components/doorbird/translations/pl.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "BoorBird jest ju\u017c skonfigurowany.", + "link_local_address": "Po\u0142\u0105czenie lokalnego adresu nie jest obs\u0142ugiwane", + "not_doorbird_device": "To urz\u0105dzenie nie jest urz\u0105dzeniem DoorBird" + }, + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia, spr\u00f3buj ponownie.", + "invalid_auth": "Niepoprawne uwierzytelnienie.", + "unknown": "Niespodziewany b\u0142\u0105d." + }, + "flow_title": "DoorBird {name} ({host})", + "step": { + "user": { + "data": { + "host": "Nazwa hosta lub adres IP", + "name": "Nazwa urz\u0105dzenia", + "password": "Has\u0142o", + "username": "Nazwa u\u017cytkownika" + }, + "title": "Po\u0142\u0105czenie z DoorBird" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "events": "Lista wydarze\u0144 oddzielona przecinkami" + }, + "description": "Dodaj nazwy wydarze\u0144 oddzielonych przecinkami, kt\u00f3re chcesz \u015bledzi\u0107. Po wprowadzeniu ich tutaj u\u017cyj aplikacji DoorBird, aby przypisa\u0107 je do okre\u015blonych zdarze\u0144. Zapoznaj si\u0119 z dokumentacj\u0105 na stronie https://www.home-assistant.io/integrations/doorbird/#events.\nPrzyk\u0142ad: nacisnienie_przycisku, ruch" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/doorbird/translations/pt.json b/homeassistant/components/doorbird/translations/pt.json new file mode 100644 index 0000000000000..6515658d6a71a --- /dev/null +++ b/homeassistant/components/doorbird/translations/pt.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "invalid_auth": "Autentica\u00e7\u00e3o inv\u00e1lida", + "unknown": "Erro inesperado" + }, + "step": { + "user": { + "data": { + "name": "Nome do dispositivo", + "password": "Palavra-passe", + "username": "Nome de Utilizador" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/doorbird/translations/ru.json b/homeassistant/components/doorbird/translations/ru.json new file mode 100644 index 0000000000000..c6a638f2530a0 --- /dev/null +++ b/homeassistant/components/doorbird/translations/ru.json @@ -0,0 +1,36 @@ +{ + "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.", + "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.", + "not_doorbird_device": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0435 DoorBird." + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f, \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0435\u0449\u0435 \u0440\u0430\u0437.", + "invalid_auth": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "flow_title": "DoorBird {name} ({host})", + "step": { + "user": { + "data": { + "host": "\u0414\u043e\u043c\u0435\u043d\u043d\u043e\u0435 \u0438\u043c\u044f \u0438\u043b\u0438 IP-\u0430\u0434\u0440\u0435\u0441", + "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435", + "password": "\u041f\u0430\u0440\u043e\u043b\u044c", + "username": "\u041b\u043e\u0433\u0438\u043d" + }, + "title": "\u041f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435 \u043a DoorBird" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "events": "\u0421\u043f\u0438\u0441\u043e\u043a \u0441\u043e\u0431\u044b\u0442\u0438\u0439 \u0447\u0435\u0440\u0435\u0437 \u0437\u0430\u043f\u044f\u0442\u0443\u044e." + }, + "description": "\u0414\u043e\u0431\u0430\u0432\u044c\u0442\u0435 \u0447\u0435\u0440\u0435\u0437 \u0437\u0430\u043f\u044f\u0442\u0443\u044e \u043d\u0430\u0437\u0432\u0430\u043d\u0438\u044f \u0441\u043e\u0431\u044b\u0442\u0438\u0439, \u043a\u043e\u0442\u043e\u0440\u043e\u0435 \u0412\u044b \u0445\u043e\u0442\u0438\u0442\u0435 \u043e\u0442\u0441\u043b\u0435\u0436\u0438\u0432\u0430\u0442\u044c. \u041f\u043e\u0441\u043b\u0435 \u044d\u0442\u043e\u0433\u043e, \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0439\u0442\u0435 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435 DoorBird, \u0447\u0442\u043e\u0431\u044b \u043d\u0430\u0437\u043d\u0430\u0447\u0438\u0442\u044c \u0438\u0445 \u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u043d\u043e\u043c\u0443 \u0441\u043e\u0431\u044b\u0442\u0438\u044e. \u041f\u0440\u0438\u043c\u0435\u0440: somebody_pressed_the_button, motion. \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 \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: https://www.home-assistant.io/integrations/doorbird/#events." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/doorbird/translations/sl.json b/homeassistant/components/doorbird/translations/sl.json new file mode 100644 index 0000000000000..336a40904d2fe --- /dev/null +++ b/homeassistant/components/doorbird/translations/sl.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "Ta DoorBird je \u017ee nastavljen", + "link_local_address": "Lokalni naslovi povezav niso podprti", + "not_doorbird_device": "Ta naprava ni DoorBird" + }, + "error": { + "cannot_connect": "Povezava ni uspela, poskusite znova", + "invalid_auth": "Neveljavna avtentikacija", + "unknown": "Nepri\u010dakovana napaka" + }, + "flow_title": "DoorBird {name} ({host})", + "step": { + "user": { + "data": { + "host": "Gostitelj (IP naslov)", + "name": "Ime naprave", + "password": "Geslo", + "username": "Uporabni\u0161ko ime" + }, + "title": "Pove\u017eite se z DoorBird" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "events": "Seznam dogodkov, lo\u010denih z vejico." + }, + "description": "Za vsak dogodek, ki ga \u017eelite spremljati, dodajte ime, lo\u010deno z vejico. Ko jih tukaj vnesete, uporabite aplikacijo DoorBird, da jih dodelite dolo\u010denemu dogodku. Glej dokumentacijo na strani https://www.home-assistant.io/integrations/doorbird/#events. Primer: nekdo_pritisnil_gumb, gibanje" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/doorbird/translations/sv.json b/homeassistant/components/doorbird/translations/sv.json new file mode 100644 index 0000000000000..8025b956a17fe --- /dev/null +++ b/homeassistant/components/doorbird/translations/sv.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "cannot_connect": "Det gick inte att ansluta, f\u00f6rs\u00f6k igen", + "invalid_auth": "Ogiltig autentisering", + "unknown": "Ov\u00e4ntat fel" + }, + "step": { + "user": { + "data": { + "host": "V\u00e4rd (IP-adress)", + "name": "Enhetsnamn", + "password": "L\u00f6senord", + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/doorbird/translations/zh-Hant.json b/homeassistant/components/doorbird/translations/zh-Hant.json new file mode 100644 index 0000000000000..b751d1bcf8396 --- /dev/null +++ b/homeassistant/components/doorbird/translations/zh-Hant.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "\u6b64 DoorBird \u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "link_local_address": "\u4e0d\u652f\u63f4\u9023\u7d50\u672c\u5730\u7aef\u4f4d\u5740", + "not_doorbird_device": "\u6b64\u8a2d\u5099\u4e26\u975e DoorBird" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557\uff0c\u8acb\u518d\u8a66\u4e00\u6b21", + "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "flow_title": "DoorBird {name} ({host})", + "step": { + "user": { + "data": { + "host": "\u4e3b\u6a5f\uff08IP \u4f4d\u5740\uff09", + "name": "\u8a2d\u5099\u540d\u7a31", + "password": "\u5bc6\u78bc", + "username": "\u4f7f\u7528\u8005\u540d\u7a31" + }, + "title": "\u9023\u7dda\u81f3 DoorBird" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "events": "\u4ee5\u9017\u865f\u5206\u5225\u4e8b\u4ef6\u5217\u8868\u3002" + }, + "description": "\u4ee5\u9017\u865f\u5206\u5225\u6240\u8981\u8ffd\u8e64\u7684\u4e8b\u4ef6\u540d\u7a31\u3002\u65bc\u6b64\u8f38\u5165\u5f8c\uff0c\u4f7f\u7528 DoorBird App \u6307\u5b9a\u81f3\u7279\u5b9a\u4e8b\u4ef6\u3002\u8acb\u53c3\u95b1\u6587\u4ef6\uff1ahttps://www.home-assistant.io/integrations/doorbird/#events\u3002\u4f8b\u5982\uff1asomebody_pressed_the_button, motion" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/doorbird/util.py b/homeassistant/components/doorbird/util.py new file mode 100644 index 0000000000000..7db9063580d93 --- /dev/null +++ b/homeassistant/components/doorbird/util.py @@ -0,0 +1,19 @@ +"""DoorBird integration utils.""" + +from .const import DOMAIN, DOOR_STATION + + +def get_mac_address_from_doorstation_info(doorstation_info): + """Get the mac address depending on the device type.""" + if "PRIMARY_MAC_ADDR" in doorstation_info: + return doorstation_info["PRIMARY_MAC_ADDR"] + return doorstation_info["WIFI_MAC_ADDR"] + + +def get_doorstation_by_token(hass, token): + """Get doorstation by slug.""" + for config_entry_id in hass.data[DOMAIN]: + doorstation = hass.data[DOMAIN][config_entry_id][DOOR_STATION] + + if token == doorstation.token: + return doorstation diff --git a/homeassistant/components/dovado/__init__.py b/homeassistant/components/dovado/__init__.py index 2a240c2a79ec7..8e4c712e8b4b1 100644 --- a/homeassistant/components/dovado/__init__.py +++ b/homeassistant/components/dovado/__init__.py @@ -1,37 +1,51 @@ """Support for Dovado router.""" -import logging from datetime import timedelta +import logging +import dovado 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) + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + DEVICE_DEFAULT_NAME, +) +import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle _LOGGER = logging.getLogger(__name__) -DOMAIN = 'dovado' +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, -}) +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: 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, + } + ) + }, + extra=vol.ALLOW_EXTRA, +) 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)) + config[DOMAIN][CONF_USERNAME], + config[DOMAIN][CONF_PASSWORD], + config[DOMAIN].get(CONF_HOST), + config[DOMAIN].get(CONF_PORT), + ) ) return True @@ -56,8 +70,7 @@ def update(self): self.state = self._client.state or {} if not self.state: return False - self.state.update( - connected=self.state.get("modem status") == "CONNECTED") + self.state.update(connected=self.state.get("modem status") == "CONNECTED") _LOGGER.debug("Received: %s", self.state) return True except OSError as error: diff --git a/homeassistant/components/dovado/manifest.json b/homeassistant/components/dovado/manifest.json index 122d774c26822..0a2a52cb21d44 100644 --- a/homeassistant/components/dovado/manifest.json +++ b/homeassistant/components/dovado/manifest.json @@ -1,10 +1,7 @@ { "domain": "dovado", "name": "Dovado", - "documentation": "https://www.home-assistant.io/components/dovado", - "requirements": [ - "dovado==0.4.1" - ], - "dependencies": [], + "documentation": "https://www.home-assistant.io/integrations/dovado", + "requirements": ["dovado==0.4.1"], "codeowners": [] } diff --git a/homeassistant/components/dovado/notify.py b/homeassistant/components/dovado/notify.py index f9d9e5574a103..02ce994b1df95 100644 --- a/homeassistant/components/dovado/notify.py +++ b/homeassistant/components/dovado/notify.py @@ -1,8 +1,7 @@ """Support for SMS notifications from the Dovado router.""" import logging -from homeassistant.components.notify import ( - ATTR_TARGET, BaseNotificationService) +from homeassistant.components.notify import ATTR_TARGET, BaseNotificationService from . import DOMAIN as DOVADO_DOMAIN diff --git a/homeassistant/components/dovado/sensor.py b/homeassistant/components/dovado/sensor.py index 7a825118fc6b5..8328df8bd7f06 100644 --- a/homeassistant/components/dovado/sensor.py +++ b/homeassistant/components/dovado/sensor.py @@ -6,7 +6,7 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import CONF_SENSORS +from homeassistant.const import CONF_SENSORS, DATA_GIGABYTES, UNIT_PERCENTAGE import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -16,26 +16,33 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30) -SENSOR_UPLOAD = 'upload' -SENSOR_DOWNLOAD = 'download' -SENSOR_SIGNAL = 'signal' -SENSOR_NETWORK = 'network' -SENSOR_SMS_UNREAD = 'sms' +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'), + SENSOR_NETWORK: ("signal strength", "Network", None, "mdi:access-point-network"), + SENSOR_SIGNAL: ( + "signal strength", + "Signal Strength", + UNIT_PERCENTAGE, + "mdi:signal", + ), + SENSOR_SMS_UNREAD: ("sms unread", "SMS unread", "", "mdi:message-text-outline"), + SENSOR_UPLOAD: ("traffic modem tx", "Sent", DATA_GIGABYTES, "mdi:cloud-upload"), + SENSOR_DOWNLOAD: ( + "traffic modem rx", + "Received", + DATA_GIGABYTES, + "mdi:cloud-download", + ), } -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_SENSORS): vol.All(cv.ensure_list, [vol.In(SENSORS)]), -}) +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): @@ -83,7 +90,7 @@ def update(self): @property def name(self): """Return the name of the sensor.""" - return "{} {}".format(self._data.name, SENSORS[self._sensor][1]) + return f"{self._data.name} {SENSORS[self._sensor][1]}" @property def state(self): @@ -103,5 +110,4 @@ def unit_of_measurement(self): @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']} + return {k: v for k, v in self._data.state.items() if k not in ["date", "time"]} diff --git a/homeassistant/components/downloader/__init__.py b/homeassistant/components/downloader/__init__.py index 5af367ef92d45..0c87f04e3abfc 100644 --- a/homeassistant/components/downloader/__init__.py +++ b/homeassistant/components/downloader/__init__.py @@ -7,55 +7,58 @@ import requests import voluptuous as vol +from homeassistant.const import HTTP_OK 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' +ATTR_FILENAME = "filename" +ATTR_SUBDIR = "subdir" +ATTR_URL = "url" +ATTR_OVERWRITE = "overwrite" -CONF_DOWNLOAD_DIR = 'download_dir' +CONF_DOWNLOAD_DIR = "download_dir" -DOMAIN = 'downloader' -DOWNLOAD_FAILED_EVENT = 'download_failed' -DOWNLOAD_COMPLETED_EVENT = 'download_completed' +DOMAIN = "downloader" +DOWNLOAD_FAILED_EVENT = "download_failed" +DOWNLOAD_COMPLETED_EVENT = "download_completed" -SERVICE_DOWNLOAD_FILE = 'download_file' +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, -}) +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) +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 path is relative, we assume relative to Home Assistant 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) + "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: @@ -74,17 +77,20 @@ def do_download(): req = requests.get(url, stream=True, timeout=10) - if req.status_code != 200: + if req.status_code != HTTP_OK: _LOGGER.warning( - "downloading '%s' failed, status_code=%d", - url, - req.status_code) + "downloading '%s' failed, status_code=%d", url, req.status_code + ) + hass.bus.fire( + f"{DOMAIN}_{DOWNLOAD_FAILED_EVENT}", + {"url": url, "filename": filename}, + ) else: - if filename is None and \ - 'content-disposition' in req.headers: - match = re.findall(r"filename=(\S+)", - req.headers['content-disposition']) + 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("'\" ") @@ -93,7 +99,7 @@ def do_download(): filename = os.path.basename(url).strip() if not filename: - filename = 'ha_download' + filename = "ha_download" # Remove stuff to ruin paths filename = sanitize_filename(filename) @@ -121,28 +127,26 @@ def do_download(): while os.path.isfile(final_path): tries += 1 - final_path = "{}_{}.{}".format(path, tries, ext) + final_path = f"{path}_{tries}.{ext}" _LOGGER.debug("%s -> %s", url, final_path) - with open(final_path, 'wb') as fil: + 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 - }) + f"{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 - }) + f"{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): @@ -150,7 +154,11 @@ def do_download(): threading.Thread(target=do_download).start() - hass.services.register(DOMAIN, SERVICE_DOWNLOAD_FILE, download_file, - schema=SERVICE_DOWNLOAD_FILE_SCHEMA) + 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 index 514509c004d50..6b447f270ccb5 100644 --- a/homeassistant/components/downloader/manifest.json +++ b/homeassistant/components/downloader/manifest.json @@ -1,8 +1,7 @@ { "domain": "downloader", "name": "Downloader", - "documentation": "https://www.home-assistant.io/components/downloader", - "requirements": [], - "dependencies": [], - "codeowners": [] + "documentation": "https://www.home-assistant.io/integrations/downloader", + "codeowners": [], + "quality_scale": "internal" } diff --git a/homeassistant/components/downloader/services.yaml b/homeassistant/components/downloader/services.yaml index e69de29bb2d1d..6e16e00432fdb 100644 --- a/homeassistant/components/downloader/services.yaml +++ b/homeassistant/components/downloader/services.yaml @@ -0,0 +1,15 @@ +download_file: + description: Downloads a file to the download location. + fields: + url: + description: The URL of the file to download. + example: "http://example.org/myfile" + subdir: + description: Download into subdirectory. + example: "download_dir" + filename: + description: Determine the filename. + example: "my_file_name" + overwrite: + description: Whether to overwrite the file or not. + example: "false" diff --git a/homeassistant/components/dsmr/manifest.json b/homeassistant/components/dsmr/manifest.json index 21c98d56d1d55..42e6b81dc1f7a 100644 --- a/homeassistant/components/dsmr/manifest.json +++ b/homeassistant/components/dsmr/manifest.json @@ -1,10 +1,7 @@ { "domain": "dsmr", - "name": "Dsmr", - "documentation": "https://www.home-assistant.io/components/dsmr", - "requirements": [ - "dsmr_parser==0.12" - ], - "dependencies": [], + "name": "DSMR Slimme Meter", + "documentation": "https://www.home-assistant.io/integrations/dsmr", + "requirements": ["dsmr_parser==0.18"], "codeowners": [] } diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index d7acc5c28bf89..484bd708489ca 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -1,204 +1,151 @@ """Support for Dutch Smart Meter (also known as Smartmeter or P1 port).""" import asyncio -from datetime import timedelta from functools import partial import logging +from dsmr_parser import obis_references as obis_ref +from dsmr_parser.clients.protocol import create_dsmr_reader, create_tcp_dsmr_reader +import serial 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 + CONF_HOST, + CONF_PORT, + EVENT_HOMEASSISTANT_STOP, + TIME_HOURS, +) +from homeassistant.core import CoreState, callback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) from homeassistant.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) -CONF_DSMR_VERSION = 'dsmr_version' -CONF_RECONNECT_INTERVAL = 'reconnect_interval' -CONF_PRECISION = 'precision' +CONF_DSMR_VERSION = "dsmr_version" +CONF_RECONNECT_INTERVAL = "reconnect_interval" +CONF_PRECISION = "precision" -DEFAULT_DSMR_VERSION = '2.2' -DEFAULT_PORT = '/dev/ttyUSB0' +DEFAULT_DSMR_VERSION = "2.2" +DEFAULT_PORT = "/dev/ttyUSB0" DEFAULT_PRECISION = 3 -DOMAIN = 'dsmr' +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) +ICON_GAS = "mdi:fire" +ICON_POWER = "mdi:flash" +ICON_POWER_FAILURE = "mdi:flash-off" +ICON_SWELL_SAG = "mdi:pulse" 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), -}) +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(["5B", "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): +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 + logging.getLogger("dsmr_parser").setLevel(logging.ERROR) 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 - ], + ["Power Consumption", obis_ref.CURRENT_ELECTRICITY_USAGE], + ["Power Production", obis_ref.CURRENT_ELECTRICITY_DELIVERY], + ["Power Tariff", obis_ref.ELECTRICITY_ACTIVE_TARIFF], + ["Energy Consumption (total)", obis_ref.ELECTRICITY_IMPORTED_TOTAL], + ["Energy Consumption (tarif 1)", obis_ref.ELECTRICITY_USED_TARIFF_1], + ["Energy Consumption (tarif 2)", obis_ref.ELECTRICITY_USED_TARIFF_2], + ["Energy Production (tarif 1)", obis_ref.ELECTRICITY_DELIVERED_TARIFF_1], + ["Energy Production (tarif 2)", 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], + ["Short Power Failure Count", obis_ref.SHORT_POWER_FAILURE_COUNT], + ["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], + ["Current Phase L1", obis_ref.INSTANTANEOUS_CURRENT_L1], + ["Current Phase L2", obis_ref.INSTANTANEOUS_CURRENT_L2], + ["Current Phase L3", obis_ref.INSTANTANEOUS_CURRENT_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'): + if dsmr_version in ("4", "5"): gas_obis = obis_ref.HOURLY_GAS_METER_READING + elif dsmr_version in ("5B",): + gas_obis = obis_ref.BELGIUM_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), + 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()) + update_entities_telegram = partial(async_dispatcher_send, hass, DOMAIN) # 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) + 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) + 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): + 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") @@ -207,7 +154,8 @@ async def connect_and_reconnect(): if transport: # Register listener to close transport on HA shutdown stop_listener = hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_STOP, transport.close) + EVENT_HOMEASSISTANT_STOP, transport.close + ) # Wait for reader to close await protocol.wait_closed() @@ -223,8 +171,7 @@ async def connect_and_reconnect(): update_entities_telegram({}) # throttle reconnect attempts - await asyncio.sleep(config[CONF_RECONNECT_INTERVAL], - loop=hass.loop) + 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()) @@ -240,6 +187,18 @@ def __init__(self, name, obis, config): self._config = config self.telegram = {} + async def async_added_to_hass(self): + """When entity is added to hass.""" + self.async_on_remove( + async_dispatcher_connect(self.hass, DOMAIN, self.update_data) + ) + + @callback + def update_data(self, telegram): + """Update data.""" + self.telegram = telegram + self.async_write_ha_state() + 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 @@ -258,24 +217,22 @@ def name(self): @property def icon(self): """Icon to use in the frontend, if any.""" - if 'Sags' in self._name or 'Swells' in self.name: + if "Sags" in self._name or "Swells" in self.name: return ICON_SWELL_SAG - if 'Failure' in self._name: + if "Failure" in self._name: return ICON_POWER_FAILURE - if 'Power' in self._name: + if "Power" in self._name: return ICON_POWER - if 'Gas' in self._name: + 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') + value = self.get_dsmr_object_attr("value") - if self._obis == obis.ELECTRICITY_ACTIVE_TARIFF: - return self.translate_tariff(value) + if self._obis == obis_ref.ELECTRICITY_ACTIVE_TARIFF: + return self.translate_tariff(value, self._config[CONF_DSMR_VERSION]) try: value = round(float(value), self._config[CONF_PRECISION]) @@ -290,17 +247,24 @@ def state(self): @property def unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" - return self.get_dsmr_object_attr('unit') + return self.get_dsmr_object_attr("unit") @staticmethod - def translate_tariff(value): - """Convert 2/1 to normal/low.""" + def translate_tariff(value, dsmr_version): + """Convert 2/1 to normal/low depending on DSMR version.""" + # DSMR V5B: Note: In Belgium values are swapped: + # Rate code 2 is used for low rate and rate code 1 is used for normal rate. + if dsmr_version in ("5B",): + if value == "0001": + value = "0002" + elif value == "0002": + value = "0001" # 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' + if value == "0002": + return "normal" + if value == "0001": + return "low" return None @@ -332,9 +296,9 @@ async def async_update(self): """ # check if the timestamp for the object differs from the previous one - timestamp = self.get_dsmr_object_attr('datetime') + timestamp = self.get_dsmr_object_attr("datetime") if timestamp and timestamp != self._previous_timestamp: - current_reading = self.get_dsmr_object_attr('value') + current_reading = self.get_dsmr_object_attr("value") if self._previous_reading is None: # Can't calculate rate without previous datapoint @@ -353,6 +317,6 @@ async def async_update(self): @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') + unit = self.get_dsmr_object_attr("unit") if unit: - return unit + '/h' + return f"{unit}/{TIME_HOURS}" diff --git a/homeassistant/components/dsmr_reader/__init__.py b/homeassistant/components/dsmr_reader/__init__.py new file mode 100644 index 0000000000000..946be91d1a561 --- /dev/null +++ b/homeassistant/components/dsmr_reader/__init__.py @@ -0,0 +1 @@ +"""The DSMR Reader component.""" diff --git a/homeassistant/components/dsmr_reader/definitions.py b/homeassistant/components/dsmr_reader/definitions.py new file mode 100644 index 0000000000000..b674065543441 --- /dev/null +++ b/homeassistant/components/dsmr_reader/definitions.py @@ -0,0 +1,247 @@ +"""Definitions for DSMR Reader sensors added to MQTT.""" + +from homeassistant.const import ENERGY_KILO_WATT_HOUR, VOLT, VOLUME_CUBIC_METERS + + +def dsmr_transform(value): + """Transform DSMR version value to right format.""" + if value.isdigit(): + return float(value) / 10 + return value + + +def tariff_transform(value): + """Transform tariff from number to description.""" + if value == "1": + return "low" + return "high" + + +DEFINITIONS = { + "dsmr/reading/electricity_delivered_1": { + "name": "Low tariff usage", + "icon": "mdi:flash", + "unit": ENERGY_KILO_WATT_HOUR, + }, + "dsmr/reading/electricity_returned_1": { + "name": "Low tariff returned", + "icon": "mdi:flash-outline", + "unit": ENERGY_KILO_WATT_HOUR, + }, + "dsmr/reading/electricity_delivered_2": { + "name": "High tariff usage", + "icon": "mdi:flash", + "unit": ENERGY_KILO_WATT_HOUR, + }, + "dsmr/reading/electricity_returned_2": { + "name": "High tariff returned", + "icon": "mdi:flash-outline", + "unit": ENERGY_KILO_WATT_HOUR, + }, + "dsmr/reading/electricity_currently_delivered": { + "name": "Current power usage", + "icon": "mdi:flash", + "unit": "kW", + }, + "dsmr/reading/electricity_currently_returned": { + "name": "Current power return", + "icon": "mdi:flash-outline", + "unit": "kW", + }, + "dsmr/reading/phase_currently_delivered_l1": { + "name": "Current power usage L1", + "icon": "mdi:flash", + "unit": "kW", + }, + "dsmr/reading/phase_currently_delivered_l2": { + "name": "Current power usage L2", + "icon": "mdi:flash", + "unit": "kW", + }, + "dsmr/reading/phase_currently_delivered_l3": { + "name": "Current power usage L3", + "icon": "mdi:flash", + "unit": "kW", + }, + "dsmr/reading/phase_currently_returned_l1": { + "name": "Current power return L1", + "icon": "mdi:flash-outline", + "unit": "kW", + }, + "dsmr/reading/phase_currently_returned_l2": { + "name": "Current power return L2", + "icon": "mdi:flash-outline", + "unit": "kW", + }, + "dsmr/reading/phase_currently_returned_l3": { + "name": "Current power return L3", + "icon": "mdi:flash-outline", + "unit": "kW", + }, + "dsmr/reading/extra_device_delivered": { + "name": "Gas meter usage", + "icon": "mdi:fire", + "unit": VOLUME_CUBIC_METERS, + }, + "dsmr/reading/phase_voltage_l1": { + "name": "Current voltage L1", + "icon": "mdi:flash", + "unit": VOLT, + }, + "dsmr/reading/phase_voltage_l2": { + "name": "Current voltage L2", + "icon": "mdi:flash", + "unit": VOLT, + }, + "dsmr/reading/phase_voltage_l3": { + "name": "Current voltage L3", + "icon": "mdi:flash", + "unit": VOLT, + }, + "dsmr/consumption/gas/delivered": { + "name": "Gas usage", + "icon": "mdi:fire", + "unit": VOLUME_CUBIC_METERS, + }, + "dsmr/consumption/gas/currently_delivered": { + "name": "Current gas usage", + "icon": "mdi:fire", + "unit": VOLUME_CUBIC_METERS, + }, + "dsmr/consumption/gas/read_at": { + "name": "Gas meter read", + "icon": "mdi:clock", + "unit": "", + }, + "dsmr/day-consumption/electricity1": { + "name": "Low tariff usage", + "icon": "mdi:counter", + "unit": ENERGY_KILO_WATT_HOUR, + }, + "dsmr/day-consumption/electricity2": { + "name": "High tariff usage", + "icon": "mdi:counter", + "unit": ENERGY_KILO_WATT_HOUR, + }, + "dsmr/day-consumption/electricity1_returned": { + "name": "Low tariff return", + "icon": "mdi:counter", + "unit": ENERGY_KILO_WATT_HOUR, + }, + "dsmr/day-consumption/electricity2_returned": { + "name": "High tariff return", + "icon": "mdi:counter", + "unit": ENERGY_KILO_WATT_HOUR, + }, + "dsmr/day-consumption/electricity_merged": { + "name": "Power usage total", + "icon": "mdi:counter", + "unit": ENERGY_KILO_WATT_HOUR, + }, + "dsmr/day-consumption/electricity_returned_merged": { + "name": "Power return total", + "icon": "mdi:counter", + "unit": ENERGY_KILO_WATT_HOUR, + }, + "dsmr/day-consumption/electricity1_cost": { + "name": "Low tariff cost", + "icon": "mdi:currency-eur", + "unit": "€", + }, + "dsmr/day-consumption/electricity2_cost": { + "name": "High tariff cost", + "icon": "mdi:currency-eur", + "unit": "€", + }, + "dsmr/day-consumption/electricity_cost_merged": { + "name": "Power total cost", + "icon": "mdi:currency-eur", + "unit": "€", + }, + "dsmr/day-consumption/gas": { + "name": "Gas usage", + "icon": "mdi:counter", + "unit": VOLUME_CUBIC_METERS, + }, + "dsmr/day-consumption/gas_cost": { + "name": "Gas cost", + "icon": "mdi:currency-eur", + "unit": "€", + }, + "dsmr/day-consumption/total_cost": { + "name": "Total cost", + "icon": "mdi:currency-eur", + "unit": "€", + }, + "dsmr/day-consumption/energy_supplier_price_electricity_delivered_1": { + "name": "Low tariff delivered price", + "icon": "mdi:currency-eur", + "unit": "€", + }, + "dsmr/day-consumption/energy_supplier_price_electricity_delivered_2": { + "name": "High tariff delivered price", + "icon": "mdi:currency-eur", + "unit": "€", + }, + "dsmr/day-consumption/energy_supplier_price_electricity_returned_1": { + "name": "Low tariff returned price", + "icon": "mdi:currency-eur", + "unit": "€", + }, + "dsmr/day-consumption/energy_supplier_price_electricity_returned_2": { + "name": "High tariff returned price", + "icon": "mdi:currency-eur", + "unit": "€", + }, + "dsmr/day-consumption/energy_supplier_price_gas": { + "name": "Gas price", + "icon": "mdi:currency-eur", + "unit": "€", + }, + "dsmr/meter-stats/dsmr_version": { + "name": "DSMR version", + "icon": "mdi:alert-circle", + "transform": dsmr_transform, + }, + "dsmr/meter-stats/electricity_tariff": { + "name": "Electricity tariff", + "icon": "mdi:flash", + "transform": tariff_transform, + }, + "dsmr/meter-stats/power_failure_count": { + "name": "Power failure count", + "icon": "mdi:flash", + }, + "dsmr/meter-stats/long_power_failure_count": { + "name": "Long power failure count", + "icon": "mdi:flash", + }, + "dsmr/meter-stats/voltage_sag_count_l1": { + "name": "Voltage sag L1", + "icon": "mdi:flash", + }, + "dsmr/meter-stats/voltage_sag_count_l2": { + "name": "Voltage sag L2", + "icon": "mdi:flash", + }, + "dsmr/meter-stats/voltage_sag_count_l3": { + "name": "Voltage sag L3", + "icon": "mdi:flash", + }, + "dsmr/meter-stats/voltage_swell_count_l1": { + "name": "Voltage swell L1", + "icon": "mdi:flash", + }, + "dsmr/meter-stats/voltage_swell_count_l2": { + "name": "Voltage swell L2", + "icon": "mdi:flash", + }, + "dsmr/meter-stats/voltage_swell_count_l3": { + "name": "Voltage swell L3", + "icon": "mdi:flash", + }, + "dsmr/meter-stats/rejected_telegrams": { + "name": "Rejected telegrams", + "icon": "mdi:flash", + }, +} diff --git a/homeassistant/components/dsmr_reader/manifest.json b/homeassistant/components/dsmr_reader/manifest.json new file mode 100644 index 0000000000000..59096d626e32d --- /dev/null +++ b/homeassistant/components/dsmr_reader/manifest.json @@ -0,0 +1,7 @@ +{ + "domain": "dsmr_reader", + "name": "DSMR Reader", + "documentation": "https://www.home-assistant.io/integrations/dsmr_reader", + "dependencies": ["mqtt"], + "codeowners": ["@depl0y"] +} diff --git a/homeassistant/components/dsmr_reader/sensor.py b/homeassistant/components/dsmr_reader/sensor.py new file mode 100644 index 0000000000000..341451522d4ec --- /dev/null +++ b/homeassistant/components/dsmr_reader/sensor.py @@ -0,0 +1,78 @@ +"""Support for DSMR Reader through MQTT.""" +from homeassistant.components import mqtt +from homeassistant.core import callback +from homeassistant.helpers.entity import Entity +from homeassistant.util import slugify + +from .definitions import DEFINITIONS + +DOMAIN = "dsmr_reader" + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up DSMR Reader sensors.""" + + sensors = [] + for topic in DEFINITIONS: + sensors.append(DSMRSensor(topic)) + + async_add_entities(sensors) + + +class DSMRSensor(Entity): + """Representation of a DSMR sensor that is updated via MQTT.""" + + def __init__(self, topic): + """Initialize the sensor.""" + + self._definition = DEFINITIONS[topic] + + self._entity_id = slugify(topic.replace("/", "_")) + self._topic = topic + + self._name = self._definition.get("name", topic.split("/")[-1]) + self._unit_of_measurement = self._definition.get("unit") + self._icon = self._definition.get("icon") + self._transform = self._definition.get("transform") + self._state = None + + async def async_added_to_hass(self): + """Subscribe to MQTT events.""" + + @callback + def message_received(message): + """Handle new MQTT messages.""" + + if self._transform is not None: + self._state = self._transform(message.payload) + else: + self._state = message.payload + + self.async_write_ha_state() + + await mqtt.async_subscribe(self.hass, self._topic, message_received, 1) + + @property + def name(self): + """Return the name of the sensor supplied in constructor.""" + return self._name + + @property + def entity_id(self): + """Return the entity ID for this sensor.""" + return f"sensor.{self._entity_id}" + + @property + def state(self): + """Return the current state of the entity.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit_of_measurement of this sensor.""" + return self._unit_of_measurement + + @property + def icon(self): + """Return the icon of this sensor.""" + return self._icon diff --git a/homeassistant/components/dte_energy_bridge/manifest.json b/homeassistant/components/dte_energy_bridge/manifest.json index fbf7a00f8e6b0..a63831498889e 100644 --- a/homeassistant/components/dte_energy_bridge/manifest.json +++ b/homeassistant/components/dte_energy_bridge/manifest.json @@ -1,8 +1,6 @@ { "domain": "dte_energy_bridge", - "name": "Dte energy bridge", - "documentation": "https://www.home-assistant.io/components/dte_energy_bridge", - "requirements": [], - "dependencies": [], + "name": "DTE Energy Bridge", + "documentation": "https://www.home-assistant.io/integrations/dte_energy_bridge", "codeowners": [] } diff --git a/homeassistant/components/dte_energy_bridge/sensor.py b/homeassistant/components/dte_energy_bridge/sensor.py index 8610a1e7f7008..efd00b3da1ef8 100644 --- a/homeassistant/components/dte_energy_bridge/sensor.py +++ b/homeassistant/components/dte_energy_bridge/sensor.py @@ -1,36 +1,40 @@ """Support for monitoring energy usage using the DTE energy bridge.""" import logging +import requests import voluptuous as vol -from homeassistant.helpers.entity import Entity from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_NAME, HTTP_OK import homeassistant.helpers.config_validation as cv -from homeassistant.const import CONF_NAME +from homeassistant.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) -CONF_IP_ADDRESS = 'ip' -CONF_VERSION = 'version' +CONF_IP_ADDRESS = "ip" +CONF_VERSION = "version" -DEFAULT_NAME = 'Current Energy Usage' +DEFAULT_NAME = "Current Energy Usage" DEFAULT_VERSION = 1 -ICON = 'mdi:flash' +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)) -}) +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) + name = config[CONF_NAME] + ip_address = config[CONF_IP_ADDRESS] + version = config[CONF_VERSION] add_entities([DteEnergyBridgeSensor(ip_address, name, version)], True) @@ -43,11 +47,9 @@ def __init__(self, ip_address, name, version): self._version = version if self._version == 1: - url_template = "http://{}/instantaneousdemand" + self._url = f"http://{ip_address}/instantaneousdemand" elif self._version == 2: - url_template = "http://{}:8888/zigbee/se/instantaneousdemand" - - self._url = url_template.format(ip_address) + self._url = f"http://{ip_address}:8888/zigbee/se/instantaneousdemand" self._name = name self._unit_of_measurement = "kW" @@ -75,20 +77,20 @@ def icon(self): 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) + "Could not update status for DTE Energy Bridge (%s)", self._name + ) return - if response.status_code != 200: + if response.status_code != HTTP_OK: _LOGGER.warning( - 'Invalid status_code from DTE Energy Bridge: %s (%s)', - response.status_code, self._name) + "Invalid status_code from DTE Energy Bridge: %s (%s)", + response.status_code, + self._name, + ) return response_split = response.text.split() @@ -96,7 +98,9 @@ def update(self): if len(response_split) != 2: _LOGGER.warning( 'Invalid response from DTE Energy Bridge: "%s" (%s)', - response.text, self._name) + response.text, + self._name, + ) return val = float(response_split[0]) @@ -107,7 +111,7 @@ def update(self): # 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]: + 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/manifest.json b/homeassistant/components/dublin_bus_transport/manifest.json index fc13fddc9364e..a8ed951b1d9f1 100644 --- a/homeassistant/components/dublin_bus_transport/manifest.json +++ b/homeassistant/components/dublin_bus_transport/manifest.json @@ -1,8 +1,6 @@ { "domain": "dublin_bus_transport", - "name": "Dublin bus transport", - "documentation": "https://www.home-assistant.io/components/dublin_bus_transport", - "requirements": [], - "dependencies": [], + "name": "Dublin Bus", + "documentation": "https://www.home-assistant.io/integrations/dublin_bus_transport", "codeowners": [] } diff --git a/homeassistant/components/dublin_bus_transport/sensor.py b/homeassistant/components/dublin_bus_transport/sensor.py index 7a70d7af3a7bb..0c0d6c53ed1d6 100644 --- a/homeassistant/components/dublin_bus_transport/sensor.py +++ b/homeassistant/components/dublin_bus_transport/sensor.py @@ -3,47 +3,46 @@ 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/ """ +from datetime import datetime, timedelta 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.const import ATTR_ATTRIBUTION, CONF_NAME, HTTP_OK, TIME_MINUTES +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity +import homeassistant.util.dt as dt_util _LOGGER = logging.getLogger(__name__) -_RESOURCE = 'https://data.dublinked.ie/cgi-bin/rtpi/realtimebusinformation' +_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' +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' +CONF_STOP_ID = "stopid" +CONF_ROUTE = "route" -DEFAULT_NAME = 'Next Bus' -ICON = 'mdi:bus' +DEFAULT_NAME = "Next Bus" +ICON = "mdi:bus" SCAN_INTERVAL = timedelta(minutes=1) -TIME_STR_FORMAT = '%H:%M' +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, -}) +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): @@ -51,17 +50,18 @@ def due_in_minutes(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) + 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) + name = config[CONF_NAME] + stop = config[CONF_STOP_ID] + route = config[CONF_ROUTE] data = PublicTransportData(stop, route) add_entities([DublinPublicTransportSensor(data, stop, route, name)], True) @@ -94,7 +94,7 @@ def device_state_attributes(self): if self._times is not None: next_up = "None" if len(self._times) > 1: - next_up = self._times[1][ATTR_ROUTE] + " in " + next_up = f"{self._times[1][ATTR_ROUTE]} in " next_up += self._times[1][ATTR_DUE_IN] return { @@ -103,13 +103,13 @@ def device_state_attributes(self): ATTR_STOP_ID: self._stop, ATTR_ROUTE: self._times[0][ATTR_ROUTE], ATTR_ATTRIBUTION: ATTRIBUTION, - ATTR_NEXT_UP: next_up + ATTR_NEXT_UP: next_up, } @property def unit_of_measurement(self): """Return the unit this state is expressed in.""" - return 'min' + return TIME_MINUTES @property def icon(self): @@ -133,48 +133,48 @@ 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'}] + 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 + params["stopid"] = self.stop if self.route: - params['routeid'] = self.route + params["routeid"] = self.route - params['maxresults'] = 2 - params['format'] = 'json' + 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'}] + if response.status_code != HTTP_OK: + 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'}] + 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') + 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)} + 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'}] + 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 index 9899a0af98ec1..b3da1ec275253 100644 --- a/homeassistant/components/duckdns/__init__.py +++ b/homeassistant/components/duckdns/__init__.py @@ -1,36 +1,43 @@ """Integrate with DuckDNS.""" +from asyncio import iscoroutinefunction 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.core import CALLBACK_TYPE, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.event import async_track_point_in_utc_time +from homeassistant.loader import bind_hass +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) -ATTR_TXT = 'txt' +ATTR_TXT = "txt" -DOMAIN = 'duckdns' +DOMAIN = "duckdns" INTERVAL = timedelta(minutes=5) -SERVICE_SET_TXT = 'set_txt' +SERVICE_SET_TXT = "set_txt" -UPDATE_URL = 'https://www.duckdns.org/update' +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) +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) -}) +SERVICE_TXT_SCHEMA = vol.Schema({vol.Required(ATTR_TXT): vol.Any(None, cv.string)}) async def async_setup(hass, config): @@ -39,55 +46,87 @@ async def async_setup(hass, config): 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): + async def update_domain_interval(_now): """Update the DuckDNS entry.""" - await _update_duckdns(session, domain, token) + return await _update_duckdns(session, domain, token) + + intervals = ( + INTERVAL, + timedelta(minutes=1), + timedelta(minutes=5), + timedelta(minutes=15), + timedelta(minutes=30), + ) + async_track_time_interval_backoff(hass, update_domain_interval, intervals) async def update_domain_service(call): """Update the DuckDNS entry.""" - await _update_duckdns( - session, domain, token, txt=call.data[ATTR_TXT]) + 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) + DOMAIN, SERVICE_SET_TXT, update_domain_service, schema=SERVICE_TXT_SCHEMA + ) - return result + return True _SENTINEL = object() -async def _update_duckdns(session, domain, token, *, txt=_SENTINEL, - clear=False): +async def _update_duckdns(session, domain, token, *, txt=_SENTINEL, clear=False): """Update DuckDNS.""" - params = { - 'domains': domain, - 'token': token, - } + 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'] = '' + params["txt"] = "" clear = True else: - params['txt'] = txt + params["txt"] = txt if clear: - params['clear'] = 'true' + params["clear"] = "true" resp = await session.get(UPDATE_URL, params=params) body = await resp.text() - if body != 'OK': + if body != "OK": _LOGGER.warning("Updating DuckDNS domain failed: %s", domain) return False return True + + +@callback +@bind_hass +def async_track_time_interval_backoff(hass, action, intervals) -> CALLBACK_TYPE: + """Add a listener that fires repetitively at every timedelta interval.""" + if not iscoroutinefunction: + _LOGGER.error("action needs to be a coroutine and return True/False") + return + + if not isinstance(intervals, (list, tuple)): + intervals = (intervals,) + remove = None + failed = 0 + + async def interval_listener(now): + """Handle elapsed intervals with backoff.""" + nonlocal failed, remove + try: + failed += 1 + if await action(now): + failed = 0 + finally: + delay = intervals[failed] if failed < len(intervals) else intervals[-1] + remove = async_track_point_in_utc_time(hass, interval_listener, now + delay) + + hass.async_run_job(interval_listener, dt_util.utcnow()) + + def remove_listener(): + """Remove interval listener.""" + if remove: + remove() # pylint: disable=not-callable + + return remove_listener diff --git a/homeassistant/components/duckdns/manifest.json b/homeassistant/components/duckdns/manifest.json index ed38d35346f22..bfa692c80f320 100644 --- a/homeassistant/components/duckdns/manifest.json +++ b/homeassistant/components/duckdns/manifest.json @@ -1,8 +1,6 @@ { "domain": "duckdns", - "name": "Duckdns", - "documentation": "https://www.home-assistant.io/components/duckdns", - "requirements": [], - "dependencies": [], + "name": "Duck DNS", + "documentation": "https://www.home-assistant.io/integrations/duckdns", "codeowners": [] } diff --git a/homeassistant/components/duckdns/services.yaml b/homeassistant/components/duckdns/services.yaml index e69de29bb2d1d..e0ba27390df3b 100644 --- a/homeassistant/components/duckdns/services.yaml +++ b/homeassistant/components/duckdns/services.yaml @@ -0,0 +1,6 @@ +set_txt: + description: Set the TXT record of your DuckDNS subdomain. + fields: + txt: + description: Payload for the TXT record. + example: "This domain name is reserved for use in documentation" diff --git a/homeassistant/components/duke_energy/__init__.py b/homeassistant/components/duke_energy/__init__.py deleted file mode 100644 index 5a1f29add438d..0000000000000 --- a/homeassistant/components/duke_energy/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""The duke_energy component.""" diff --git a/homeassistant/components/duke_energy/manifest.json b/homeassistant/components/duke_energy/manifest.json deleted file mode 100644 index 602dfec801fd7..0000000000000 --- a/homeassistant/components/duke_energy/manifest.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "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 deleted file mode 100644 index e364e35048b6e..0000000000000 --- a/homeassistant/components/duke_energy/sensor.py +++ /dev/null @@ -1,77 +0,0 @@ -"""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/manifest.json b/homeassistant/components/dunehd/manifest.json index 35e6c4a2449ed..2dfafdf045142 100644 --- a/homeassistant/components/dunehd/manifest.json +++ b/homeassistant/components/dunehd/manifest.json @@ -1,10 +1,7 @@ { "domain": "dunehd", - "name": "Dunehd", - "documentation": "https://www.home-assistant.io/components/dunehd", - "requirements": [ - "pdunehd==1.3" - ], - "dependencies": [], + "name": "DuneHD", + "documentation": "https://www.home-assistant.io/integrations/dunehd", + "requirements": ["pdunehd==1.3"], "codeowners": [] } diff --git a/homeassistant/components/dunehd/media_player.py b/homeassistant/components/dunehd/media_player.py index a5698c7465438..7ef0171dd6c51 100644 --- a/homeassistant/components/dunehd/media_player.py +++ b/homeassistant/components/dunehd/media_player.py @@ -1,35 +1,52 @@ """DuneHD implementation of the media player.""" +from pdunehd import DuneHDPlayer import voluptuous as vol -from homeassistant.components.media_player import ( - MediaPlayerDevice, PLATFORM_SCHEMA) +from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerEntity 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_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) + CONF_HOST, + CONF_NAME, + STATE_OFF, + STATE_ON, + STATE_PAUSED, + STATE_PLAYING, +) import homeassistant.helpers.config_validation as cv -DEFAULT_NAME = 'DuneHD' +DEFAULT_NAME = "DuneHD" -CONF_SOURCES = 'sources' +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, -}) +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 +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) @@ -38,7 +55,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): add_entities([DuneHDPlayerEntity(DuneHDPlayer(host), name, sources)], True) -class DuneHDPlayerEntity(MediaPlayerDevice): +class DuneHDPlayerEntity(MediaPlayerEntity): """Implementation of the Dune HD player.""" def __init__(self, player, name, sources): @@ -59,13 +76,13 @@ def update(self): def state(self): """Return player state.""" state = STATE_OFF - if 'playback_position' in self._state: + if "playback_position" in self._state: state = STATE_PLAYING - if self._state['player_state'] in ('playing', 'buffering'): + if self._state["player_state"] in ("playing", "buffering"): state = STATE_PLAYING - if int(self._state.get('playback_speed', 1234)) == 0: + if int(self._state.get("playback_speed", 1234)) == 0: state = STATE_PAUSED - if self._state['player_state'] == 'navigator': + if self._state["player_state"] == "navigator": state = STATE_ON return state @@ -77,12 +94,12 @@ def name(self): @property def volume_level(self): """Return the volume level of the media player (0..1).""" - return int(self._state.get('playback_volume', 0)) / 100 + 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 + return int(self._state.get("playback_mute", 0)) == 1 @property def source_list(self): @@ -133,16 +150,16 @@ def media_title(self): self.__update_title() if self._media_title: return self._media_title - return self._state.get('playback_url', 'Not playing') + 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: + 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'] + pburl = self._state["playback_url"] if pburl in sval: self._media_title = list(skey)[list(sval).index(pburl)] else: diff --git a/homeassistant/components/dwd_weather_warnings/manifest.json b/homeassistant/components/dwd_weather_warnings/manifest.json index a2b21a9e0bf94..52173f001e7b6 100644 --- a/homeassistant/components/dwd_weather_warnings/manifest.json +++ b/homeassistant/components/dwd_weather_warnings/manifest.json @@ -1,8 +1,7 @@ { "domain": "dwd_weather_warnings", - "name": "Dwd weather warnings", - "documentation": "https://www.home-assistant.io/components/dwd_weather_warnings", - "requirements": [], - "dependencies": [], + "name": "Deutsche Wetter Dienst (DWD) Weather Warnings", + "documentation": "https://www.home-assistant.io/integrations/dwd_weather_warnings", + "after_dependencies": ["rest"], "codeowners": [] } diff --git a/homeassistant/components/dwd_weather_warnings/sensor.py b/homeassistant/components/dwd_weather_warnings/sensor.py index 9e61c9e31969d..152c757424ced 100644 --- a/homeassistant/components/dwd_weather_warnings/sensor.py +++ b/homeassistant/components/dwd_weather_warnings/sensor.py @@ -1,9 +1,6 @@ """ 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 @@ -12,45 +9,53 @@ Warnungen vor markantem Wetter (Stufe 2) Wetterwarnungen (Stufe 1) """ -import logging -import json from datetime import timedelta +import json +import logging import voluptuous as vol +from homeassistant.components.rest.sensor import RestData +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ATTR_ATTRIBUTION, CONF_MONITORED_CONDITIONS, CONF_NAME +from homeassistant.helpers.aiohttp_client import SERVER_SOFTWARE as HA_USER_AGENT 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' +DEFAULT_NAME = "DWD-Weather-Warnings" -CONF_REGION_NAME = 'region_name' +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'], + "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)]), -}) +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): @@ -60,8 +65,10 @@ def setup_platform(hass, config, add_entities, discovery_info=None): api = DwdWeatherWarningsAPI(region_name) - sensors = [DwdWeatherWarningsSensor(api, name, condition) - for condition in config[CONF_MONITORED_CONDITIONS]] + sensors = [ + DwdWeatherWarningsSensor(api, name, condition) + for condition in config[CONF_MONITORED_CONDITIONS] + ] add_entities(sensors, True) @@ -83,7 +90,7 @@ def __init__(self, api, name, variable): @property def name(self): """Return the name of the sensor.""" - return "{} {}".format(self._name, self._var_name) + return f"{self._name} {self._var_name}" @property def icon(self): @@ -106,50 +113,57 @@ def state(self): @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 - } + 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 + data["region_id"] = self._api.region_id if self._api.region_state is not None: - data['region_state'] = self._api.region_state + 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._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' + if self._var_id == "current_warning_level": + prefix = "current" + elif self._var_id == "advance_warning_level": + prefix = "advance" else: - raise Exception('Unknown warning type') + raise Exception("Unknown warning type") - data['warning_count'] = self._api.data[prefix + '_warning_count'] + data["warning_count"] = self._api.data[f"{prefix}_warning_count"] i = 0 - for event in self._api.data[prefix + '_warnings']: + for event in self._api.data[f"{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)) + # dictionary for the attribute containing the complete warning as json + event_json = event.copy() + + data[f"warning_{i}_name"] = event["event"] + data[f"warning_{i}_level"] = event["level"] + data[f"warning_{i}_type"] = event["type"] + if event["headline"]: + data[f"warning_{i}_headline"] = event["headline"] + if event["description"]: + data[f"warning_{i}_description"] = event["description"] + if event["instruction"]: + data[f"warning_{i}_instruction"] = event["instruction"] + + if event["start"] is not None: + data[f"warning_{i}_start"] = dt_util.as_local( + dt_util.utc_from_timestamp(event["start"] / 1000) + ) + event_json["start"] = data[f"warning_{i}_start"] + + if event["end"] is not None: + data[f"warning_{i}_end"] = dt_util.as_local( + dt_util.utc_from_timestamp(event["end"] / 1000) + ) + event_json["end"] = data[f"warning_{i}_end"] + + data[f"warning_{i}"] = event_json return data @@ -168,14 +182,12 @@ class DwdWeatherWarningsAPI: 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) + resource = "https://www.dwd.de/DWD/warnungen/warnapp_landkreise/json/warnings.json?jsonp=loadWarnings" + + # a User-Agent is necessary for this rest api endpoint (#29496) + headers = {"User-Agent": HA_USER_AGENT} + + self._rest = RestData("GET", resource, None, headers, None, True) self.region_name = region_name self.region_id = None self.region_state = None @@ -189,20 +201,21 @@ def update(self): try: self._rest.update() - json_string = self._rest.data[24:len(self._rest.data) - 2] + json_string = self._rest.data[24 : len(self._rest.data) - 2] json_obj = json.loads(json_string) - data = {'time': json_obj['time']} + data = {"time": json_obj["time"]} for mykey, myvalue in { - 'current': 'warnings', - 'advance': 'vorabInformation' + "current": "warnings", + "advance": "vorabInformation", }.items(): - _LOGGER.debug("Found %d %s global DWD warnings", - len(json_obj[myvalue]), mykey) + _LOGGER.debug( + "Found %d %s global DWD warnings", len(json_obj[myvalue]), mykey + ) - data['{}_warning_level'.format(mykey)] = 0 + data[f"{mykey}_warning_level"] = 0 my_warnings = [] if self.region_id is not None: @@ -214,26 +227,25 @@ def update(self): # 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'] + 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'] + 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)] + maxlevel = data[f"{mykey}_warning_level"] for event in my_warnings: - if event['level'] >= maxlevel: - data['{}_warning_level'.format(mykey)] = event['level'] + if event["level"] >= maxlevel: + data[f"{mykey}_warning_level"] = event["level"] - data['{}_warning_count'.format(mykey)] = len(my_warnings) - data['{}_warnings'.format(mykey)] = my_warnings + data[f"{mykey}_warning_count"] = len(my_warnings) + data[f"{mykey}_warnings"] = my_warnings - _LOGGER.debug("Found %d %s local DWD warnings", - len(my_warnings), mykey) + _LOGGER.debug("Found %d %s local DWD warnings", len(my_warnings), mykey) self.data = data self.available = True diff --git a/homeassistant/components/dweet/__init__.py b/homeassistant/components/dweet/__init__.py index 148eeeec9a42d..db985e57a41bd 100644 --- a/homeassistant/components/dweet/__init__.py +++ b/homeassistant/components/dweet/__init__.py @@ -1,28 +1,39 @@ """Support for sending data to Dweet.io.""" -import logging from datetime import timedelta +import logging +import dweepy import voluptuous as vol from homeassistant.const import ( - CONF_NAME, CONF_WHITELIST, EVENT_STATE_CHANGED, STATE_UNKNOWN) -import homeassistant.helpers.config_validation as cv + CONF_NAME, + CONF_WHITELIST, + EVENT_STATE_CHANGED, + STATE_UNKNOWN, +) from homeassistant.helpers import state as state_helper +import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle _LOGGER = logging.getLogger(__name__) -DOMAIN = 'dweet' +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) +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): @@ -34,9 +45,12 @@ def setup(hass, config): 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: + 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: @@ -44,7 +58,7 @@ def dweet_event_listener(event): except ValueError: _state = state.state - json_body[state.attributes.get('friendly_name')] = _state + json_body[state.attributes.get("friendly_name")] = _state send_data(name, json_body) @@ -56,7 +70,6 @@ def dweet_event_listener(event): @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: diff --git a/homeassistant/components/dweet/manifest.json b/homeassistant/components/dweet/manifest.json index e0a00620210af..7849b2b33460f 100644 --- a/homeassistant/components/dweet/manifest.json +++ b/homeassistant/components/dweet/manifest.json @@ -1,12 +1,7 @@ { "domain": "dweet", - "name": "Dweet", - "documentation": "https://www.home-assistant.io/components/dweet", - "requirements": [ - "dweepy==0.3.0" - ], - "dependencies": [], - "codeowners": [ - "@fabaff" - ] + "name": "dweet.io", + "documentation": "https://www.home-assistant.io/integrations/dweet", + "requirements": ["dweepy==0.3.0"], + "codeowners": ["@fabaff"] } diff --git a/homeassistant/components/dweet/sensor.py b/homeassistant/components/dweet/sensor.py index 55f3c5341a330..f3f604ff3692b 100644 --- a/homeassistant/components/dweet/sensor.py +++ b/homeassistant/components/dweet/sensor.py @@ -1,34 +1,39 @@ """Support for showing values from Dweet.io.""" +from datetime import timedelta import json import logging -from datetime import timedelta +import dweepy 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) + CONF_DEVICE, + CONF_NAME, + CONF_UNIT_OF_MEASUREMENT, + CONF_VALUE_TEMPLATE, +) +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) -DEFAULT_NAME = 'Dweet.io Sensor' +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, -}) +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) @@ -37,12 +42,12 @@ def setup_platform(hass, config, add_entities, discovery_info=None): value_template.hass = hass try: - content = json.dumps(dweepy.get_latest_dweet_for(device)[0]['content']) + 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) == '': + if value_template.render_with_possible_json_value(content) == "": _LOGGER.error("%s was not found", value_template) return @@ -85,9 +90,10 @@ def update(self): if self.dweet.data is None: self._state = None else: - values = json.dumps(self.dweet.data[0]['content']) + values = json.dumps(self.dweet.data[0]["content"]) self._state = self._value_template.render_with_possible_json_value( - values, None) + values, None + ) class DweetData: @@ -100,8 +106,6 @@ def __init__(self, device): 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: diff --git a/homeassistant/components/dynalite/__init__.py b/homeassistant/components/dynalite/__init__.py new file mode 100644 index 0000000000000..78281f56f0f67 --- /dev/null +++ b/homeassistant/components/dynalite/__init__.py @@ -0,0 +1,239 @@ +"""Support for the Dynalite networks.""" + +import asyncio +from typing import Any, Dict, Union + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components.cover import DEVICE_CLASSES_SCHEMA +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_TYPE +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv + +# Loading the config flow file will register the flow +from .bridge import DynaliteBridge +from .const import ( + ACTIVE_INIT, + ACTIVE_OFF, + ACTIVE_ON, + CONF_ACTIVE, + CONF_AREA, + CONF_AUTO_DISCOVER, + CONF_BRIDGES, + CONF_CHANNEL, + CONF_CHANNEL_COVER, + CONF_CLOSE_PRESET, + CONF_DEFAULT, + CONF_DEVICE_CLASS, + CONF_DURATION, + CONF_FADE, + CONF_NO_DEFAULT, + CONF_OPEN_PRESET, + CONF_POLL_TIMER, + CONF_PRESET, + CONF_ROOM_OFF, + CONF_ROOM_ON, + CONF_STOP_PRESET, + CONF_TEMPLATE, + CONF_TILT_TIME, + DEFAULT_CHANNEL_TYPE, + DEFAULT_NAME, + DEFAULT_PORT, + DEFAULT_TEMPLATES, + DOMAIN, + ENTITY_PLATFORMS, + LOGGER, +) + + +def num_string(value: Union[int, str]) -> str: + """Test if value is a string of digits, aka an integer.""" + new_value = str(value) + if new_value.isdigit(): + return new_value + raise vol.Invalid("Not a string with numbers") + + +CHANNEL_DATA_SCHEMA = vol.Schema( + { + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_FADE): vol.Coerce(float), + vol.Optional(CONF_TYPE, default=DEFAULT_CHANNEL_TYPE): vol.Any( + "light", "switch" + ), + } +) + +CHANNEL_SCHEMA = vol.Schema({num_string: CHANNEL_DATA_SCHEMA}) + +PRESET_DATA_SCHEMA = vol.Schema( + {vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_FADE): vol.Coerce(float)} +) + +PRESET_SCHEMA = vol.Schema({num_string: vol.Any(PRESET_DATA_SCHEMA, None)}) + +TEMPLATE_ROOM_SCHEMA = vol.Schema( + {vol.Optional(CONF_ROOM_ON): num_string, vol.Optional(CONF_ROOM_OFF): num_string} +) + +TEMPLATE_TIMECOVER_SCHEMA = vol.Schema( + { + vol.Optional(CONF_CHANNEL_COVER): num_string, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_OPEN_PRESET): num_string, + vol.Optional(CONF_CLOSE_PRESET): num_string, + vol.Optional(CONF_STOP_PRESET): num_string, + vol.Optional(CONF_DURATION): vol.Coerce(float), + vol.Optional(CONF_TILT_TIME): vol.Coerce(float), + } +) + +TEMPLATE_DATA_SCHEMA = vol.Any(TEMPLATE_ROOM_SCHEMA, TEMPLATE_TIMECOVER_SCHEMA) + +TEMPLATE_SCHEMA = vol.Schema({str: TEMPLATE_DATA_SCHEMA}) + + +def validate_area(config: Dict[str, Any]) -> Dict[str, Any]: + """Validate that template parameters are only used if area is using the relevant template.""" + conf_set = set() + for template in DEFAULT_TEMPLATES: + for conf in DEFAULT_TEMPLATES[template]: + conf_set.add(conf) + if config.get(CONF_TEMPLATE): + for conf in DEFAULT_TEMPLATES[config[CONF_TEMPLATE]]: + conf_set.remove(conf) + for conf in conf_set: + if config.get(conf): + raise vol.Invalid( + f"{conf} should not be part of area {config[CONF_NAME]} config" + ) + return config + + +AREA_DATA_SCHEMA = vol.Schema( + vol.All( + { + vol.Required(CONF_NAME): cv.string, + vol.Optional(CONF_TEMPLATE): vol.In(DEFAULT_TEMPLATES), + vol.Optional(CONF_FADE): vol.Coerce(float), + vol.Optional(CONF_NO_DEFAULT): cv.boolean, + vol.Optional(CONF_CHANNEL): CHANNEL_SCHEMA, + vol.Optional(CONF_PRESET): PRESET_SCHEMA, + # the next ones can be part of the templates + vol.Optional(CONF_ROOM_ON): num_string, + vol.Optional(CONF_ROOM_OFF): num_string, + vol.Optional(CONF_CHANNEL_COVER): num_string, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_OPEN_PRESET): num_string, + vol.Optional(CONF_CLOSE_PRESET): num_string, + vol.Optional(CONF_STOP_PRESET): num_string, + vol.Optional(CONF_DURATION): vol.Coerce(float), + vol.Optional(CONF_TILT_TIME): vol.Coerce(float), + }, + validate_area, + ) +) + +AREA_SCHEMA = vol.Schema({num_string: vol.Any(AREA_DATA_SCHEMA, None)}) + +PLATFORM_DEFAULTS_SCHEMA = vol.Schema({vol.Optional(CONF_FADE): vol.Coerce(float)}) + + +BRIDGE_SCHEMA = vol.Schema( + { + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): int, + vol.Optional(CONF_AUTO_DISCOVER, default=False): vol.Coerce(bool), + vol.Optional(CONF_POLL_TIMER, default=1.0): vol.Coerce(float), + vol.Optional(CONF_AREA): AREA_SCHEMA, + vol.Optional(CONF_DEFAULT): PLATFORM_DEFAULTS_SCHEMA, + vol.Optional(CONF_ACTIVE, default=False): vol.Any( + ACTIVE_ON, ACTIVE_OFF, ACTIVE_INIT, cv.boolean + ), + vol.Optional(CONF_PRESET): PRESET_SCHEMA, + vol.Optional(CONF_TEMPLATE): TEMPLATE_SCHEMA, + } +) + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + {vol.Optional(CONF_BRIDGES): vol.All(cv.ensure_list, [BRIDGE_SCHEMA])} + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass: HomeAssistant, config: Dict[str, Any]) -> bool: + """Set up the Dynalite platform.""" + + conf = config.get(DOMAIN) + LOGGER.debug("Setting up dynalite component config = %s", conf) + + if conf is None: + conf = {} + + hass.data[DOMAIN] = {} + + # User has configured bridges + if CONF_BRIDGES not in conf: + return True + + bridges = conf[CONF_BRIDGES] + + for bridge_conf in bridges: + host = bridge_conf[CONF_HOST] + LOGGER.debug("Starting config entry flow host=%s conf=%s", host, bridge_conf) + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=bridge_conf, + ) + ) + + return True + + +async def async_entry_changed(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Reload entry since the data has changed.""" + LOGGER.debug("Reconfiguring entry %s", entry.data) + bridge = hass.data[DOMAIN][entry.entry_id] + bridge.reload_config(entry.data) + LOGGER.debug("Reconfiguring entry finished %s", entry.data) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up a bridge from a config entry.""" + LOGGER.debug("Setting up entry %s", entry.data) + bridge = DynaliteBridge(hass, entry.data) + # need to do it before the listener + hass.data[DOMAIN][entry.entry_id] = bridge + entry.add_update_listener(async_entry_changed) + if not await bridge.async_setup(): + LOGGER.error("Could not set up bridge for entry %s", entry.data) + hass.data[DOMAIN][entry.entry_id] = None + raise ConfigEntryNotReady + for platform in ENTITY_PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, platform) + ) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + LOGGER.debug("Unloading entry %s", entry.data) + hass.data[DOMAIN].pop(entry.entry_id) + tasks = [ + hass.config_entries.async_forward_entry_unload(entry, platform) + for platform in ENTITY_PLATFORMS + ] + results = await asyncio.gather(*tasks) + return False not in results diff --git a/homeassistant/components/dynalite/bridge.py b/homeassistant/components/dynalite/bridge.py new file mode 100644 index 0000000000000..522061a85aaa9 --- /dev/null +++ b/homeassistant/components/dynalite/bridge.py @@ -0,0 +1,82 @@ +"""Code to handle a Dynalite bridge.""" + +from typing import Any, Callable, Dict, List, Optional + +from dynalite_devices_lib.dynalite_devices import DynaliteBaseDevice, DynaliteDevices + +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from .const import ENTITY_PLATFORMS, LOGGER +from .convert_config import convert_config + + +class DynaliteBridge: + """Manages a single Dynalite bridge.""" + + def __init__(self, hass: HomeAssistant, config: Dict[str, Any]) -> None: + """Initialize the system based on host parameter.""" + self.hass = hass + self.area = {} + self.async_add_devices = {} + self.waiting_devices = {} + self.host = config[CONF_HOST] + # Configure the dynalite devices + self.dynalite_devices = DynaliteDevices( + new_device_func=self.add_devices_when_registered, + update_device_func=self.update_device, + ) + self.dynalite_devices.configure(convert_config(config)) + + async def async_setup(self) -> bool: + """Set up a Dynalite bridge.""" + # Configure the dynalite devices + LOGGER.debug("Setting up bridge - host %s", self.host) + return await self.dynalite_devices.async_setup() + + def reload_config(self, config: Dict[str, Any]) -> None: + """Reconfigure a bridge when config changes.""" + LOGGER.debug("Reloading bridge - host %s, config %s", self.host, config) + self.dynalite_devices.configure(convert_config(config)) + + def update_signal(self, device: Optional[DynaliteBaseDevice] = None) -> str: + """Create signal to use to trigger entity update.""" + if device: + signal = f"dynalite-update-{self.host}-{device.unique_id}" + else: + signal = f"dynalite-update-{self.host}" + return signal + + @callback + def update_device(self, device: Optional[DynaliteBaseDevice] = None) -> None: + """Call when a device or all devices should be updated.""" + if not device: + # This is used to signal connection or disconnection, so all devices may become available or not. + log_string = ( + "Connected" if self.dynalite_devices.connected else "Disconnected" + ) + LOGGER.info("%s to dynalite host", log_string) + async_dispatcher_send(self.hass, self.update_signal()) + else: + async_dispatcher_send(self.hass, self.update_signal(device)) + + @callback + def register_add_devices(self, platform: str, async_add_devices: Callable) -> None: + """Add an async_add_entities for a category.""" + self.async_add_devices[platform] = async_add_devices + if platform in self.waiting_devices: + self.async_add_devices[platform](self.waiting_devices[platform]) + + def add_devices_when_registered(self, devices: List[DynaliteBaseDevice]) -> None: + """Add the devices to HA if the add devices callback was registered, otherwise queue until it is.""" + for platform in ENTITY_PLATFORMS: + platform_devices = [ + device for device in devices if device.category == platform + ] + if platform in self.async_add_devices: + self.async_add_devices[platform](platform_devices) + else: # handle it later when it is registered + if platform not in self.waiting_devices: + self.waiting_devices[platform] = [] + self.waiting_devices[platform].extend(platform_devices) diff --git a/homeassistant/components/dynalite/config_flow.py b/homeassistant/components/dynalite/config_flow.py new file mode 100644 index 0000000000000..4c5b2ceb7d88a --- /dev/null +++ b/homeassistant/components/dynalite/config_flow.py @@ -0,0 +1,36 @@ +"""Config flow to configure Dynalite hub.""" +from typing import Any, Dict + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST + +from .bridge import DynaliteBridge +from .const import DOMAIN, LOGGER + + +class DynaliteFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a Dynalite config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + def __init__(self) -> None: + """Initialize the Dynalite flow.""" + self.host = None + + async def async_step_import(self, import_info: Dict[str, Any]) -> Any: + """Import a new bridge as a config entry.""" + LOGGER.debug("Starting async_step_import - %s", import_info) + host = import_info[CONF_HOST] + for entry in self.hass.config_entries.async_entries(DOMAIN): + if entry.data[CONF_HOST] == host: + if entry.data != import_info: + self.hass.config_entries.async_update_entry(entry, data=import_info) + return self.async_abort(reason="already_configured") + # New entry + bridge = DynaliteBridge(self.hass, import_info) + if not await bridge.async_setup(): + LOGGER.error("Unable to setup bridge - import info=%s", import_info) + return self.async_abort(reason="no_connection") + LOGGER.debug("Creating entry for the bridge - %s", import_info) + return self.async_create_entry(title=host, data=import_info) diff --git a/homeassistant/components/dynalite/const.py b/homeassistant/components/dynalite/const.py new file mode 100644 index 0000000000000..82d66dba7ba65 --- /dev/null +++ b/homeassistant/components/dynalite/const.py @@ -0,0 +1,53 @@ +"""Constants for the Dynalite component.""" +import logging + +from homeassistant.components.cover import DEVICE_CLASS_SHUTTER +from homeassistant.const import CONF_ROOM + +LOGGER = logging.getLogger(__package__) +DOMAIN = "dynalite" + +ENTITY_PLATFORMS = ["light", "switch", "cover"] + + +CONF_ACTIVE = "active" +ACTIVE_INIT = "init" +ACTIVE_OFF = "off" +ACTIVE_ON = "on" +CONF_AREA = "area" +CONF_AUTO_DISCOVER = "autodiscover" +CONF_BRIDGES = "bridges" +CONF_CHANNEL = "channel" +CONF_CHANNEL_COVER = "channel_cover" +CONF_CLOSE_PRESET = "close" +CONF_DEFAULT = "default" +CONF_DEVICE_CLASS = "class" +CONF_DURATION = "duration" +CONF_FADE = "fade" +CONF_NO_DEFAULT = "nodefault" +CONF_OPEN_PRESET = "open" +CONF_POLL_TIMER = "polltimer" +CONF_PRESET = "preset" +CONF_ROOM_OFF = "room_off" +CONF_ROOM_ON = "room_on" +CONF_STOP_PRESET = "stop" +CONF_TEMPLATE = "template" +CONF_TILT_TIME = "tilt" +CONF_TIME_COVER = "time_cover" + +DEFAULT_CHANNEL_TYPE = "light" +DEFAULT_COVER_CLASS = DEVICE_CLASS_SHUTTER +DEFAULT_NAME = "dynalite" +DEFAULT_PORT = 12345 +DEFAULT_TEMPLATES = { + CONF_ROOM: [CONF_ROOM_ON, CONF_ROOM_OFF], + CONF_TIME_COVER: [ + CONF_CHANNEL_COVER, + CONF_DEVICE_CLASS, + CONF_OPEN_PRESET, + CONF_CLOSE_PRESET, + CONF_STOP_PRESET, + CONF_DURATION, + CONF_TILT_TIME, + ], +} diff --git a/homeassistant/components/dynalite/convert_config.py b/homeassistant/components/dynalite/convert_config.py new file mode 100644 index 0000000000000..2e21d5edd9b4f --- /dev/null +++ b/homeassistant/components/dynalite/convert_config.py @@ -0,0 +1,78 @@ +"""Convert the HA config to the dynalite config.""" + +from typing import Any, Dict + +from dynalite_devices_lib import const as dyn_const + +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_ROOM, CONF_TYPE + +from .const import ( + ACTIVE_INIT, + ACTIVE_OFF, + ACTIVE_ON, + CONF_ACTIVE, + CONF_AREA, + CONF_AUTO_DISCOVER, + CONF_CHANNEL, + CONF_CHANNEL_COVER, + CONF_CLOSE_PRESET, + CONF_DEFAULT, + CONF_DEVICE_CLASS, + CONF_DURATION, + CONF_FADE, + CONF_NO_DEFAULT, + CONF_OPEN_PRESET, + CONF_POLL_TIMER, + CONF_PRESET, + CONF_ROOM_OFF, + CONF_ROOM_ON, + CONF_STOP_PRESET, + CONF_TEMPLATE, + CONF_TILT_TIME, + CONF_TIME_COVER, +) + +CONF_MAP = { + CONF_ACTIVE: dyn_const.CONF_ACTIVE, + ACTIVE_INIT: dyn_const.ACTIVE_INIT, + ACTIVE_OFF: dyn_const.ACTIVE_OFF, + ACTIVE_ON: dyn_const.ACTIVE_ON, + CONF_AREA: dyn_const.CONF_AREA, + CONF_AUTO_DISCOVER: dyn_const.CONF_AUTO_DISCOVER, + CONF_CHANNEL: dyn_const.CONF_CHANNEL, + CONF_CHANNEL_COVER: dyn_const.CONF_CHANNEL_COVER, + CONF_TYPE: dyn_const.CONF_CHANNEL_TYPE, + CONF_CLOSE_PRESET: dyn_const.CONF_CLOSE_PRESET, + CONF_DEFAULT: dyn_const.CONF_DEFAULT, + CONF_DEVICE_CLASS: dyn_const.CONF_DEVICE_CLASS, + CONF_DURATION: dyn_const.CONF_DURATION, + CONF_FADE: dyn_const.CONF_FADE, + CONF_HOST: dyn_const.CONF_HOST, + CONF_NAME: dyn_const.CONF_NAME, + CONF_NO_DEFAULT: dyn_const.CONF_NO_DEFAULT, + CONF_OPEN_PRESET: dyn_const.CONF_OPEN_PRESET, + CONF_POLL_TIMER: dyn_const.CONF_POLL_TIMER, + CONF_PORT: dyn_const.CONF_PORT, + CONF_PRESET: dyn_const.CONF_PRESET, + CONF_ROOM: dyn_const.CONF_ROOM, + CONF_ROOM_OFF: dyn_const.CONF_ROOM_OFF, + CONF_ROOM_ON: dyn_const.CONF_ROOM_ON, + CONF_STOP_PRESET: dyn_const.CONF_STOP_PRESET, + CONF_TEMPLATE: dyn_const.CONF_TEMPLATE, + CONF_TILT_TIME: dyn_const.CONF_TILT_TIME, + CONF_TIME_COVER: dyn_const.CONF_TIME_COVER, +} + + +def convert_config(config: Dict[str, Any]) -> Dict[str, Any]: + """Convert a config dict by replacing component consts with library consts.""" + result = {} + for (key, value) in config.items(): + if isinstance(value, dict): + new_value = convert_config(value) + elif isinstance(value, str): + new_value = CONF_MAP.get(value, value) + else: + new_value = value + result[CONF_MAP.get(key, key)] = new_value + return result diff --git a/homeassistant/components/dynalite/cover.py b/homeassistant/components/dynalite/cover.py new file mode 100644 index 0000000000000..a5c25945aa81d --- /dev/null +++ b/homeassistant/components/dynalite/cover.py @@ -0,0 +1,98 @@ +"""Support for the Dynalite channels as covers.""" +from typing import Callable + +from homeassistant.components.cover import DEVICE_CLASSES, CoverEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback + +from .const import DEFAULT_COVER_CLASS +from .dynalitebase import DynaliteBase, async_setup_entry_base + + +async def async_setup_entry( + hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: Callable +) -> None: + """Record the async_add_entities function to add them later when received from Dynalite.""" + + @callback + def cover_from_device(device, bridge): + if device.has_tilt: + return DynaliteCoverWithTilt(device, bridge) + return DynaliteCover(device, bridge) + + async_setup_entry_base( + hass, config_entry, async_add_entities, "cover", cover_from_device + ) + + +class DynaliteCover(DynaliteBase, CoverEntity): + """Representation of a Dynalite Channel as a Home Assistant Cover.""" + + @property + def device_class(self) -> str: + """Return the class of the device.""" + dev_cls = self._device.device_class + if dev_cls in DEVICE_CLASSES: + return dev_cls + return DEFAULT_COVER_CLASS + + @property + def current_cover_position(self) -> int: + """Return the position of the cover from 0 to 100.""" + return self._device.current_cover_position + + @property + def is_opening(self) -> bool: + """Return true if cover is opening.""" + return self._device.is_opening + + @property + def is_closing(self) -> bool: + """Return true if cover is closing.""" + return self._device.is_closing + + @property + def is_closed(self) -> bool: + """Return true if cover is closed.""" + return self._device.is_closed + + async def async_open_cover(self, **kwargs) -> None: + """Open the cover.""" + await self._device.async_open_cover(**kwargs) + + async def async_close_cover(self, **kwargs) -> None: + """Close the cover.""" + await self._device.async_close_cover(**kwargs) + + async def async_set_cover_position(self, **kwargs) -> None: + """Set the cover position.""" + await self._device.async_set_cover_position(**kwargs) + + async def async_stop_cover(self, **kwargs) -> None: + """Stop the cover.""" + await self._device.async_stop_cover(**kwargs) + + +class DynaliteCoverWithTilt(DynaliteCover): + """Representation of a Dynalite Channel as a Home Assistant Cover that uses up and down for tilt.""" + + @property + def current_cover_tilt_position(self) -> int: + """Return the current tilt position.""" + return self._device.current_cover_tilt_position + + async def async_open_cover_tilt(self, **kwargs) -> None: + """Open cover tilt.""" + await self._device.async_open_cover_tilt(**kwargs) + + async def async_close_cover_tilt(self, **kwargs) -> None: + """Close cover tilt.""" + await self._device.async_close_cover_tilt(**kwargs) + + async def async_set_cover_tilt_position(self, **kwargs) -> None: + """Set the cover tilt position.""" + await self._device.async_set_cover_tilt_position(**kwargs) + + async def async_stop_cover_tilt(self, **kwargs) -> None: + """Stop the cover tilt.""" + await self._device.async_stop_cover_tilt(**kwargs) diff --git a/homeassistant/components/dynalite/dynalitebase.py b/homeassistant/components/dynalite/dynalitebase.py new file mode 100644 index 0000000000000..31879c5c118f6 --- /dev/null +++ b/homeassistant/components/dynalite/dynalitebase.py @@ -0,0 +1,92 @@ +"""Support for the Dynalite devices as entities.""" +from typing import Any, Callable, Dict + +from homeassistant.components.dynalite.bridge import DynaliteBridge +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN, LOGGER + + +def async_setup_entry_base( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: Callable, + platform: str, + entity_from_device: Callable, +) -> None: + """Record the async_add_entities function to add them later when received from Dynalite.""" + LOGGER.debug("Setting up %s entry = %s", platform, config_entry.data) + bridge = hass.data[DOMAIN][config_entry.entry_id] + + @callback + def async_add_entities_platform(devices): + # assumes it is called with a single platform + added_entities = [] + for device in devices: + added_entities.append(entity_from_device(device, bridge)) + if added_entities: + async_add_entities(added_entities) + + bridge.register_add_devices(platform, async_add_entities_platform) + + +class DynaliteBase(Entity): + """Base class for the Dynalite entities.""" + + def __init__(self, device: Any, bridge: DynaliteBridge) -> None: + """Initialize the base class.""" + self._device = device + self._bridge = bridge + self._unsub_dispatchers = [] + + @property + def name(self) -> str: + """Return the name of the entity.""" + return self._device.name + + @property + def unique_id(self) -> str: + """Return the unique ID of the entity.""" + return self._device.unique_id + + @property + def available(self) -> bool: + """Return if entity is available.""" + return self._device.available + + @property + def device_info(self) -> Dict[str, Any]: + """Device info for this entity.""" + return { + "identifiers": {(DOMAIN, self._device.unique_id)}, + "name": self.name, + "manufacturer": "Dynalite", + } + + async def async_added_to_hass(self) -> None: + """Added to hass so need to register to dispatch.""" + # register for device specific update + self._unsub_dispatchers.append( + async_dispatcher_connect( + self.hass, + self._bridge.update_signal(self._device), + self.async_schedule_update_ha_state, + ) + ) + # register for wide update + self._unsub_dispatchers.append( + async_dispatcher_connect( + self.hass, + self._bridge.update_signal(), + self.async_schedule_update_ha_state, + ) + ) + + async def async_will_remove_from_hass(self) -> None: + """Unregister signal dispatch listeners when being removed.""" + for unsub in self._unsub_dispatchers: + unsub() + self._unsub_dispatchers = [] diff --git a/homeassistant/components/dynalite/light.py b/homeassistant/components/dynalite/light.py new file mode 100644 index 0000000000000..5e7069ab50b7e --- /dev/null +++ b/homeassistant/components/dynalite/light.py @@ -0,0 +1,45 @@ +"""Support for Dynalite channels as lights.""" +from typing import Callable + +from homeassistant.components.light import SUPPORT_BRIGHTNESS, LightEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .dynalitebase import DynaliteBase, async_setup_entry_base + + +async def async_setup_entry( + hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: Callable +) -> None: + """Record the async_add_entities function to add them later when received from Dynalite.""" + + async_setup_entry_base( + hass, config_entry, async_add_entities, "light", DynaliteLight + ) + + +class DynaliteLight(DynaliteBase, LightEntity): + """Representation of a Dynalite Channel as a Home Assistant Light.""" + + @property + def brightness(self) -> int: + """Return the brightness of this light between 0..255.""" + return self._device.brightness + + @property + def is_on(self) -> bool: + """Return true if device is on.""" + return self._device.is_on + + async def async_turn_on(self, **kwargs) -> None: + """Turn the light on.""" + await self._device.async_turn_on(**kwargs) + + async def async_turn_off(self, **kwargs) -> None: + """Turn the light off.""" + await self._device.async_turn_off(**kwargs) + + @property + def supported_features(self) -> int: + """Flag supported features.""" + return SUPPORT_BRIGHTNESS diff --git a/homeassistant/components/dynalite/manifest.json b/homeassistant/components/dynalite/manifest.json new file mode 100644 index 0000000000000..581110ba58348 --- /dev/null +++ b/homeassistant/components/dynalite/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "dynalite", + "name": "Philips Dynalite", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/dynalite", + "codeowners": ["@ziv1234"], + "requirements": ["dynalite_devices==0.1.40"] +} diff --git a/homeassistant/components/dynalite/switch.py b/homeassistant/components/dynalite/switch.py new file mode 100644 index 0000000000000..d106d976d689d --- /dev/null +++ b/homeassistant/components/dynalite/switch.py @@ -0,0 +1,35 @@ +"""Support for the Dynalite channels and presets as switches.""" +from typing import Callable + +from homeassistant.components.switch import SwitchEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .dynalitebase import DynaliteBase, async_setup_entry_base + + +async def async_setup_entry( + hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: Callable +) -> None: + """Record the async_add_entities function to add them later when received from Dynalite.""" + + async_setup_entry_base( + hass, config_entry, async_add_entities, "switch", DynaliteSwitch + ) + + +class DynaliteSwitch(DynaliteBase, SwitchEntity): + """Representation of a Dynalite Channel as a Home Assistant Switch.""" + + @property + def is_on(self) -> bool: + """Return true if switch is on.""" + return self._device.is_on + + async def async_turn_on(self, **kwargs) -> None: + """Turn the switch on.""" + await self._device.async_turn_on() + + async def async_turn_off(self, **kwargs) -> None: + """Turn the switch off.""" + await self._device.async_turn_off() diff --git a/homeassistant/components/dyson/__init__.py b/homeassistant/components/dyson/__init__.py index fdba263d4cafc..fbe7897e6bb20 100644 --- a/homeassistant/components/dyson/__init__.py +++ b/homeassistant/components/dyson/__init__.py @@ -1,36 +1,40 @@ """Support for Dyson Pure Cool Link devices.""" import logging +from libpurecool.dyson import DysonAccount 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.const import CONF_DEVICES, CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME from homeassistant.helpers import discovery +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -CONF_LANGUAGE = 'language' -CONF_RETRY = 'retry' +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) +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): @@ -40,10 +44,11 @@ def setup(hass, config): 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)) + dyson_account = DysonAccount( + config[DOMAIN].get(CONF_USERNAME), + config[DOMAIN].get(CONF_PASSWORD), + config[DOMAIN].get(CONF_LANGUAGE), + ) logged = dyson_account.login() @@ -59,8 +64,9 @@ def setup(hass, config): 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) + 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"]) @@ -68,20 +74,26 @@ def setup(hass, config): _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) + _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)) + _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"]) + "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) + _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) diff --git a/homeassistant/components/dyson/air_quality.py b/homeassistant/components/dyson/air_quality.py index 238b8b6934d76..647fb2367074f 100644 --- a/homeassistant/components/dyson/air_quality.py +++ b/homeassistant/components/dyson/air_quality.py @@ -1,21 +1,24 @@ """Support for Dyson Pure Cool Air Quality Sensors.""" import logging -from homeassistant.components.air_quality import AirQualityEntity, DOMAIN +from libpurecool.dyson_pure_cool import DysonPureCool +from libpurecool.dyson_pure_state_v2 import DysonEnvironmentalSensorV2State + +from homeassistant.components.air_quality import DOMAIN, AirQualityEntity + from . import DYSON_DEVICES -ATTRIBUTION = 'Dyson purifier air quality sensor' +ATTRIBUTION = "Dyson purifier air quality sensor" _LOGGER = logging.getLogger(__name__) -DYSON_AIQ_DEVICES = 'dyson_aiq_devices' +DYSON_AIQ_DEVICES = "dyson_aiq_devices" -ATTR_VOC = 'volatile_organic_compounds' +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 @@ -25,8 +28,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): # 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: + 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]) @@ -43,18 +45,18 @@ def __init__(self, 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) + 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): + _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() @@ -76,10 +78,12 @@ def attribution(self): @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) + 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): @@ -106,8 +110,7 @@ def nitrogen_dioxide(self): 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 int(self._device.environmental_state.volatile_organic_compounds) return None @property diff --git a/homeassistant/components/dyson/climate.py b/homeassistant/components/dyson/climate.py index a0c4c56d3188b..6b2d7cbe74ccf 100644 --- a/homeassistant/components/dyson/climate.py +++ b/homeassistant/components/dyson/climate.py @@ -1,22 +1,31 @@ """Support for Dyson Pure Hot+Cool link fan.""" import logging -from homeassistant.components.climate import ClimateDevice +from libpurecool.const import FocusMode, HeatMode, HeatState, HeatTarget +from libpurecool.dyson_pure_hotcool_link import DysonPureHotCoolLink +from libpurecool.dyson_pure_state import DysonPureHotCoolState + +from homeassistant.components.climate import ClimateEntity from homeassistant.components.climate.const import ( - STATE_COOL, STATE_HEAT, STATE_IDLE, SUPPORT_FAN_MODE, - SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE) + CURRENT_HVAC_COOL, + CURRENT_HVAC_HEAT, + CURRENT_HVAC_IDLE, + FAN_DIFFUSE, + FAN_FOCUS, + HVAC_MODE_COOL, + HVAC_MODE_HEAT, + SUPPORT_FAN_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) +SUPPORT_FAN = [FAN_FOCUS, FAN_DIFFUSE] +SUPPORT_HVAG = [HVAC_MODE_COOL, HVAC_MODE_HEAT] +SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_FAN_MODE def setup_platform(hass, config, add_devices, discovery_info=None): @@ -24,16 +33,17 @@ def setup_platform(hass, config, add_devices, discovery_info=None): 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)] + [ + DysonPureHotCoolLinkDevice(device) + for device in hass.data[DYSON_DEVICES] + if isinstance(device, DysonPureHotCoolLink) + ] ) -class DysonPureHotCoolLinkDevice(ClimateDevice): +class DysonPureHotCoolLinkDevice(ClimateEntity): """Representation of a Dyson climate fan.""" def __init__(self, device): @@ -43,17 +53,15 @@ def __init__(self, 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) + 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 not isinstance(message, DysonPureHotCoolState): + return - if isinstance(message, DysonPureHotCoolState): - _LOGGER.debug("Message received for climate device %s : %s", - self.name, message) - self.schedule_update_ha_state() + _LOGGER.debug("Message received for climate device %s : %s", self.name, message) + self.schedule_update_ha_state() @property def should_poll(self): @@ -81,8 +89,7 @@ def current_temperature(self): 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)) + self._current_temp = float(f"{(temperature_kelvin - 273):.1f}") return self._current_temp @property @@ -101,32 +108,46 @@ def current_humidity(self): return None @property - def current_operation(self): - """Return current operation ie. heat, cool, idle.""" - from libpurecool.const import HeatMode, HeatState + def hvac_mode(self): + """Return hvac operation ie. heat, cool mode. + + Need to be one of HVAC_MODE_*. + """ 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 + return HVAC_MODE_HEAT + return HVAC_MODE_COOL @property - def operation_list(self): - """Return the list of available operation modes.""" - return OPERATION_LIST + def hvac_modes(self): + """Return the list of available hvac operation modes. + + Need to be a subset of HVAC_MODES. + """ + return SUPPORT_HVAG + + @property + def hvac_action(self): + """Return the current running hvac operation if supported. + + Need to be one of CURRENT_HVAC_*. + """ + if self._device.state.heat_mode == HeatMode.HEAT_ON.value: + if self._device.state.heat_state == HeatState.HEAT_STATE_ON.value: + return CURRENT_HVAC_HEAT + return CURRENT_HVAC_IDLE + return CURRENT_HVAC_COOL @property - def current_fan_mode(self): + def 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 + return FAN_FOCUS + return FAN_DIFFUSE @property - def fan_list(self): + def fan_modes(self): """Return the list of available fan modes.""" - return FAN_LIST + return SUPPORT_FAN def set_temperature(self, **kwargs): """Set new target temperature.""" @@ -138,27 +159,24 @@ def set_temperature(self, **kwargs): # 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) + 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: + if fan_mode == FAN_FOCUS: self._device.set_configuration(focus_mode=FocusMode.FOCUS_ON) - elif fan_mode == STATE_DIFFUSE: + elif fan_mode == FAN_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: + def set_hvac_mode(self, hvac_mode): + """Set new target hvac mode.""" + _LOGGER.debug("Set %s heat mode %s", self.name, hvac_mode) + if hvac_mode == HVAC_MODE_HEAT: self._device.set_configuration(heat_mode=HeatMode.HEAT_ON) - elif operation_mode == STATE_COOL: + elif hvac_mode == HVAC_MODE_COOL: self._device.set_configuration(heat_mode=HeatMode.HEAT_OFF) @property diff --git a/homeassistant/components/dyson/fan.py b/homeassistant/components/dyson/fan.py index 65ff093d6d555..8613ab3e7af32 100644 --- a/homeassistant/components/dyson/fan.py +++ b/homeassistant/components/dyson/fan.py @@ -1,78 +1,95 @@ -"""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/ -""" +"""Support for Dyson Pure Cool link fan.""" import logging +from libpurecool.const import FanMode, FanSpeed, NightMode, Oscillation +from libpurecool.dyson_pure_cool import DysonPureCool +from libpurecool.dyson_pure_cool_link import DysonPureCoolLink +from libpurecool.dyson_pure_state import DysonPureCoolState +from libpurecool.dyson_pure_state_v2 import DysonPureCoolV2State import 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) + SPEED_HIGH, + SPEED_LOW, + SPEED_MEDIUM, + SUPPORT_OSCILLATE, + SUPPORT_SET_SPEED, + FanEntity, +) from homeassistant.const import ATTR_ENTITY_ID +import homeassistant.helpers.config_validation as cv + 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 -}) +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 @@ -99,11 +116,12 @@ def setup_platform(hass, config, add_entities, discovery_info=None): 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) + 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)) + _LOGGER.warning("Unable to find Dyson fan device %s", str(entity_id)) return if service.service == SERVICE_SET_NIGHT_MODE: @@ -113,12 +131,12 @@ def service_handle(service): 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]) + 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]) + 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]) @@ -128,28 +146,37 @@ def service_handle(service): # 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) + DYSON_DOMAIN, + SERVICE_SET_NIGHT_MODE, + service_handle, + schema=DYSON_SET_NIGHT_MODE_SCHEMA, + ) + hass.services.register( + DYSON_DOMAIN, SERVICE_SET_AUTO_MODE, service_handle, schema=SET_AUTO_MODE_SCHEMA + ) + if has_purecool_devices: hass.services.register( - DYSON_DOMAIN, SERVICE_SET_ANGLE, service_handle, - schema=SET_ANGLE_SCHEMA) + 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) + 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) + 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) + DYSON_DOMAIN, + SERVICE_SET_DYSON_SPEED, + service_handle, + schema=SET_DYSON_SPEED_SCHEMA, + ) class DysonPureCoolLinkDevice(FanEntity): @@ -163,16 +190,13 @@ def __init__(self, hass, 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) + 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) + _LOGGER.debug("Message received for fan device %s: %s", self.name, message) self.schedule_update_ha_state() @property @@ -187,53 +211,42 @@ def name(self): 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) + fan_speed = FanSpeed(f"{int(speed):04d}") + 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))) + fan_speed = FanSpeed(f"{int(speed):04d}") self._device.set_configuration( - fan_mode=FanMode.FAN, fan_speed=fan_speed) + 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) + _LOGGER.debug("Turn oscillation %s for device %s", oscillating, self.name) if oscillating: - self._device.set_configuration( - oscillation=Oscillation.OSCILLATION_ON) + self._device.set_configuration(oscillation=Oscillation.OSCILLATION_ON) else: - self._device.set_configuration( - oscillation=Oscillation.OSCILLATION_OFF) + self._device.set_configuration(oscillation=Oscillation.OSCILLATION_OFF) @property def oscillating(self): @@ -250,8 +263,6 @@ def is_on(self): @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 @@ -270,8 +281,6 @@ def night_mode(self): 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) @@ -285,8 +294,6 @@ def auto_mode(self): 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) @@ -296,8 +303,6 @@ def set_auto_mode(self, auto_mode: bool) -> None: @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), @@ -322,10 +327,7 @@ def supported_features(self) -> int: @property def device_state_attributes(self) -> dict: """Return optional state attributes.""" - return { - ATTR_NIGHT_MODE: self.night_mode, - ATTR_AUTO_MODE: self.auto_mode - } + return {ATTR_NIGHT_MODE: self.night_mode, ATTR_AUTO_MODE: self.auto_mode} class DysonPureCoolDevice(FanEntity): @@ -338,15 +340,13 @@ def __init__(self, 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) + 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) + _LOGGER.debug("Message received for fan device %s: %s", self.name, message) self.schedule_update_ha_state() @property @@ -370,7 +370,6 @@ def turn_on(self, speed: str = None, **kwargs) -> None: 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: @@ -385,17 +384,14 @@ def turn_off(self, **kwargs): 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))) + fan_speed = FanSpeed(f"{int(speed):04d}") 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) + _LOGGER.debug("Turn oscillation %s for device %s", oscillating, self.name) if oscillating: self._device.enable_oscillation() @@ -404,8 +400,7 @@ def oscillate(self, oscillating: bool) -> None: 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) + _LOGGER.debug("Turn night mode %s for device %s", night_mode, self.name) if night_mode: self._device.enable_night_mode() @@ -414,8 +409,7 @@ def set_night_mode(self, night_mode: bool) -> None: 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) + _LOGGER.debug("Turn auto mode %s for device %s", auto_mode, self.name) if auto_mode: self._device.enable_auto_mode() else: @@ -423,16 +417,21 @@ def set_auto_mode(self, auto_mode: bool) -> None: 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) + _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: + 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) + _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() @@ -441,8 +440,7 @@ def set_flow_direction_front(self, def set_timer(self, timer) -> None: """Set timer.""" - _LOGGER.debug("Set timer to %s for device %s", timer, - self.name) + _LOGGER.debug("Set timer to %s for device %s", timer, self.name) if timer == 0: self._device.disable_sleep_timer() @@ -463,27 +461,25 @@ def is_on(self): @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} + 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 @@ -512,7 +508,7 @@ def angle_high(self): @property def flow_direction_front(self): """Return frontal flow direction.""" - return self._device.state.front_direction == 'ON' + return self._device.state.front_direction == "ON" @property def timer(self): @@ -527,6 +523,8 @@ def hepa_filter(self): @property def carbon_filter(self): """Return the carbon filter state.""" + if self._device.state.carbon_filter_state == "INV": + return self._device.state.carbon_filter_state return int(self._device.state.carbon_filter_state) @property @@ -537,7 +535,6 @@ def speed_list(self) -> list: @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), @@ -559,8 +556,7 @@ def device_serial(self): @property def supported_features(self) -> int: """Flag supported features.""" - return SUPPORT_OSCILLATE | \ - SUPPORT_SET_SPEED + return SUPPORT_OSCILLATE | SUPPORT_SET_SPEED @property def device_state_attributes(self) -> dict: @@ -575,5 +571,5 @@ def device_state_attributes(self) -> dict: 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 + ATTR_DYSON_SPEED_LIST: self.dyson_speed_list, } diff --git a/homeassistant/components/dyson/manifest.json b/homeassistant/components/dyson/manifest.json index 7b956dd96c832..60800963842dc 100644 --- a/homeassistant/components/dyson/manifest.json +++ b/homeassistant/components/dyson/manifest.json @@ -1,10 +1,7 @@ { "domain": "dyson", "name": "Dyson", - "documentation": "https://www.home-assistant.io/components/dyson", - "requirements": [ - "libpurecool==0.5.0" - ], - "dependencies": [], - "codeowners": [] + "documentation": "https://www.home-assistant.io/integrations/dyson", + "requirements": ["libpurecool==0.6.1"], + "codeowners": ["@etheralm"] } diff --git a/homeassistant/components/dyson/sensor.py b/homeassistant/components/dyson/sensor.py index 9cd1c915c570f..55f2ff69314c1 100644 --- a/homeassistant/components/dyson/sensor.py +++ b/homeassistant/components/dyson/sensor.py @@ -1,34 +1,36 @@ """Support for Dyson Pure Cool Link Sensors.""" import logging -from homeassistant.const import STATE_OFF, TEMP_CELSIUS +from libpurecool.dyson_pure_cool import DysonPureCool +from libpurecool.dyson_pure_cool_link import DysonPureCoolLink + +from homeassistant.const import STATE_OFF, TEMP_CELSIUS, TIME_HOURS, UNIT_PERCENTAGE from homeassistant.helpers.entity import Entity + from . import DYSON_DEVICES SENSOR_UNITS = { - 'air_quality': None, - 'dust': None, - 'filter_life': 'hours', - 'humidity': '%', + "air_quality": None, + "dust": None, + "filter_life": TIME_HOURS, + "humidity": UNIT_PERCENTAGE, } SENSOR_ICONS = { - 'air_quality': 'mdi:fan', - 'dust': 'mdi:cloud', - 'filter_life': 'mdi:filter-outline', - 'humidity': 'mdi:water-percent', - 'temperature': 'mdi:thermometer', + "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' +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 @@ -38,13 +40,12 @@ def setup_platform(hass, config, add_entities, discovery_info=None): 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]] + 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: + if f"{device.serial}-temperature" not in device_ids: devices.append(DysonTemperatureSensor(device, unit)) - if '{}-{}'.format(device.serial, 'humidity') not in device_ids: + if f"{device.serial}-humidity" not in device_ids: devices.append(DysonHumiditySensor(device)) elif isinstance(device, DysonPureCoolLink): devices.append(DysonFilterLifeSensor(device)) @@ -68,14 +69,14 @@ def __init__(self, device, 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) + 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) + _LOGGER.debug("Message received for %s device: %s", self.name, message) self._old_value = self.state self.schedule_update_ha_state() @@ -102,7 +103,7 @@ def icon(self): @property def unique_id(self): """Return the sensor's unique id.""" - return '{}-{}'.format(self._device.serial, self._sensor_type) + return f"{self._device.serial}-{self._sensor_type}" class DysonFilterLifeSensor(DysonSensor): @@ -110,8 +111,8 @@ class DysonFilterLifeSensor(DysonSensor): def __init__(self, device): """Create a new Dyson Filter Life sensor.""" - super().__init__(device, 'filter_life') - self._name = "{} Filter Life".format(self._device.name) + super().__init__(device, "filter_life") + self._name = f"{self._device.name} Filter Life" @property def state(self): @@ -126,8 +127,8 @@ class DysonDustSensor(DysonSensor): def __init__(self, device): """Create a new Dyson Dust sensor.""" - super().__init__(device, 'dust') - self._name = "{} Dust".format(self._device.name) + super().__init__(device, "dust") + self._name = f"{self._device.name} Dust" @property def state(self): @@ -142,8 +143,8 @@ class DysonHumiditySensor(DysonSensor): def __init__(self, device): """Create a new Dyson Humidity sensor.""" - super().__init__(device, 'humidity') - self._name = "{} Humidity".format(self._device.name) + super().__init__(device, "humidity") + self._name = f"{self._device.name} Humidity" @property def state(self): @@ -160,8 +161,8 @@ class DysonTemperatureSensor(DysonSensor): def __init__(self, device, unit): """Create a new Dyson Temperature sensor.""" - super().__init__(device, 'temperature') - self._name = "{} Temperature".format(self._device.name) + super().__init__(device, "temperature") + self._name = f"{self._device.name} Temperature" self._unit = unit @property @@ -172,8 +173,8 @@ def state(self): 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 float(f"{(temperature_kelvin - 273.15):.1f}") + return float(f"{(temperature_kelvin * 9 / 5 - 459.67):.1f}") return None @property @@ -187,12 +188,12 @@ class DysonAirQualitySensor(DysonSensor): def __init__(self, device): """Create a new Dyson Air Quality sensor.""" - super().__init__(device, 'air_quality') - self._name = "{} AQI".format(self._device.name) + super().__init__(device, "air_quality") + self._name = f"{self._device.name} AQI" @property def state(self): """Return Air Quality value.""" if self._device.environmental_state: - return self._device.environmental_state.volatil_organic_compounds + return int(self._device.environmental_state.volatil_organic_compounds) return None diff --git a/homeassistant/components/dyson/services.yaml b/homeassistant/components/dyson/services.yaml index a93b15b4304bf..73f7bc7587449 100644 --- a/homeassistant/components/dyson/services.yaml +++ b/homeassistant/components/dyson/services.yaml @@ -5,7 +5,7 @@ set_night_mode: fields: entity_id: description: Name(s) of the entities to enable/disable night mode - example: 'fan.living_room' + example: "fan.living_room" night_mode: description: Night mode status example: true @@ -15,7 +15,7 @@ set_auto_mode: fields: entity_id: description: Name(s) of the entities to enable/disable auto mode - example: 'fan.living_room' + example: "fan.living_room" auto_mode: description: Auto mode status example: true @@ -25,7 +25,7 @@ set_angle: fields: entity_id: description: Name(s) of the entities for which to set the angle - example: 'fan.living_room' + example: "fan.living_room" angle_low: description: The angle at which the oscillation should start example: 1 @@ -38,7 +38,7 @@ flow_direction_front: fields: entity_id: description: Name(s) of the entities to set frontal flow direction for - example: 'fan.living_room' + example: "fan.living_room" flow_direction_front: description: Frontal flow direction example: true @@ -48,7 +48,7 @@ set_timer: fields: entity_id: description: Name(s) of the entities to set the sleep timer for - example: 'fan.living_room' + example: "fan.living_room" timer: description: The value in minutes to set the timer to, 0 to disable it example: 30 @@ -58,7 +58,7 @@ set_speed: fields: entity_id: description: Name(s) of the entities to set the speed for - example: 'fan.living_room' - timer: + example: "fan.living_room" + dyson_speed: description: Speed - example: 1 \ No newline at end of file + example: 1 diff --git a/homeassistant/components/dyson/vacuum.py b/homeassistant/components/dyson/vacuum.py index 0bb2368f69037..2306e07072da4 100644 --- a/homeassistant/components/dyson/vacuum.py +++ b/homeassistant/components/dyson/vacuum.py @@ -1,38 +1,52 @@ """Support for the Dyson 360 eye vacuum cleaner robot.""" import logging +from libpurecool.const import Dyson360EyeMode, PowerMode +from libpurecool.dyson_360_eye import Dyson360Eye + 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) + SUPPORT_BATTERY, + SUPPORT_FAN_SPEED, + SUPPORT_PAUSE, + SUPPORT_RETURN_HOME, + SUPPORT_STATUS, + SUPPORT_STOP, + SUPPORT_TURN_OFF, + SUPPORT_TURN_ON, + VacuumEntity, +) 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' +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 +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)]: + 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) @@ -40,7 +54,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): return True -class Dyson360EyeDevice(VacuumDevice): +class Dyson360EyeDevice(VacuumEntity): """Dyson 360 Eye robot vacuum device.""" def __init__(self, device): @@ -50,8 +64,7 @@ def __init__(self, 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) + 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.""" @@ -74,7 +87,6 @@ def name(self): @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", @@ -83,13 +95,11 @@ def status(self): 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.FAULT_REPLACE_ON_DOCK: "Error - Replace device on dock", Dyson360EyeMode.FULL_CLEAN_FINISHED: "Finished", - Dyson360EyeMode.FULL_CLEAN_NEEDS_CHARGE: "Need charging" + Dyson360EyeMode.FULL_CLEAN_NEEDS_CHARGE: "Need charging", } - return dyson_labels.get( - self._device.state.state, self._device.state.state) + return dyson_labels.get(self._device.state.state, self._device.state.state) @property def battery_level(self): @@ -99,11 +109,7 @@ def battery_level(self): @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" - } + speed_labels = {PowerMode.MAX: "Max", PowerMode.QUIET: "Quiet"} return speed_labels[self._device.state.power_mode] @property @@ -114,19 +120,15 @@ def fan_speed_list(self): @property def device_state_attributes(self): """Return the specific state attributes of this vacuum cleaner.""" - return { - ATTR_POSITION: str(self._device.state.position) - } + 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 + Dyson360EyeMode.FULL_CLEAN_RUNNING, ] @property @@ -142,17 +144,13 @@ def supported_features(self): @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] + charging = self._device.state.state in [Dyson360EyeMode.INACTIVE_CHARGING] return icon_for_battery_level( - battery_level=self.battery_level, charging=charging) + 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() @@ -171,24 +169,19 @@ def stop(self, **kwargs): 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 - } + 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]: + elif self._device.state.state in [ + Dyson360EyeMode.INACTIVE_CHARGED, + Dyson360EyeMode.INACTIVE_CHARGING, + ]: _LOGGER.debug("Start device %s", self.name) self._device.start() else: diff --git a/homeassistant/components/ebox/manifest.json b/homeassistant/components/ebox/manifest.json index 16b033df8fdc0..18f26436981b3 100644 --- a/homeassistant/components/ebox/manifest.json +++ b/homeassistant/components/ebox/manifest.json @@ -1,10 +1,7 @@ { "domain": "ebox", - "name": "Ebox", - "documentation": "https://www.home-assistant.io/components/ebox", - "requirements": [ - "pyebox==1.1.4" - ], - "dependencies": [], + "name": "EBox", + "documentation": "https://www.home-assistant.io/integrations/ebox", + "requirements": ["pyebox==1.1.4"], "codeowners": [] } diff --git a/homeassistant/components/ebox/sensor.py b/homeassistant/components/ebox/sensor.py index aaf3384d55ff3..dc150109cf77d 100644 --- a/homeassistant/components/ebox/sensor.py +++ b/homeassistant/components/ebox/sensor.py @@ -2,68 +2,72 @@ 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 logging +from pyebox import EboxClient +from pyebox.client import PyEboxError 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) + CONF_MONITORED_VARIABLES, + CONF_NAME, + CONF_PASSWORD, + CONF_USERNAME, + DATA_GIGABITS, + TIME_DAYS, + UNIT_PERCENTAGE, +) +from homeassistant.exceptions import PlatformNotReady +import homeassistant.helpers.config_validation as cv 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 +PRICE = "CAD" -DEFAULT_NAME = 'EBox' +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'], + "usage": ["Usage", UNIT_PERCENTAGE, "mdi:percent"], + "balance": ["Balance", PRICE, "mdi:square-inc-cash"], + "limit": ["Data limit", DATA_GIGABITS, "mdi:download"], + "days_left": ["Days left", TIME_DAYS, "mdi:calendar-today"], + "before_offpeak_download": [ + "Download before offpeak", + DATA_GIGABITS, + "mdi:download", + ], + "before_offpeak_upload": ["Upload before offpeak", DATA_GIGABITS, "mdi:upload"], + "before_offpeak_total": ["Total before offpeak", DATA_GIGABITS, "mdi:download"], + "offpeak_download": ["Offpeak download", DATA_GIGABITS, "mdi:download"], + "offpeak_upload": ["Offpeak Upload", DATA_GIGABITS, "mdi:upload"], + "offpeak_total": ["Offpeak Total", DATA_GIGABITS, "mdi:download"], + "download": ["Download", DATA_GIGABITS, "mdi:download"], + "upload": ["Upload", DATA_GIGABITS, "mdi:upload"], + "total": ["Total", DATA_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, -}) +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): +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) @@ -73,7 +77,6 @@ async def async_setup_platform(hass, config, async_add_entities, name = config.get(CONF_NAME) - from pyebox.client import PyEboxError try: await ebox_data.async_update() except PyEboxError as exp: @@ -103,7 +106,7 @@ def __init__(self, ebox_data, sensor_type, name): @property def name(self): """Return the name of the sensor.""" - return '{} {}'.format(self.client_name, self._name) + return f"{self.client_name} {self._name}" @property def state(self): @@ -132,15 +135,12 @@ class EBoxData: def __init__(self, username, password, httpsession): """Initialize the data object.""" - from pyebox import EboxClient - self.client = EboxClient(username, password, - REQUESTS_TIMEOUT, httpsession) + 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: diff --git a/homeassistant/components/ebusd/__init__.py b/homeassistant/components/ebusd/__init__.py index 15ff523f4fbf9..a1a7fc8808606 100644 --- a/homeassistant/components/ebusd/__init__.py +++ b/homeassistant/components/ebusd/__init__.py @@ -3,36 +3,58 @@ import logging import socket +import ebusdpy import voluptuous as vol from homeassistant.const import ( - CONF_NAME, CONF_HOST, CONF_PORT, CONF_MONITORED_CONDITIONS) + CONF_HOST, + CONF_MONITORED_CONDITIONS, + CONF_NAME, + CONF_PORT, +) 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) +from .const import DOMAIN, SENSOR_TYPES _LOGGER = logging.getLogger(__name__) -DEFAULT_NAME = 'ebusd' +DEFAULT_NAME = "ebusd" DEFAULT_PORT = 8888 -CONF_CIRCUIT = 'circuit' +CONF_CIRCUIT = "circuit" CACHE_TTL = 900 -SERVICE_EBUSD_WRITE = 'ebusd_write' +SERVICE_EBUSD_WRITE = "ebusd_write" MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=15) -CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ - 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=[]): vol.All( - cv.ensure_list, [vol.In(SENSOR_TYPES['700'])]) - }) -}, extra=vol.ALLOW_EXTRA) + +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(f"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): @@ -41,28 +63,26 @@ def setup(hass, config): 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)) + server_address = (conf.get(CONF_HOST), conf.get(CONF_PORT)) try: - _LOGGER.debug("Ebusd component setup started") - import ebusdpy + _LOGGER.debug("Ebusd integration setup started") + 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] + "client_name": name, + "sensor_types": SENSOR_TYPES[circuit], } - load_platform(hass, 'sensor', DOMAIN, sensor_config, config) + load_platform(hass, "sensor", DOMAIN, sensor_config, config) - hass.services.register( - DOMAIN, SERVICE_EBUSD_WRITE, hass.data[DOMAIN].write) + hass.services.register(DOMAIN, SERVICE_EBUSD_WRITE, hass.data[DOMAIN].write) - _LOGGER.debug("Ebusd component setup completed") + _LOGGER.debug("Ebusd integration setup completed") return True - except (socket.timeout, socket.error): + except (socket.timeout, OSError): return False @@ -78,14 +98,13 @@ def __init__(self, address, circuit): @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) + self._address, self._circuit, name, stype, CACHE_TTL + ) if command_result is not None: - if 'ERR:' in command_result: + if "ERR:" in command_result: _LOGGER.warning(command_result) else: self.value[name] = command_result @@ -95,16 +114,14 @@ def update(self, name, stype): def write(self, call): """Call write methon on ebusd.""" - import ebusdpy - name = call.data.get('name') - value = call.data.get('value') + 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) + 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) + 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 index 3821bd8ce159e..10ed0b68e8778 100644 --- a/homeassistant/components/ebusd/const.py +++ b/homeassistant/components/ebusd/const.py @@ -1,102 +1,138 @@ """Constants for ebus component.""" -from homeassistant.const import ENERGY_KILO_WATT_HOUR +from homeassistant.const import ( + ENERGY_KILO_WATT_HOUR, + PRESSURE_BAR, + TEMP_CELSIUS, + TIME_SECONDS, +) -DOMAIN = 'ebusd' +DOMAIN = "ebusd" -# SensorTypes: +# SensorTypes from ebusdpy module : # 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] + "700": { + "ActualFlowTemperatureDesired": [ + "Hc1ActualFlowTempDesired", + TEMP_CELSIUS, + "mdi:thermometer", + 0, + ], + "MaxFlowTemperatureDesired": [ + "Hc1MaxFlowTempDesired", + TEMP_CELSIUS, + "mdi:thermometer", + 0, + ], + "MinFlowTemperatureDesired": [ + "Hc1MinFlowTempDesired", + TEMP_CELSIUS, + "mdi:thermometer", + 0, + ], + "PumpStatus": ["Hc1PumpStatus", None, "mdi:toggle-switch", 2], + "HCSummerTemperatureLimit": [ + "Hc1SummerTempLimit", + TEMP_CELSIUS, + "mdi:weather-sunny", + 0, + ], + "HolidayTemperature": ["HolidayTemp", TEMP_CELSIUS, "mdi:thermometer", 0], + "HWTemperatureDesired": ["HwcTempDesired", TEMP_CELSIUS, "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", PRESSURE_BAR, "mdi:water-pump", 0], + "Zone1RoomZoneMapping": ["z1RoomZoneMapping", None, "mdi:label", 0], + "Zone1NightTemperature": ["z1NightTemp", TEMP_CELSIUS, "mdi:weather-night", 0], + "Zone1DayTemperature": ["z1DayTemp", TEMP_CELSIUS, "mdi:weather-sunny", 0], + "Zone1HolidayTemperature": [ + "z1HolidayTemp", + TEMP_CELSIUS, + "mdi:thermometer", + 0, + ], + "Zone1RoomTemperature": ["z1RoomTemp", TEMP_CELSIUS, "mdi:thermometer", 0], + "Zone1ActualRoomTemperatureDesired": [ + "z1ActualRoomTempDesired", + TEMP_CELSIUS, + "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", TEMP_CELSIUS, "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] + "ehp": { + "HWTemperature": ["HwcTemp", TEMP_CELSIUS, "mdi:thermometer", 4], + "OutsideTemp": ["OutsideTemp", TEMP_CELSIUS, "mdi:thermometer", 4], + }, + "bai": { + "HotWaterTemperature": ["HwcTemp", TEMP_CELSIUS, "mdi:thermometer", 4], + "StorageTemperature": ["StorageTemp", TEMP_CELSIUS, "mdi:thermometer", 4], + "DesiredStorageTemperature": [ + "StorageTempDesired", + TEMP_CELSIUS, + "mdi:thermometer", + 0, + ], + "OutdoorsTemperature": [ + "OutdoorstempSensor", + TEMP_CELSIUS, + "mdi:thermometer", + 4, + ], + "WaterPreasure": ["WaterPressure", PRESSURE_BAR, "mdi:pipe", 4], + "AverageIgnitionTime": ["averageIgnitiontime", TIME_SECONDS, "mdi:av-timer", 0], + "MaximumIgnitionTime": ["maxIgnitiontime", TIME_SECONDS, "mdi:av-timer", 0], + "MinimumIgnitionTime": ["minIgnitiontime", TIME_SECONDS, "mdi:av-timer", 0], + "ReturnTemperature": ["ReturnTemp", TEMP_CELSIUS, "mdi:thermometer", 4], + "CentralHeatingPump": ["WP", None, "mdi:toggle-switch", 2], + "HeatingSwitch": ["HeatingSwitch", None, "mdi:toggle-switch", 2], + "DesiredFlowTemperature": [ + "FlowTempDesired", + TEMP_CELSIUS, + "mdi:thermometer", + 0, + ], + "FlowTemperature": ["FlowTemp", TEMP_CELSIUS, "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], }, - '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 index 46b8fb761dcb7..482b691851850 100644 --- a/homeassistant/components/ebusd/manifest.json +++ b/homeassistant/components/ebusd/manifest.json @@ -1,10 +1,7 @@ { "domain": "ebusd", - "name": "Ebusd", - "documentation": "https://www.home-assistant.io/components/ebusd", - "requirements": [ - "ebusdpy==0.0.16" - ], - "dependencies": [], + "name": "ebusd", + "documentation": "https://www.home-assistant.io/integrations/ebusd", + "requirements": ["ebusdpy==0.0.16"], "codeowners": [] } diff --git a/homeassistant/components/ebusd/sensor.py b/homeassistant/components/ebusd/sensor.py index f73bb09b50969..63f72a89ccd8a 100644 --- a/homeassistant/components/ebusd/sensor.py +++ b/homeassistant/components/ebusd/sensor.py @@ -1,17 +1,18 @@ """Support for Ebusd sensors.""" -import logging import datetime +import logging from homeassistant.helpers.entity import Entity +import homeassistant.util.dt as dt_util 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' +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__) @@ -19,13 +20,14 @@ 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'] + 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)) + dev.append( + EbusdSensor(ebusd_api, discovery_info["sensor_types"][condition], name) + ) add_entities(dev, True) @@ -43,7 +45,7 @@ def __init__(self, data, sensor, name): @property def name(self): """Return the name of the sensor.""" - return '{} {}'.format(self._client_name, self._name) + return f"{self._client_name} {self._name}" @property def state(self): @@ -60,17 +62,15 @@ def device_state_attributes(self): TIME_FRAME2_BEGIN: None, TIME_FRAME2_END: None, TIME_FRAME3_BEGIN: None, - TIME_FRAME3_END: None + TIME_FRAME3_END: None, } - time_frame = self._state.split(';') + 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 = datetime.datetime.strptime(time_frame[index], "%H:%M") parsed = parsed.replace( - datetime.datetime.now().year, - datetime.datetime.now().month, - datetime.datetime.now().day) + dt_util.now().year, dt_util.now().month, dt_util.now().day + ) schedule[item[0]] = parsed.isoformat() return schedule return None diff --git a/homeassistant/components/ebusd/services.yaml b/homeassistant/components/ebusd/services.yaml index 0f64533f7f156..eee9896da108f 100644 --- a/homeassistant/components/ebusd/services.yaml +++ b/homeassistant/components/ebusd/services.yaml @@ -3,4 +3,4 @@ write: fields: call: description: Property name and value to set - example: '{"name": "Hc1MaxFlowTempDesired", "value": 21}' \ No newline at end of file + example: '{"name": "Hc1MaxFlowTempDesired", "value": 21}' diff --git a/homeassistant/components/ebusd/strings.json b/homeassistant/components/ebusd/strings.json deleted file mode 100644 index ee62df8ddad5f..0000000000000 --- a/homeassistant/components/ebusd/strings.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "state": { - "day": "Day", - "night": "Night" - } -} \ No newline at end of file diff --git a/homeassistant/components/ebusd/.translations/bg.json b/homeassistant/components/ebusd/translations/bg.json similarity index 100% rename from homeassistant/components/ebusd/.translations/bg.json rename to homeassistant/components/ebusd/translations/bg.json diff --git a/homeassistant/components/ebusd/.translations/ca.json b/homeassistant/components/ebusd/translations/ca.json similarity index 100% rename from homeassistant/components/ebusd/.translations/ca.json rename to homeassistant/components/ebusd/translations/ca.json diff --git a/homeassistant/components/ebusd/.translations/cs.json b/homeassistant/components/ebusd/translations/cs.json similarity index 100% rename from homeassistant/components/ebusd/.translations/cs.json rename to homeassistant/components/ebusd/translations/cs.json diff --git a/homeassistant/components/ebusd/.translations/da.json b/homeassistant/components/ebusd/translations/da.json similarity index 100% rename from homeassistant/components/ebusd/.translations/da.json rename to homeassistant/components/ebusd/translations/da.json diff --git a/homeassistant/components/ebusd/.translations/de.json b/homeassistant/components/ebusd/translations/de.json similarity index 100% rename from homeassistant/components/ebusd/.translations/de.json rename to homeassistant/components/ebusd/translations/de.json diff --git a/homeassistant/components/ebusd/.translations/ebusd.en.json b/homeassistant/components/ebusd/translations/ebusd.en.json similarity index 100% rename from homeassistant/components/ebusd/.translations/ebusd.en.json rename to homeassistant/components/ebusd/translations/ebusd.en.json diff --git a/homeassistant/components/ebusd/.translations/ebusd.it.json b/homeassistant/components/ebusd/translations/ebusd.it.json similarity index 100% rename from homeassistant/components/ebusd/.translations/ebusd.it.json rename to homeassistant/components/ebusd/translations/ebusd.it.json diff --git a/homeassistant/components/ebusd/.translations/en.json b/homeassistant/components/ebusd/translations/en.json similarity index 100% rename from homeassistant/components/ebusd/.translations/en.json rename to homeassistant/components/ebusd/translations/en.json diff --git a/homeassistant/components/ebusd/.translations/es-419.json b/homeassistant/components/ebusd/translations/es-419.json similarity index 100% rename from homeassistant/components/ebusd/.translations/es-419.json rename to homeassistant/components/ebusd/translations/es-419.json diff --git a/homeassistant/components/ebusd/.translations/es.json b/homeassistant/components/ebusd/translations/es.json similarity index 100% rename from homeassistant/components/ebusd/.translations/es.json rename to homeassistant/components/ebusd/translations/es.json diff --git a/homeassistant/components/ebusd/.translations/fr.json b/homeassistant/components/ebusd/translations/fr.json similarity index 100% rename from homeassistant/components/ebusd/.translations/fr.json rename to homeassistant/components/ebusd/translations/fr.json diff --git a/homeassistant/components/ebusd/.translations/he.json b/homeassistant/components/ebusd/translations/he.json similarity index 100% rename from homeassistant/components/ebusd/.translations/he.json rename to homeassistant/components/ebusd/translations/he.json diff --git a/homeassistant/components/ebusd/.translations/hu.json b/homeassistant/components/ebusd/translations/hu.json similarity index 100% rename from homeassistant/components/ebusd/.translations/hu.json rename to homeassistant/components/ebusd/translations/hu.json diff --git a/homeassistant/components/ebusd/.translations/it.json b/homeassistant/components/ebusd/translations/it.json similarity index 100% rename from homeassistant/components/ebusd/.translations/it.json rename to homeassistant/components/ebusd/translations/it.json diff --git a/homeassistant/components/ebusd/.translations/ko.json b/homeassistant/components/ebusd/translations/ko.json similarity index 100% rename from homeassistant/components/ebusd/.translations/ko.json rename to homeassistant/components/ebusd/translations/ko.json diff --git a/homeassistant/components/ebusd/.translations/lb.json b/homeassistant/components/ebusd/translations/lb.json similarity index 100% rename from homeassistant/components/ebusd/.translations/lb.json rename to homeassistant/components/ebusd/translations/lb.json diff --git a/homeassistant/components/ebusd/.translations/nl.json b/homeassistant/components/ebusd/translations/nl.json similarity index 100% rename from homeassistant/components/ebusd/.translations/nl.json rename to homeassistant/components/ebusd/translations/nl.json diff --git a/homeassistant/components/ebusd/.translations/no.json b/homeassistant/components/ebusd/translations/no.json similarity index 100% rename from homeassistant/components/ebusd/.translations/no.json rename to homeassistant/components/ebusd/translations/no.json diff --git a/homeassistant/components/ebusd/.translations/pl.json b/homeassistant/components/ebusd/translations/pl.json similarity index 100% rename from homeassistant/components/ebusd/.translations/pl.json rename to homeassistant/components/ebusd/translations/pl.json diff --git a/homeassistant/components/ebusd/.translations/pt.json b/homeassistant/components/ebusd/translations/pt-BR.json similarity index 100% rename from homeassistant/components/ebusd/.translations/pt.json rename to homeassistant/components/ebusd/translations/pt-BR.json 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 similarity index 100% rename from homeassistant/components/ebusd/.translations/ru.json rename to homeassistant/components/ebusd/translations/ru.json diff --git a/homeassistant/components/ebusd/.translations/sl.json b/homeassistant/components/ebusd/translations/sl.json similarity index 100% rename from homeassistant/components/ebusd/.translations/sl.json rename to homeassistant/components/ebusd/translations/sl.json diff --git a/homeassistant/components/ebusd/.translations/sv.json b/homeassistant/components/ebusd/translations/sv.json similarity index 100% rename from homeassistant/components/ebusd/.translations/sv.json rename to homeassistant/components/ebusd/translations/sv.json diff --git a/homeassistant/components/ebusd/.translations/th.json b/homeassistant/components/ebusd/translations/th.json similarity index 100% rename from homeassistant/components/ebusd/.translations/th.json rename to homeassistant/components/ebusd/translations/th.json diff --git a/homeassistant/components/ebusd/.translations/uk.json b/homeassistant/components/ebusd/translations/uk.json similarity index 100% rename from homeassistant/components/ebusd/.translations/uk.json rename to homeassistant/components/ebusd/translations/uk.json diff --git a/homeassistant/components/ebusd/.translations/zh-Hans.json b/homeassistant/components/ebusd/translations/zh-Hans.json similarity index 100% rename from homeassistant/components/ebusd/.translations/zh-Hans.json rename to homeassistant/components/ebusd/translations/zh-Hans.json diff --git a/homeassistant/components/ebusd/.translations/zh-Hant.json b/homeassistant/components/ebusd/translations/zh-Hant.json similarity index 100% rename from homeassistant/components/ebusd/.translations/zh-Hant.json rename to homeassistant/components/ebusd/translations/zh-Hant.json diff --git a/homeassistant/components/ecoal_boiler/__init__.py b/homeassistant/components/ecoal_boiler/__init__.py index 796324d9337db..b0ca7aec5ccde 100644 --- a/homeassistant/components/ecoal_boiler/__init__.py +++ b/homeassistant/components/ecoal_boiler/__init__.py @@ -1,18 +1,24 @@ """Support to control ecoal/esterownik.pl coal/wood boiler controller.""" import logging +from ecoaliface.simple import ECoalController import voluptuous as vol -from homeassistant.const import (CONF_HOST, CONF_PASSWORD, CONF_USERNAME, - CONF_MONITORED_CONDITIONS, CONF_SENSORS, - CONF_SWITCHES) +from homeassistant.const import ( + CONF_HOST, + CONF_MONITORED_CONDITIONS, + CONF_PASSWORD, + CONF_SENSORS, + CONF_SWITCHES, + CONF_USERNAME, +) 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 +DATA_ECOAL_BOILER = f"data_{DOMAIN}" DEFAULT_USERNAME = "admin" DEFAULT_PASSWORD = "admin" @@ -29,42 +35,52 @@ # 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', + "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)]) -}) +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)]) -}) +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) +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] @@ -74,16 +90,18 @@ def setup(hass, hass_config): 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, ) + _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, ) + _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) + 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) + 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 index 5bd488e0ff4bd..c51f737cfd816 100644 --- a/homeassistant/components/ecoal_boiler/manifest.json +++ b/homeassistant/components/ecoal_boiler/manifest.json @@ -1,10 +1,7 @@ { "domain": "ecoal_boiler", - "name": "Ecoal boiler", - "documentation": "https://www.home-assistant.io/components/ecoal_boiler", - "requirements": [ - "ecoaliface==0.4.0" - ], - "dependencies": [], + "name": "eSterownik eCoal.pl Boiler", + "documentation": "https://www.home-assistant.io/integrations/ecoal_boiler", + "requirements": ["ecoaliface==0.4.0"], "codeowners": [] } diff --git a/homeassistant/components/ecoal_boiler/switch.py b/homeassistant/components/ecoal_boiler/switch.py index 9f286e625a520..bd3b216d705bd 100644 --- a/homeassistant/components/ecoal_boiler/switch.py +++ b/homeassistant/components/ecoal_boiler/switch.py @@ -2,7 +2,7 @@ import logging from typing import Optional -from homeassistant.components.switch import SwitchDevice +from homeassistant.components.switch import SwitchEntity from . import AVAILABLE_PUMPS, DATA_ECOAL_BOILER @@ -21,7 +21,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): add_entities(switches, True) -class EcoalSwitch(SwitchDevice): +class EcoalSwitch(SwitchEntity): """Representation of Ecoal switch.""" def __init__(self, ecoal_contr, name, state_attr): @@ -38,7 +38,7 @@ def __init__(self, ecoal_contr, name, state_attr): # set_() # as attribute name in status instance: # status. - self._contr_set_fun = getattr(self._ecoal_contr, "set_" + state_attr) + self._contr_set_fun = getattr(self._ecoal_contr, f"set_{state_attr}") # No value set, will be read from controller instead self._state = None diff --git a/homeassistant/components/ecobee/__init__.py b/homeassistant/components/ecobee/__init__.py index 5f9ae6a919da1..26bfbe5b3dadc 100644 --- a/homeassistant/components/ecobee/__init__.py +++ b/homeassistant/components/ecobee/__init__.py @@ -1,115 +1,128 @@ -"""Support for Ecobee devices.""" -import logging -import os +"""Support for ecobee.""" +import asyncio from datetime import timedelta +from pyecobee import ECOBEE_API_KEY, ECOBEE_REFRESH_TOKEN, Ecobee, ExpiredTokenError import voluptuous as vol -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers import discovery +from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import CONF_API_KEY +from homeassistant.helpers import config_validation as cv 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' +from .const import ( + _LOGGER, + CONF_REFRESH_TOKEN, + DATA_ECOBEE_CONFIG, + DOMAIN, + ECOBEE_PLATFORMS, +) MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=180) -NETWORK = None +CONFIG_SCHEMA = vol.Schema( + {DOMAIN: vol.Schema({vol.Optional(CONF_API_KEY): cv.string})}, extra=vol.ALLOW_EXTRA +) -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) +async def async_setup(hass, config): + """ + Ecobee uses config flow for configuration. -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 + But, an "ecobee:" entry in configuration.yaml will trigger an import flow + if a config entry doesn't already exist. If ecobee.conf exists, the import + flow will attempt to import it and create a config entry, to assist users + migrating from the old ecobee component. Otherwise, the user will have to + continue setting up the integration via the config flow. + """ + hass.data[DATA_ECOBEE_CONFIG] = config.get(DOMAIN, {}) - def ecobee_configuration_callback(callback_data): - """Handle configuration callbacks.""" - network.request_tokens() - network.update() - setup_ecobee(hass, network, config) + if not hass.config_entries.async_entries(DOMAIN) and hass.data[DATA_ECOBEE_CONFIG]: + # No config entry exists and configuration.yaml config exists, trigger the import flow. + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT} + ) + ) - _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." - ) + return True -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 +async def async_setup_entry(hass, entry): + """Set up ecobee via a config entry.""" + api_key = entry.data[CONF_API_KEY] + refresh_token = entry.data[CONF_REFRESH_TOKEN] - if 'ecobee' in _CONFIGURING: - configurator = hass.components.configurator - configurator.request_done(_CONFIGURING.pop('ecobee')) + data = EcobeeData(hass, entry, api_key=api_key, refresh_token=refresh_token) - hold_temp = config[DOMAIN].get(CONF_HOLD_TEMP) + if not await data.refresh(): + return False - 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) + await data.update() + if data.ecobee.thermostats is None: + _LOGGER.error("No ecobee devices found to set up") + return False -class EcobeeData: - """Get the latest data and update the states.""" + hass.data[DOMAIN] = data - 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") + for component in ECOBEE_PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + return True -def setup(hass, config): - """Set up the Ecobee. - Will automatically load thermostat and sensor components to support - devices discovered on the network. +class EcobeeData: """ - global NETWORK - - if 'ecobee' in _CONFIGURING: - return + Handle getting the latest data from ecobee.com so platforms can use it. - # 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)) + Also handle refreshing tokens and updating config entry with refreshed tokens. + """ - setup_ecobee(hass, NETWORK.ecobee, config) + def __init__(self, hass, entry, api_key, refresh_token): + """Initialize the Ecobee data object.""" + self._hass = hass + self._entry = entry + self.ecobee = Ecobee( + config={ECOBEE_API_KEY: api_key, ECOBEE_REFRESH_TOKEN: refresh_token} + ) - return True + @Throttle(MIN_TIME_BETWEEN_UPDATES) + async def update(self): + """Get the latest data from ecobee.com.""" + try: + await self._hass.async_add_executor_job(self.ecobee.update) + _LOGGER.debug("Updating ecobee") + except ExpiredTokenError: + _LOGGER.debug("Refreshing expired ecobee tokens") + await self.refresh() + + async def refresh(self) -> bool: + """Refresh ecobee tokens and update config entry.""" + _LOGGER.debug("Refreshing ecobee tokens and updating config entry") + if await self._hass.async_add_executor_job(self.ecobee.refresh_tokens): + self._hass.config_entries.async_update_entry( + self._entry, + data={ + CONF_API_KEY: self.ecobee.config[ECOBEE_API_KEY], + CONF_REFRESH_TOKEN: self.ecobee.config[ECOBEE_REFRESH_TOKEN], + }, + ) + return True + _LOGGER.error("Error refreshing ecobee tokens") + return False + + +async def async_unload_entry(hass, config_entry): + """Unload the config entry and platforms.""" + hass.data.pop(DOMAIN) + + tasks = [] + for platform in ECOBEE_PLATFORMS: + tasks.append( + hass.config_entries.async_forward_entry_unload(config_entry, platform) + ) + + return all(await asyncio.gather(*tasks)) diff --git a/homeassistant/components/ecobee/binary_sensor.py b/homeassistant/components/ecobee/binary_sensor.py index 0989b9ded976c..64c4b07ed1f0a 100644 --- a/homeassistant/components/ecobee/binary_sensor.py +++ b/homeassistant/components/ecobee/binary_sensor.py @@ -1,59 +1,109 @@ """Support for Ecobee binary sensors.""" -from homeassistant.components import ecobee -from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_OCCUPANCY, + BinarySensorEntity, +) -ECOBEE_CONFIG_FILE = 'ecobee.conf' +from .const import _LOGGER, DOMAIN, ECOBEE_MODEL_TO_NAME, MANUFACTURER -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() +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up ecobee binary (occupancy) sensors.""" + data = hass.data[DOMAIN] + dev = [] 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': + for item in sensor["capability"]: + if item["type"] != "occupancy": continue - dev.append(EcobeeBinarySensor(sensor['name'], index)) + dev.append(EcobeeBinarySensor(data, sensor["name"], index)) - add_entities(dev, True) + async_add_entities(dev, True) -class EcobeeBinarySensor(BinarySensorDevice): +class EcobeeBinarySensor(BinarySensorEntity): """Representation of an Ecobee sensor.""" - def __init__(self, sensor_name, sensor_index): + def __init__(self, data, sensor_name, sensor_index): """Initialize the Ecobee sensor.""" - self._name = sensor_name + ' Occupancy' + self.data = data + self._name = f"{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 unique_id(self): + """Return a unique identifier for this sensor.""" + for sensor in self.data.ecobee.get_remote_sensors(self.index): + if sensor["name"] == self.sensor_name: + if "code" in sensor: + return f"{sensor['code']}-{self.device_class}" + thermostat = self.data.ecobee.get_thermostat(self.index) + return f"{thermostat['identifier']}-{sensor['id']}-{self.device_class}" + + @property + def device_info(self): + """Return device information for this sensor.""" + identifier = None + model = None + for sensor in self.data.ecobee.get_remote_sensors(self.index): + if sensor["name"] != self.sensor_name: + continue + if "code" in sensor: + identifier = sensor["code"] + model = "ecobee Room Sensor" + else: + thermostat = self.data.ecobee.get_thermostat(self.index) + identifier = thermostat["identifier"] + try: + model = ( + f"{ECOBEE_MODEL_TO_NAME[thermostat['modelNumber']]} Thermostat" + ) + except KeyError: + _LOGGER.error( + "Model number for ecobee thermostat %s not recognized. " + "Please visit this link and provide the following information: " + "https://github.com/home-assistant/home-assistant/issues/27172 " + "Unrecognized model number: %s", + thermostat["name"], + thermostat["modelNumber"], + ) + break + + if identifier is not None and model is not None: + return { + "identifiers": {(DOMAIN, identifier)}, + "name": self.sensor_name, + "manufacturer": MANUFACTURER, + "model": model, + } + return None + @property def is_on(self): """Return the status of the sensor.""" - return self._state == 'true' + return self._state == "true" @property def device_class(self): """Return the class of this sensor, from DEVICE_CLASSES.""" - return self._device_class + return DEVICE_CLASS_OCCUPANCY - def update(self): + async def async_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'] + await self.data.update() + for sensor in self.data.ecobee.get_remote_sensors(self.index): + if sensor["name"] != self.sensor_name: + continue + for item in sensor["capability"]: + if item["type"] != "occupancy": + continue + self._state = item["value"] + break diff --git a/homeassistant/components/ecobee/climate.py b/homeassistant/components/ecobee/climate.py index 3fe1646ee02b7..c956308ab8eea 100644 --- a/homeassistant/components/ecobee/climate.py +++ b/homeassistant/components/ecobee/climate.py @@ -1,64 +1,190 @@ """Support for Ecobee Thermostats.""" -import logging +import collections +from typing import Optional import voluptuous as vol -from homeassistant.components import ecobee -from homeassistant.components.climate import ClimateDevice +from homeassistant.components.climate import ClimateEntity 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) + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + CURRENT_HVAC_COOL, + CURRENT_HVAC_DRY, + CURRENT_HVAC_FAN, + CURRENT_HVAC_HEAT, + CURRENT_HVAC_IDLE, + FAN_AUTO, + FAN_ON, + HVAC_MODE_COOL, + HVAC_MODE_HEAT, + HVAC_MODE_HEAT_COOL, + HVAC_MODE_OFF, + PRESET_AWAY, + PRESET_NONE, + SUPPORT_AUX_HEAT, + SUPPORT_FAN_MODE, + SUPPORT_PRESET_MODE, + SUPPORT_TARGET_TEMPERATURE, + SUPPORT_TARGET_TEMPERATURE_RANGE, +) from homeassistant.const import ( - ATTR_ENTITY_ID, STATE_ON, STATE_OFF, ATTR_TEMPERATURE, TEMP_FAHRENHEIT) + ATTR_ENTITY_ID, + ATTR_TEMPERATURE, + STATE_ON, + 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' +from homeassistant.util.temperature import convert + +from .const import _LOGGER, DOMAIN, ECOBEE_MODEL_TO_NAME, MANUFACTURER +from .util import ecobee_date, ecobee_time + +ATTR_COOL_TEMP = "cool_temp" +ATTR_END_DATE = "end_date" +ATTR_END_TIME = "end_time" +ATTR_FAN_MIN_ON_TIME = "fan_min_on_time" +ATTR_FAN_MODE = "fan_mode" +ATTR_HEAT_TEMP = "heat_temp" +ATTR_RESUME_ALL = "resume_all" +ATTR_START_DATE = "start_date" +ATTR_START_TIME = "start_time" +ATTR_VACATION_NAME = "vacation_name" 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) +PRESET_TEMPERATURE = "temp" +PRESET_VACATION = "vacation" +PRESET_HOLD_NEXT_TRANSITION = "next_transition" +PRESET_HOLD_INDEFINITE = "indefinite" +AWAY_MODE = "awayMode" +PRESET_HOME = "home" +PRESET_SLEEP = "sleep" + +# Order matters, because for reverse mapping we don't want to map HEAT to AUX +ECOBEE_HVAC_TO_HASS = collections.OrderedDict( + [ + ("heat", HVAC_MODE_HEAT), + ("cool", HVAC_MODE_COOL), + ("auto", HVAC_MODE_HEAT_COOL), + ("off", HVAC_MODE_OFF), + ("auxHeatOnly", HVAC_MODE_HEAT), + ] +) + +ECOBEE_HVAC_ACTION_TO_HASS = { + # Map to None if we do not know how to represent. + "heatPump": CURRENT_HVAC_HEAT, + "heatPump2": CURRENT_HVAC_HEAT, + "heatPump3": CURRENT_HVAC_HEAT, + "compCool1": CURRENT_HVAC_COOL, + "compCool2": CURRENT_HVAC_COOL, + "auxHeat1": CURRENT_HVAC_HEAT, + "auxHeat2": CURRENT_HVAC_HEAT, + "auxHeat3": CURRENT_HVAC_HEAT, + "fan": CURRENT_HVAC_FAN, + "humidifier": None, + "dehumidifier": CURRENT_HVAC_DRY, + "ventilator": CURRENT_HVAC_FAN, + "economizer": CURRENT_HVAC_FAN, + "compHotWater": None, + "auxHotWater": None, +} + +PRESET_TO_ECOBEE_HOLD = { + PRESET_HOLD_NEXT_TRANSITION: "nextTransition", + PRESET_HOLD_INDEFINITE: "indefinite", +} + +SERVICE_CREATE_VACATION = "create_vacation" +SERVICE_DELETE_VACATION = "delete_vacation" +SERVICE_RESUME_PROGRAM = "resume_program" +SERVICE_SET_FAN_MIN_ON_TIME = "set_fan_min_on_time" + +DTGROUP_INCLUSIVE_MSG = ( + f"{ATTR_START_DATE}, {ATTR_START_TIME}, {ATTR_END_DATE}, " + f"and {ATTR_END_TIME} must be specified together" +) + +CREATE_VACATION_SCHEMA = vol.Schema( + { + vol.Required(ATTR_ENTITY_ID): cv.entity_id, + vol.Required(ATTR_VACATION_NAME): vol.All(cv.string, vol.Length(max=12)), + vol.Required(ATTR_COOL_TEMP): vol.Coerce(float), + vol.Required(ATTR_HEAT_TEMP): vol.Coerce(float), + vol.Inclusive( + ATTR_START_DATE, "dtgroup", msg=DTGROUP_INCLUSIVE_MSG + ): ecobee_date, + vol.Inclusive( + ATTR_START_TIME, "dtgroup", msg=DTGROUP_INCLUSIVE_MSG + ): ecobee_time, + vol.Inclusive(ATTR_END_DATE, "dtgroup", msg=DTGROUP_INCLUSIVE_MSG): ecobee_date, + vol.Inclusive(ATTR_END_TIME, "dtgroup", msg=DTGROUP_INCLUSIVE_MSG): ecobee_time, + vol.Optional(ATTR_FAN_MODE, default="auto"): vol.Any("auto", "on"), + vol.Optional(ATTR_FAN_MIN_ON_TIME, default=0): vol.All( + int, vol.Range(min=0, max=60) + ), + } +) + +DELETE_VACATION_SCHEMA = vol.Schema( + { + vol.Required(ATTR_ENTITY_ID): cv.entity_id, + vol.Required(ATTR_VACATION_NAME): vol.All(cv.string, vol.Length(max=12)), + } +) + +RESUME_PROGRAM_SCHEMA = vol.Schema( + { + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Optional(ATTR_RESUME_ALL, default=DEFAULT_RESUME_ALL): cv.boolean, + } +) + +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), + } +) + + +SUPPORT_FLAGS = ( + SUPPORT_TARGET_TEMPERATURE + | SUPPORT_PRESET_MODE + | SUPPORT_AUX_HEAT + | SUPPORT_TARGET_TEMPERATURE_RANGE + | SUPPORT_FAN_MODE +) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the ecobee thermostat.""" + + data = hass.data[DOMAIN] + + devices = [Thermostat(data, index) for index in range(len(data.ecobee.thermostats))] + + async_add_entities(devices, True) + + def create_vacation_service(service): + """Create a vacation on the target thermostat.""" + entity_id = service.data[ATTR_ENTITY_ID] + + for thermostat in devices: + if thermostat.entity_id == entity_id: + thermostat.create_vacation(service.data) + thermostat.schedule_update_ha_state(True) + break + + def delete_vacation_service(service): + """Delete a vacation on the target thermostat.""" + entity_id = service.data[ATTR_ENTITY_ID] + vacation_name = service.data[ATTR_VACATION_NAME] + + for thermostat in devices: + if thermostat.entity_id == entity_id: + thermostat.delete_vacation(vacation_name) + thermostat.schedule_update_ha_state(True) + break def fan_min_on_time_set_service(service): """Set the minimum fan on time on the target thermostats.""" @@ -66,8 +192,9 @@ def fan_min_on_time_set_service(service): 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] + target_thermostats = [ + device for device in devices if device.entity_id in entity_id + ] else: target_thermostats = devices @@ -82,8 +209,9 @@ def resume_program_set_service(service): 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] + target_thermostats = [ + device for device in devices if device.entity_id in entity_id + ] else: target_thermostats = devices @@ -92,43 +220,81 @@ def resume_program_set_service(service): 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): + hass.services.async_register( + DOMAIN, + SERVICE_CREATE_VACATION, + create_vacation_service, + schema=CREATE_VACATION_SCHEMA, + ) + + hass.services.async_register( + DOMAIN, + SERVICE_DELETE_VACATION, + delete_vacation_service, + schema=DELETE_VACATION_SCHEMA, + ) + + hass.services.async_register( + DOMAIN, + SERVICE_SET_FAN_MIN_ON_TIME, + fan_min_on_time_set_service, + schema=SET_FAN_MIN_ON_TIME_SCHEMA, + ) + + hass.services.async_register( + DOMAIN, + SERVICE_RESUME_PROGRAM, + resume_program_set_service, + schema=RESUME_PROGRAM_SCHEMA, + ) + + +class Thermostat(ClimateEntity): """A thermostat class for Ecobee.""" - def __init__(self, data, thermostat_index, hold_temp): + def __init__(self, data, thermostat_index): """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.thermostat = self.data.ecobee.get_thermostat(self.thermostat_index) + self._name = self.thermostat["name"] self.vacation = None - self._climate_list = self.climate_list - self._operation_list = ['auto', 'auxHeatOnly', 'cool', - 'heat', 'off'] - self._fan_list = ['auto', 'on'] + self._last_active_hvac_mode = HVAC_MODE_HEAT_COOL + + self._operation_list = [] + if ( + self.thermostat["settings"]["heatStages"] + or self.thermostat["settings"]["hasHeatPump"] + ): + self._operation_list.append(HVAC_MODE_HEAT) + if self.thermostat["settings"]["coolStages"]: + self._operation_list.append(HVAC_MODE_COOL) + if len(self._operation_list) == 2: + self._operation_list.insert(0, HVAC_MODE_HEAT_COOL) + self._operation_list.append(HVAC_MODE_OFF) + + self._preset_modes = { + comfort["climateRef"]: comfort["name"] + for comfort in self.thermostat["program"]["climates"] + } + self._fan_modes = [FAN_AUTO, FAN_ON] self.update_without_throttle = False - def update(self): + async def async_update(self): """Get the latest state from the thermostat.""" if self.update_without_throttle: - self.data.update(no_throttle=True) + await self.data.update(no_throttle=True) self.update_without_throttle = False else: - self.data.update() + await self.data.update() + self.thermostat = self.data.ecobee.get_thermostat(self.thermostat_index) + if self.hvac_mode is not HVAC_MODE_OFF: + self._last_active_hvac_mode = self.hvac_mode - self.thermostat = self.data.ecobee.get_thermostat( - self.thermostat_index) + @property + def available(self): + """Return if device is available.""" + return self.thermostat["runtime"]["connected"] @property def supported_features(self): @@ -138,7 +304,35 @@ def supported_features(self): @property def name(self): """Return the name of the Ecobee Thermostat.""" - return self.thermostat['name'] + return self.thermostat["name"] + + @property + def unique_id(self): + """Return a unique identifier for this ecobee thermostat.""" + return self.thermostat["identifier"] + + @property + def device_info(self): + """Return device information for this ecobee thermostat.""" + try: + model = f"{ECOBEE_MODEL_TO_NAME[self.thermostat['modelNumber']]} Thermostat" + except KeyError: + _LOGGER.error( + "Model number for ecobee thermostat %s not recognized. " + "Please visit this link and provide the following information: " + "https://github.com/home-assistant/home-assistant/issues/27172 " + "Unrecognized model number: %s", + self.name, + self.thermostat["modelNumber"], + ) + return None + + return { + "identifiers": {(DOMAIN, self.thermostat["identifier"])}, + "name": self.name, + "manufacturer": MANUFACTURER, + "model": model, + } @property def temperature_unit(self): @@ -148,220 +342,234 @@ def temperature_unit(self): @property def current_temperature(self): """Return the current temperature.""" - return self.thermostat['runtime']['actualTemperature'] / 10.0 + 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 + if self.hvac_mode == HVAC_MODE_HEAT_COOL: + 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 + if self.hvac_mode == HVAC_MODE_HEAT_COOL: + 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: + if self.hvac_mode == HVAC_MODE_HEAT_COOL: 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 + if self.hvac_mode == HVAC_MODE_HEAT: + return self.thermostat["runtime"]["desiredHeat"] / 10.0 + if self.hvac_mode == HVAC_MODE_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']: + if "fan" in self.thermostat["equipmentStatus"]: return STATE_ON - return STATE_OFF + return HVAC_MODE_OFF @property - def current_fan_mode(self): + def fan_mode(self): """Return the fan setting.""" - return self.thermostat['runtime']['desiredFanMode'] + 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): + def fan_modes(self): """Return the available fan modes.""" - return self._fan_list + return self._fan_modes @property - def _current_hold_mode(self): - events = self.thermostat['events'] + def preset_mode(self): + """Return current preset mode.""" + 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 + if not event["running"]: + continue + + if event["type"] == "hold": + if event["holdClimateRef"] in self._preset_modes: + return self._preset_modes[event["holdClimateRef"]] + + # Any hold not based on a climate is a temp hold + return PRESET_TEMPERATURE + 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 PRESET_VACATION + + return self._preset_modes[self.thermostat["program"]["currentClimateRef"]] @property - def current_operation(self): + def hvac_mode(self): """Return current operation.""" - if self.operation_mode == 'auxHeatOnly' or \ - self.operation_mode == 'heatPump': - return STATE_HEAT - return self.operation_mode + return ECOBEE_HVAC_TO_HASS[self.thermostat["settings"]["hvacMode"]] @property - def operation_list(self): + def hvac_modes(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'] + def current_humidity(self) -> Optional[int]: + """Return the current humidity.""" + return self.thermostat["runtime"]["actualHumidity"] @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'] + def hvac_action(self): + """Return current HVAC action. - @property - def fan_min_on_time(self): - """Return current fan minimum on time.""" - return self.thermostat['settings']['fanMinOnTime'] + Ecobee returns a CSV string with different equipment that is active. + We are prioritizing any heating/cooling equipment, otherwase look at + drying/fanning. Idle if nothing going on. + + We are unable to map all actions to HA equivalents. + """ + if self.thermostat["equipmentStatus"] == "": + return CURRENT_HVAC_IDLE + + actions = [ + ECOBEE_HVAC_ACTION_TO_HASS[status] + for status in self.thermostat["equipmentStatus"].split(",") + if ECOBEE_HVAC_ACTION_TO_HASS[status] is not None + ] + + for action in ( + CURRENT_HVAC_HEAT, + CURRENT_HVAC_COOL, + CURRENT_HVAC_DRY, + CURRENT_HVAC_FAN, + ): + if action in actions: + return action + + return CURRENT_HVAC_IDLE @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 - + status = self.thermostat["equipmentStatus"] return { - "actual_humidity": self.thermostat['runtime']['actualHumidity'], "fan": self.fan, - "climate_mode": self.mode, - "operation": operation, + "climate_mode": self._preset_modes[ + self.thermostat["program"]["currentClimateRef"] + ], "equipment_running": status, - "climate_list": self.climate_list, - "fan_min_on_time": self.fan_min_on_time + "fan_min_on_time": self.thermostat["settings"]["fanMinOnTime"], } @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): + def is_aux_heat(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: + return "auxHeat" in self.thermostat["equipmentStatus"] + + def set_preset_mode(self, preset_mode): + """Activate a preset.""" + if preset_mode == self.preset_mode: + return + + self.update_without_throttle = True + + # If we are currently in vacation mode, cancel it. + if self.preset_mode == PRESET_VACATION: + self.data.ecobee.delete_vacation(self.thermostat_index, self.vacation) + + if preset_mode == PRESET_AWAY: + self.data.ecobee.set_climate_hold( + self.thermostat_index, "away", "indefinite" + ) + + elif preset_mode == PRESET_TEMPERATURE: + self.set_temp_hold(self.current_temperature) + + elif preset_mode in (PRESET_HOLD_NEXT_TRANSITION, PRESET_HOLD_INDEFINITE): + self.data.ecobee.set_climate_hold( + self.thermostat_index, + PRESET_TO_ECOBEE_HOLD[preset_mode], + self.hold_preference(), + ) + + elif preset_mode == PRESET_NONE: 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 + elif preset_mode in self.preset_modes: + climate_ref = None - 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) + for comfort in self.thermostat["program"]["climates"]: + if comfort["name"] == preset_mode: + climate_ref = comfort["climateRef"] + break + + if climate_ref is not None: + self.data.ecobee.set_climate_hold( + self.thermostat_index, climate_ref, self.hold_preference() + ) else: - self.data.ecobee.resume_program(self.thermostat_index) + _LOGGER.warning("Received unknown preset mode: %s", preset_mode) + 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 + self.data.ecobee.set_climate_hold( + self.thermostat_index, preset_mode, self.hold_preference() + ) + + @property + def preset_modes(self): + """Return available preset modes.""" + return list(self._preset_modes.values()) 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) + 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))) + 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): + if fan_mode.lower() not in (FAN_ON, FAN_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()) + 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) @@ -376,12 +584,11 @@ def set_temp_hold(self, temp): heatCoolMinDelta property. https://www.ecobee.com/home/developer/api/examples/ex5.shtml """ - if self.current_operation == STATE_HEAT or self.current_operation == \ - STATE_COOL: + if self.hvac_mode == HVAC_MODE_HEAT or self.hvac_mode == HVAC_MODE_COOL: heat_temp = temp cool_temp = temp else: - delta = self.thermostat['settings']['heatCoolMinDelta'] / 10 + delta = self.thermostat["settings"]["heatCoolMinDelta"] / 10 heat_temp = temp - delta cool_temp = temp + delta self.set_auto_temp_hold(heat_temp, cool_temp) @@ -392,50 +599,114 @@ def set_temperature(self, **kwargs): 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): + if self.hvac_mode == HVAC_MODE_HEAT_COOL 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) + _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): + def set_hvac_mode(self, hvac_mode): """Set HVAC mode (auto, auxHeatOnly, cool, heat, off).""" - self.data.ecobee.set_hvac_mode(self.thermostat_index, operation_mode) + ecobee_value = next( + (k for k, v in ECOBEE_HVAC_TO_HASS.items() if v == hvac_mode), None + ) + if ecobee_value is None: + _LOGGER.error("Invalid mode for set_hvac_mode: %s", hvac_mode) + return + self.data.ecobee.set_hvac_mode(self.thermostat_index, ecobee_value) 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.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.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': + 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' + return "nextTransition" + + def create_vacation(self, service_data): + """Create a vacation with user-specified parameters.""" + vacation_name = service_data[ATTR_VACATION_NAME] + cool_temp = convert( + service_data[ATTR_COOL_TEMP], + self.hass.config.units.temperature_unit, + TEMP_FAHRENHEIT, + ) + heat_temp = convert( + service_data[ATTR_HEAT_TEMP], + self.hass.config.units.temperature_unit, + TEMP_FAHRENHEIT, + ) + start_date = service_data.get(ATTR_START_DATE) + start_time = service_data.get(ATTR_START_TIME) + end_date = service_data.get(ATTR_END_DATE) + end_time = service_data.get(ATTR_END_TIME) + fan_mode = service_data[ATTR_FAN_MODE] + fan_min_on_time = service_data[ATTR_FAN_MIN_ON_TIME] + + kwargs = { + key: value + for key, value in { + "start_date": start_date, + "start_time": start_time, + "end_date": end_date, + "end_time": end_time, + "fan_mode": fan_mode, + "fan_min_on_time": fan_min_on_time, + }.items() + if value is not None + } - @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)) + _LOGGER.debug( + "Creating a vacation on thermostat %s with name %s, cool temp %s, heat temp %s, " + "and the following other parameters: %s", + self.name, + vacation_name, + cool_temp, + heat_temp, + kwargs, + ) + self.data.ecobee.create_vacation( + self.thermostat_index, vacation_name, cool_temp, heat_temp, **kwargs + ) + + def delete_vacation(self, vacation_name): + """Delete a vacation with the specified name.""" + _LOGGER.debug( + "Deleting a vacation on thermostat %s with name %s", + self.name, + vacation_name, + ) + self.data.ecobee.delete_vacation(self.thermostat_index, vacation_name) + + def turn_on(self): + """Set the thermostat to the last active HVAC mode.""" + _LOGGER.debug( + "Turning on ecobee thermostat %s in %s mode", + self.name, + self._last_active_hvac_mode, + ) + self.set_hvac_mode(self._last_active_hvac_mode) diff --git a/homeassistant/components/ecobee/config_flow.py b/homeassistant/components/ecobee/config_flow.py new file mode 100644 index 0000000000000..cbe16832a3478 --- /dev/null +++ b/homeassistant/components/ecobee/config_flow.py @@ -0,0 +1,123 @@ +"""Config flow to configure ecobee.""" +from pyecobee import ( + ECOBEE_API_KEY, + ECOBEE_CONFIG_FILENAME, + ECOBEE_REFRESH_TOKEN, + Ecobee, +) +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistantError +from homeassistant.util.json import load_json + +from .const import _LOGGER, CONF_REFRESH_TOKEN, DATA_ECOBEE_CONFIG, DOMAIN + + +class EcobeeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle an ecobee config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + def __init__(self): + """Initialize the ecobee flow.""" + self._ecobee = None + + async def async_step_user(self, user_input=None): + """Handle a flow initiated by the user.""" + if self._async_current_entries(): + # Config entry already exists, only one allowed. + return self.async_abort(reason="one_instance_only") + + errors = {} + stored_api_key = ( + self.hass.data[DATA_ECOBEE_CONFIG].get(CONF_API_KEY) + if DATA_ECOBEE_CONFIG in self.hass.data + else "" + ) + + if user_input is not None: + # Use the user-supplied API key to attempt to obtain a PIN from ecobee. + self._ecobee = Ecobee(config={ECOBEE_API_KEY: user_input[CONF_API_KEY]}) + + if await self.hass.async_add_executor_job(self._ecobee.request_pin): + # We have a PIN; move to the next step of the flow. + return await self.async_step_authorize() + errors["base"] = "pin_request_failed" + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + {vol.Required(CONF_API_KEY, default=stored_api_key): str} + ), + errors=errors, + ) + + async def async_step_authorize(self, user_input=None): + """Present the user with the PIN so that the app can be authorized on ecobee.com.""" + errors = {} + + if user_input is not None: + # Attempt to obtain tokens from ecobee and finish the flow. + if await self.hass.async_add_executor_job(self._ecobee.request_tokens): + # Refresh token obtained; create the config entry. + config = { + CONF_API_KEY: self._ecobee.api_key, + CONF_REFRESH_TOKEN: self._ecobee.refresh_token, + } + return self.async_create_entry(title=DOMAIN, data=config) + errors["base"] = "token_request_failed" + + return self.async_show_form( + step_id="authorize", + errors=errors, + description_placeholders={"pin": self._ecobee.pin}, + ) + + async def async_step_import(self, import_data): + """ + Import ecobee config from configuration.yaml. + + Triggered by async_setup only if a config entry doesn't already exist. + If ecobee.conf exists, we will attempt to validate the credentials + and create an entry if valid. Otherwise, we will delegate to the user + step so that the user can continue the config flow. + """ + try: + legacy_config = await self.hass.async_add_executor_job( + load_json, self.hass.config.path(ECOBEE_CONFIG_FILENAME) + ) + config = { + ECOBEE_API_KEY: legacy_config[ECOBEE_API_KEY], + ECOBEE_REFRESH_TOKEN: legacy_config[ECOBEE_REFRESH_TOKEN], + } + except (HomeAssistantError, KeyError): + _LOGGER.debug( + "No valid ecobee.conf configuration found for import, delegating to user step" + ) + return await self.async_step_user( + user_input={ + CONF_API_KEY: self.hass.data[DATA_ECOBEE_CONFIG].get(CONF_API_KEY) + } + ) + + ecobee = Ecobee(config=config) + if await self.hass.async_add_executor_job(ecobee.refresh_tokens): + # Credentials found and validated; create the entry. + _LOGGER.debug( + "Valid ecobee configuration found for import, creating configuration entry" + ) + return self.async_create_entry( + title=DOMAIN, + data={ + CONF_API_KEY: ecobee.api_key, + CONF_REFRESH_TOKEN: ecobee.refresh_token, + }, + ) + return await self.async_step_user( + user_input={ + CONF_API_KEY: self.hass.data[DATA_ECOBEE_CONFIG].get(CONF_API_KEY) + } + ) diff --git a/homeassistant/components/ecobee/const.py b/homeassistant/components/ecobee/const.py new file mode 100644 index 0000000000000..f380c9bbef34c --- /dev/null +++ b/homeassistant/components/ecobee/const.py @@ -0,0 +1,56 @@ +"""Constants for the ecobee integration.""" +import logging + +_LOGGER = logging.getLogger(__package__) + +DOMAIN = "ecobee" +DATA_ECOBEE_CONFIG = "ecobee_config" + +CONF_INDEX = "index" +CONF_REFRESH_TOKEN = "refresh_token" + +ECOBEE_MODEL_TO_NAME = { + "idtSmart": "ecobee Smart", + "idtEms": "ecobee Smart EMS", + "siSmart": "ecobee Si Smart", + "siEms": "ecobee Si EMS", + "athenaSmart": "ecobee3 Smart", + "athenaEms": "ecobee3 EMS", + "corSmart": "Carrier/Bryant Cor", + "nikeSmart": "ecobee3 lite Smart", + "nikeEms": "ecobee3 lite EMS", + "apolloSmart": "ecobee4 Smart", + "vulcanSmart": "ecobee4 Smart", +} + +ECOBEE_PLATFORMS = ["binary_sensor", "climate", "sensor", "weather"] + +MANUFACTURER = "ecobee" + +# Translates ecobee API weatherSymbol to Home Assistant usable names +# https://www.ecobee.com/home/developer/api/documentation/v1/objects/WeatherForecast.shtml +ECOBEE_WEATHER_SYMBOL_TO_HASS = { + 0: "sunny", + 1: "partlycloudy", + 2: "partlycloudy", + 3: "cloudy", + 4: "cloudy", + 5: "cloudy", + 6: "rainy", + 7: "snowy-rainy", + 8: "pouring", + 9: "hail", + 10: "snowy", + 11: "snowy", + 12: "snowy-rainy", + 13: "snowy-heavy", + 14: "hail", + 15: "lightning-rainy", + 16: "windy", + 17: "tornado", + 18: "fog", + 19: "hazy", + 20: "hazy", + 21: "hazy", + -2: None, +} diff --git a/homeassistant/components/ecobee/manifest.json b/homeassistant/components/ecobee/manifest.json index d2aa7f0b515c1..f25bdca2fe60a 100644 --- a/homeassistant/components/ecobee/manifest.json +++ b/homeassistant/components/ecobee/manifest.json @@ -1,10 +1,8 @@ { "domain": "ecobee", - "name": "Ecobee", - "documentation": "https://www.home-assistant.io/components/ecobee", - "requirements": [ - "python-ecobee-api==0.0.18" - ], - "dependencies": ["configurator"], - "codeowners": [] + "name": "ecobee", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/ecobee", + "requirements": ["python-ecobee-api==0.2.5"], + "codeowners": ["@marthoc"] } diff --git a/homeassistant/components/ecobee/notify.py b/homeassistant/components/ecobee/notify.py index d6e4e8f0c6320..a8f53a027b370 100644 --- a/homeassistant/components/ecobee/notify.py +++ b/homeassistant/components/ecobee/notify.py @@ -1,35 +1,31 @@ """Support for Ecobee Send Message service.""" -import logging - import voluptuous as vol +from homeassistant.components.notify import PLATFORM_SCHEMA, BaseNotificationService 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' +from .const import CONF_INDEX, DOMAIN -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_INDEX, default=0): cv.positive_int, -}) +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.""" + data = hass.data[DOMAIN] index = config.get(CONF_INDEX) - return EcobeeNotificationService(index) + return EcobeeNotificationService(data, index) class EcobeeNotificationService(BaseNotificationService): """Implement the notification service for the Ecobee thermostat.""" - def __init__(self, thermostat_index): + def __init__(self, data, thermostat_index): """Initialize the service.""" + self.data = data 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) + """Send a message.""" + self.data.ecobee.send_message(self.thermostat_index, message) diff --git a/homeassistant/components/ecobee/sensor.py b/homeassistant/components/ecobee/sensor.py index 436903a645f35..4fd1a061cff9c 100644 --- a/homeassistant/components/ecobee/sensor.py +++ b/homeassistant/components/ecobee/sensor.py @@ -1,40 +1,44 @@ """Support for Ecobee sensors.""" -from homeassistant.components import ecobee +from pyecobee.const import ECOBEE_STATE_CALIBRATING, ECOBEE_STATE_UNKNOWN + from homeassistant.const import ( - DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, TEMP_FAHRENHEIT) + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_TEMPERATURE, + TEMP_FAHRENHEIT, + UNIT_PERCENTAGE, +) from homeassistant.helpers.entity import Entity -ECOBEE_CONFIG_FILE = 'ecobee.conf' +from .const import _LOGGER, DOMAIN, ECOBEE_MODEL_TO_NAME, MANUFACTURER SENSOR_TYPES = { - 'temperature': ['Temperature', TEMP_FAHRENHEIT], - 'humidity': ['Humidity', '%'] + "temperature": ["Temperature", TEMP_FAHRENHEIT], + "humidity": ["Humidity", UNIT_PERCENTAGE], } -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() +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up ecobee (temperature and humidity) sensors.""" + data = hass.data[DOMAIN] + dev = [] 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'): + for item in sensor["capability"]: + if item["type"] not in ("temperature", "humidity"): continue - dev.append(EcobeeSensor(sensor['name'], item['type'], index)) + dev.append(EcobeeSensor(data, sensor["name"], item["type"], index)) - add_entities(dev, True) + async_add_entities(dev, True) class EcobeeSensor(Entity): """Representation of an Ecobee sensor.""" - def __init__(self, sensor_name, sensor_type, sensor_index): + def __init__(self, data, sensor_name, sensor_type, sensor_index): """Initialize the sensor.""" - self._name = '{} {}'.format(sensor_name, SENSOR_TYPES[sensor_type][0]) + self.data = data + self._name = f"{sensor_name} {SENSOR_TYPES[sensor_type][0]}" self.sensor_name = sensor_name self.type = sensor_type self.index = sensor_index @@ -46,6 +50,54 @@ def name(self): """Return the name of the Ecobee sensor.""" return self._name + @property + def unique_id(self): + """Return a unique identifier for this sensor.""" + for sensor in self.data.ecobee.get_remote_sensors(self.index): + if sensor["name"] == self.sensor_name: + if "code" in sensor: + return f"{sensor['code']}-{self.device_class}" + thermostat = self.data.ecobee.get_thermostat(self.index) + return f"{thermostat['identifier']}-{sensor['id']}-{self.device_class}" + + @property + def device_info(self): + """Return device information for this sensor.""" + identifier = None + model = None + for sensor in self.data.ecobee.get_remote_sensors(self.index): + if sensor["name"] != self.sensor_name: + continue + if "code" in sensor: + identifier = sensor["code"] + model = "ecobee Room Sensor" + else: + thermostat = self.data.ecobee.get_thermostat(self.index) + identifier = thermostat["identifier"] + try: + model = ( + f"{ECOBEE_MODEL_TO_NAME[thermostat['modelNumber']]} Thermostat" + ) + except KeyError: + _LOGGER.error( + "Model number for ecobee thermostat %s not recognized. " + "Please visit this link and provide the following information: " + "https://github.com/home-assistant/home-assistant/issues/27172 " + "Unrecognized model number: %s", + thermostat["name"], + thermostat["modelNumber"], + ) + break + + if identifier is not None and model is not None: + return { + "identifiers": {(DOMAIN, identifier)}, + "name": self.sensor_name, + "manufacturer": MANUFACTURER, + "model": model, + } + return None + @property def device_class(self): """Return the device class of the sensor.""" @@ -56,6 +108,12 @@ def device_class(self): @property def state(self): """Return the state of the sensor.""" + if self._state in [ECOBEE_STATE_CALIBRATING, ECOBEE_STATE_UNKNOWN, "unknown"]: + return None + + if self.type == "temperature": + return float(self._state) / 10 + return self._state @property @@ -63,16 +121,14 @@ def unit_of_measurement(self): """Return the unit of measurement this sensor expresses itself in.""" return self._unit_of_measurement - def update(self): + async def async_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'] + await self.data.update() + for sensor in self.data.ecobee.get_remote_sensors(self.index): + if sensor["name"] != self.sensor_name: + continue + for item in sensor["capability"]: + if item["type"] != self.type: + continue + self._state = item["value"] + break diff --git a/homeassistant/components/ecobee/services.yaml b/homeassistant/components/ecobee/services.yaml index e69de29bb2d1d..88137bd953014 100644 --- a/homeassistant/components/ecobee/services.yaml +++ b/homeassistant/components/ecobee/services.yaml @@ -0,0 +1,71 @@ +create_vacation: + description: >- + Create a vacation on the selected thermostat. Note: start/end date and time must all be specified + together for these parameters to have an effect. If start/end date and time are not specified, the + vacation will start immediately and last 14 days (unless deleted earlier). + fields: + entity_id: + description: ecobee thermostat on which to create the vacation (required). + example: "climate.kitchen" + vacation_name: + description: Name of the vacation to create; must be unique on the thermostat (required). + example: "Skiing" + cool_temp: + description: Cooling temperature during the vacation (required). + example: 23 + heat_temp: + description: Heating temperature during the vacation (required). + example: 25 + start_date: + description: >- + Date the vacation starts in the YYYY-MM-DD format (optional, immediately if not provided along with + start_time, end_date, and end_time). + example: "2019-03-15" + start_time: + description: Time the vacation starts, in the local time of the thermostat, in the 24-hour format "HH:MM:SS" + example: "20:00:00" + end_date: + description: >- + Date the vacation ends in the YYYY-MM-DD format (optional, 14 days from now if not provided along with + start_date, start_time, and end_time). + example: "2019-03-20" + end_time: + description: Time the vacation ends, in the local time of the thermostat, in the 24-hour format "HH:MM:SS" + example: "20:00:00" + fan_mode: + description: Fan mode of the thermostat during the vacation (auto or on) (optional, auto if not provided). + example: "on" + fan_min_on_time: + description: Minimum number of minutes to run the fan each hour (0 to 60) during the vacation (optional, 0 if not provided). + example: 30 + +delete_vacation: + description: >- + Delete a vacation on the selected thermostat. + fields: + entity_id: + description: ecobee thermostat on which to delete the vacation (required). + example: "climate.kitchen" + vacation_name: + description: Name of the vacation to delete (required). + example: "Skiing" + +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 + +set_fan_min_on_time: + description: Set the minimum fan on time. + fields: + entity_id: + description: Name(s) of entities to change. + example: "climate.kitchen" + fan_min_on_time: + description: New value of fan min on time. + example: 5 diff --git a/homeassistant/components/ecobee/strings.json b/homeassistant/components/ecobee/strings.json new file mode 100644 index 0000000000000..6e3a5687db111 --- /dev/null +++ b/homeassistant/components/ecobee/strings.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "user": { + "title": "ecobee API key", + "description": "Please enter the API key obtained from ecobee.com.", + "data": { "api_key": "API Key" } + }, + "authorize": { + "title": "Authorize app on ecobee.com", + "description": "Please authorize this app at https://www.ecobee.com/consumerportal/index.html with pin code:\n\n{pin}\n\nThen, press Submit." + } + }, + "error": { + "pin_request_failed": "Error requesting PIN from ecobee; please verify API key is correct.", + "token_request_failed": "Error requesting tokens from ecobee; please try again." + }, + "abort": { + "one_instance_only": "This integration currently supports only one ecobee instance." + } + } +} diff --git a/homeassistant/components/ecobee/translations/bg.json b/homeassistant/components/ecobee/translations/bg.json new file mode 100644 index 0000000000000..773bc6bd11f01 --- /dev/null +++ b/homeassistant/components/ecobee/translations/bg.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "one_instance_only": "\u0422\u0430\u0437\u0438 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044f \u043f\u043e\u0434\u0434\u044a\u0440\u0436\u0430 \u0441\u0430\u043c\u043e \u0435\u0434\u043d\u043e \u043a\u043e\u043f\u0438\u0435 \u043d\u0430 ecobee." + }, + "error": { + "pin_request_failed": "\u0413\u0440\u0435\u0448\u043a\u0430 \u043f\u0440\u0438 \u0438\u0441\u043a\u0430\u043d\u0435 \u043d\u0430 \u041f\u0418\u041d \u043e\u0442 ecobee; \u043c\u043e\u043b\u044f, \u043f\u0440\u043e\u0432\u0435\u0440\u0435\u0442\u0435 \u0434\u0430\u043b\u0438 API \u043a\u043b\u044e\u0447\u044a\u0442 \u0435 \u043f\u0440\u0430\u0432\u0438\u043b\u0435\u043d.", + "token_request_failed": "\u0413\u0440\u0435\u0448\u043a\u0430 \u043f\u0440\u0438 \u0438\u0441\u043a\u0430\u043d\u0435 \u043d\u0430 \u043a\u043e\u0434\u043e\u0432\u0435 \u043e\u0442 ecobee; \u043c\u043e\u043b\u044f, \u043e\u043f\u0438\u0442\u0430\u0439\u0442\u0435 \u043e\u0442\u043d\u043e\u0432\u043e." + }, + "step": { + "authorize": { + "description": "\u041c\u043e\u043b\u044f, \u043e\u0442\u043e\u0440\u0438\u0437\u0438\u0440\u0430\u0439\u0442\u0435 \u0442\u043e\u0432\u0430 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u043d\u0430 https://www.ecobee.com/consumerportal/index.html \u0441 \u043f\u0438\u043d \u043a\u043e\u0434: \n\n {pin} \n \n \u0421\u043b\u0435\u0434 \u0442\u043e\u0432\u0430 \u043d\u0430\u0442\u0438\u0441\u043d\u0435\u0442\u0435 \u0418\u0437\u043f\u0440\u0430\u0449\u0430\u043d\u0435.", + "title": "\u041e\u0442\u043e\u0440\u0438\u0437\u0438\u0440\u0430\u043d\u0435 \u043d\u0430 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u043d\u0430 ecobee.com" + }, + "user": { + "data": { + "api_key": "API \u043a\u043b\u044e\u0447" + }, + "description": "\u041c\u043e\u043b\u044f, \u0432\u044a\u0432\u0435\u0434\u0435\u0442\u0435 API \u043a\u043b\u044e\u0447\u0430, \u043f\u043e\u043b\u0443\u0447\u0435\u043d \u043e\u0442 ecobee.com.", + "title": "ecobee API \u043a\u043b\u044e\u0447" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ecobee/translations/ca.json b/homeassistant/components/ecobee/translations/ca.json new file mode 100644 index 0000000000000..b75006483c7e7 --- /dev/null +++ b/homeassistant/components/ecobee/translations/ca.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "one_instance_only": "Aquesta integraci\u00f3 nom\u00e9s admet una sola inst\u00e0ncia ecobee." + }, + "error": { + "pin_request_failed": "Error al sol\u00b7licitar els PIN d'ecobee; verifica que la clau API \u00e9s correcta.", + "token_request_failed": "Error al sol\u00b7licitar els tokens d'autenticaci\u00f3 d'ecobee; torna-ho a provar." + }, + "step": { + "authorize": { + "description": "Autoritza aquesta aplicaci\u00f3 a https://www.ecobee.com/consumerportal/index.html amb el codi pin seg\u00fcent: \n\n {pin} \n \n A continuaci\u00f3, prem Enviar.", + "title": "Autoritzaci\u00f3 de l'aplicaci\u00f3 a ecobee.com" + }, + "user": { + "data": { + "api_key": "Clau API" + }, + "description": "Introdueix la clau API obteinguda a trav\u00e9s del lloc web ecobee.com.", + "title": "Clau API d'ecobee" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ecobee/translations/da.json b/homeassistant/components/ecobee/translations/da.json new file mode 100644 index 0000000000000..27af2cc098700 --- /dev/null +++ b/homeassistant/components/ecobee/translations/da.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "one_instance_only": "Denne integration underst\u00f8tter i \u00f8jeblikket kun en ecobee-instans." + }, + "error": { + "pin_request_failed": "Fejl ved anmodning om pinkode fra ecobee. Kontroller at API-n\u00f8glen er korrekt.", + "token_request_failed": "Fejl ved anmodning om tokens fra ecobee. Pr\u00f8v igen." + }, + "step": { + "authorize": { + "description": "Godkend denne app p\u00e5 https://www.ecobee.com/consumerportal/index.html med PIN-kode:\n\n{pin}\n\nTryk derefter p\u00e5 Indsend.", + "title": "Godkend app p\u00e5 ecobee.com" + }, + "user": { + "data": { + "api_key": "API-n\u00f8gle" + }, + "description": "Indtast API-n\u00f8glen, du har f\u00e5et fra ecobee.com.", + "title": "ecobee API-n\u00f8gle" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ecobee/translations/de.json b/homeassistant/components/ecobee/translations/de.json new file mode 100644 index 0000000000000..d0be33847c2dd --- /dev/null +++ b/homeassistant/components/ecobee/translations/de.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "one_instance_only": "Diese Integration unterst\u00fctzt derzeit nur eine Ecobee-Instanz." + }, + "error": { + "pin_request_failed": "Fehler beim Anfordern der PIN von ecobee; Bitte \u00fcberpr\u00fcfe, ob der API-Schl\u00fcssel korrekt ist.", + "token_request_failed": "Fehler beim Anfordern eines Token von ecobee; Bitte versuche es erneut." + }, + "step": { + "authorize": { + "description": "Bitte autorisiere diese App unter https://www.ecobee.com/consumerportal/index.html mit Pincode:\n\n{pin}\n\nDr\u00fccke dann auf Senden.", + "title": "App auf ecobee.com autorisieren" + }, + "user": { + "data": { + "api_key": "API Key" + }, + "description": "Bitte gib den von ecobee.com erhaltenen API-Schl\u00fcssel ein.", + "title": "ecobee API-Schl\u00fcssel" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ecobee/translations/en.json b/homeassistant/components/ecobee/translations/en.json new file mode 100644 index 0000000000000..a105296f813e0 --- /dev/null +++ b/homeassistant/components/ecobee/translations/en.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "one_instance_only": "This integration currently supports only one ecobee instance." + }, + "error": { + "pin_request_failed": "Error requesting PIN from ecobee; please verify API key is correct.", + "token_request_failed": "Error requesting tokens from ecobee; please try again." + }, + "step": { + "authorize": { + "description": "Please authorize this app at https://www.ecobee.com/consumerportal/index.html with pin code:\n\n{pin}\n\nThen, press Submit.", + "title": "Authorize app on ecobee.com" + }, + "user": { + "data": { + "api_key": "API Key" + }, + "description": "Please enter the API key obtained from ecobee.com.", + "title": "ecobee API key" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ecobee/translations/es-419.json b/homeassistant/components/ecobee/translations/es-419.json new file mode 100644 index 0000000000000..ff9c1f53decaa --- /dev/null +++ b/homeassistant/components/ecobee/translations/es-419.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "one_instance_only": "Esta integraci\u00f3n actualmente solo admite una instancia de ecobee." + }, + "error": { + "pin_request_failed": "Error al solicitar PIN de ecobee; verifique que la clave API sea correcta.", + "token_request_failed": "Error al solicitar tokens de ecobee; Int\u00e9ntelo de nuevo." + }, + "step": { + "authorize": { + "description": "Autorice esta aplicaci\u00f3n en https://www.ecobee.com/consumerportal/index.html con c\u00f3digo PIN: \n\n {pin} \n \n Luego, presione Enviar." + }, + "user": { + "data": { + "api_key": "Clave API" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ecobee/translations/es.json b/homeassistant/components/ecobee/translations/es.json new file mode 100644 index 0000000000000..26260e38ca7fe --- /dev/null +++ b/homeassistant/components/ecobee/translations/es.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "one_instance_only": "Esta integraci\u00f3n actualmente solo admite una instancia de ecobee." + }, + "error": { + "pin_request_failed": "Error al solicitar el PIN de ecobee; verifique que la clave API sea correcta.", + "token_request_failed": "Error al solicitar tokens de ecobee; Int\u00e9ntalo de nuevo." + }, + "step": { + "authorize": { + "description": "Por favor, autorizar esta aplicaci\u00f3n en https://www.ecobee.com/consumerportal/index.html con c\u00f3digo pin:\n\n{pin}\n\nA continuaci\u00f3n, pulse Enviar.", + "title": "Autorizar aplicaci\u00f3n en ecobee.com" + }, + "user": { + "data": { + "api_key": "Clave API" + }, + "description": "Introduzca la clave de API obtenida de ecobee.com.", + "title": "Clave API de ecobee" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ecobee/translations/fr.json b/homeassistant/components/ecobee/translations/fr.json new file mode 100644 index 0000000000000..46e56ce8a16a4 --- /dev/null +++ b/homeassistant/components/ecobee/translations/fr.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "one_instance_only": "Cette int\u00e9gration ne prend actuellement en charge qu'une seule instance ecobee." + }, + "error": { + "pin_request_failed": "Erreur lors de la demande du code PIN \u00e0 ecobee; veuillez v\u00e9rifier que la cl\u00e9 API est correcte.", + "token_request_failed": "Erreur lors de la demande de jetons \u00e0 ecobee; Veuillez r\u00e9essayer." + }, + "step": { + "authorize": { + "description": "Veuillez autoriser cette application \u00e0 https://www.ecobee.com/consumerportal/index.html avec un code PIN :\n\n{pin}\n\nEnsuite, appuyez sur Soumettre.", + "title": "Autoriser l'application sur ecobee.com" + }, + "user": { + "data": { + "api_key": "Cl\u00e9 API" + }, + "description": "Veuillez entrer la cl\u00e9 API obtenue aupr\u00e8s d'ecobee.com.", + "title": "Cl\u00e9 API ecobee" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ecobee/translations/hu.json b/homeassistant/components/ecobee/translations/hu.json new file mode 100644 index 0000000000000..4910991e7382e --- /dev/null +++ b/homeassistant/components/ecobee/translations/hu.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "one_instance_only": "Ez az integr\u00e1ci\u00f3 jelenleg csak egy ecobee p\u00e9ld\u00e1nyt t\u00e1mogat." + }, + "error": { + "pin_request_failed": "Hiba t\u00f6rt\u00e9nt a PIN-k\u00f3d ecobee-t\u0151l t\u00f6rt\u00e9n\u0151 k\u00e9r\u00e9sekor; ellen\u0151rizze, hogy az API-kulcs helyes-e.", + "token_request_failed": "Hiba t\u00f6rt\u00e9nt a tokenek ecobee-t\u0151l t\u00f6rt\u00e9n\u0151 ig\u00e9nyl\u00e9se k\u00f6zben; pr\u00f3b\u00e1lkozzon \u00fajra." + }, + "step": { + "authorize": { + "description": "K\u00e9rj\u00fck, enged\u00e9lyezze ezt az alkalmaz\u00e1st a https://www.ecobee.com/consumerportal/index.html c\u00edmen a k\u00f6vetkez\u0151 PIN-k\u00f3ddal: \n\n {pin} \n \n Ezut\u00e1n nyomja meg a K\u00fcld\u00e9s gombot.", + "title": "Alkalmaz\u00e1s enged\u00e9lyez\u00e9se ecobee.com-on" + }, + "user": { + "data": { + "api_key": "API kulcs" + }, + "description": "Adja meg az ecobee.com webhelyr\u0151l beszerzett API-kulcsot.", + "title": "ecobee API kulcs" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ecobee/translations/it.json b/homeassistant/components/ecobee/translations/it.json new file mode 100644 index 0000000000000..428ce78229174 --- /dev/null +++ b/homeassistant/components/ecobee/translations/it.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "one_instance_only": "Questa integrazione supporta attualmente una sola istanza ecobee." + }, + "error": { + "pin_request_failed": "Errore durante la richiesta del PIN da ecobee; verificare che la chiave API sia corretta.", + "token_request_failed": "Errore durante la richiesta di token da ecobee; per favore riprova." + }, + "step": { + "authorize": { + "description": "Autorizza questa app su https://www.ecobee.com/consumerportal/index.html con il codice PIN: \n\n {pin} \n \n Quindi, premi Invia.", + "title": "Autorizza l'app su ecobee.com" + }, + "user": { + "data": { + "api_key": "API Key" + }, + "description": "Inserisci la chiave API ottenuta da ecobee.com.", + "title": "chiave API ecobee" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ecobee/translations/ko.json b/homeassistant/components/ecobee/translations/ko.json new file mode 100644 index 0000000000000..1973c3ab8a3a3 --- /dev/null +++ b/homeassistant/components/ecobee/translations/ko.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "one_instance_only": "\uc774 \ud1b5\ud569 \uad6c\uc131\uc694\uc18c\ub294 \ud604\uc7ac \ud558\ub098\uc758 ecobee \uc778\uc2a4\ud134\uc2a4\ub9cc \uc9c0\uc6d0\ud569\ub2c8\ub2e4." + }, + "error": { + "pin_request_failed": "ecobee \ub85c\ubd80\ud130 PIN \uc694\uccad\uc5d0 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4; API \ud0a4\uac00 \uc62c\ubc14\ub978\uc9c0 \ud655\uc778\ud574\uc8fc\uc138\uc694.", + "token_request_failed": "ecobee \ub85c\ubd80\ud130 \ud1a0\ud070 \uc694\uccad\uc5d0 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4; \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694." + }, + "step": { + "authorize": { + "description": "https://www.ecobee.com/consumerportal/index.html \uc5d0\uc11c PIN \ucf54\ub4dc\ub97c \uc0ac\uc6a9\ud558\uc5ec \uc774 \uc571\uc744 \uc2b9\uc778\ud574\uc8fc\uc138\uc694:\n\n {pin} \n \n \uadf8\ub7f0 \ub2e4\uc74c Submit \uc744 \ub20c\ub7ec\uc8fc\uc138\uc694.", + "title": "ecobee.com \uc5d0\uc11c \uc571 \uc2b9\uc778\ud558\uae30" + }, + "user": { + "data": { + "api_key": "API \ud0a4" + }, + "description": "ecobee.com \uc5d0\uc11c \uc5bb\uc740 API \ud0a4\ub97c \uc785\ub825\ud574\uc8fc\uc138\uc694.", + "title": "ecobee API \ud0a4" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ecobee/translations/lb.json b/homeassistant/components/ecobee/translations/lb.json new file mode 100644 index 0000000000000..adcee0b0849fe --- /dev/null +++ b/homeassistant/components/ecobee/translations/lb.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "one_instance_only": "D\u00ebs Integratioun \u00ebnnerst\u00ebtzt n\u00ebmmen eng ecobee Instanz." + }, + "error": { + "pin_request_failed": "Feeler beim ufroe vum PIN vun ecobee; iwwerpr\u00e9ift op den API Schl\u00ebssel korrekt ass.", + "token_request_failed": "Feeler beim ufroe vum Jeton vun ecobee; prob\u00e9iert nach emol." + }, + "step": { + "authorize": { + "description": "Autoris\u00e9iert d\u00ebs App op https://www.ecobee.com/consumerportal/index.html mam Pin Code:\n\n{pin}\n\nKlickt dann op ofsch\u00e9cken.", + "title": "App autoris\u00e9ieren op ecobee.com" + }, + "user": { + "data": { + "api_key": "API Schl\u00ebssel" + }, + "description": "Gitt den API Schl\u00ebssel vun ecobee.com an:", + "title": "ecobee API Schl\u00ebssel" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ecobee/translations/nl.json b/homeassistant/components/ecobee/translations/nl.json new file mode 100644 index 0000000000000..9bb62c258c81a --- /dev/null +++ b/homeassistant/components/ecobee/translations/nl.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "one_instance_only": "Deze integratie ondersteunt momenteel slechts \u00e9\u00e9n ecobee-instantie." + }, + "error": { + "pin_request_failed": "Fout bij het aanvragen van pincode bij ecobee; Controleer of de API-sleutel correct is.", + "token_request_failed": "Fout bij het aanvragen van tokens bij ecobee; probeer het opnieuw." + }, + "step": { + "authorize": { + "description": "Autoriseer deze app op https://www.ecobee.com/consumerportal/index.html met pincode: \n\n {pin} \n \nDruk vervolgens op Submit.", + "title": "Autoriseer app op ecobee.com" + }, + "user": { + "data": { + "api_key": "API-sleutel" + }, + "description": "Voer de API-sleutel in die u van ecobee.com hebt gekregen.", + "title": "ecobee API-sleutel" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ecobee/translations/nn.json b/homeassistant/components/ecobee/translations/nn.json new file mode 100644 index 0000000000000..b23da4e97d16c --- /dev/null +++ b/homeassistant/components/ecobee/translations/nn.json @@ -0,0 +1,3 @@ +{ + "title": "ecobee" +} \ No newline at end of file diff --git a/homeassistant/components/ecobee/translations/no.json b/homeassistant/components/ecobee/translations/no.json new file mode 100644 index 0000000000000..560fe4cf4e175 --- /dev/null +++ b/homeassistant/components/ecobee/translations/no.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "one_instance_only": "Denne integrasjonen st\u00f8tter forel\u00f8pig bare \u00e9n ecobee-forekomst." + }, + "error": { + "pin_request_failed": "Feil under foresp\u00f8rsel om PIN-kode fra ecobee. Kontroller at API-n\u00f8kkelen er riktig.", + "token_request_failed": "Feil ved foresp\u00f8rsel om tokener fra ecobee: Pr\u00f8v p\u00e5 nytt." + }, + "step": { + "authorize": { + "description": "Vennligst autoriser denne appen p\u00e5 https://www.ecobee.com/consumerportal/index.html med pin-kode:\n\n{pin}\n\nTrykk deretter p\u00e5 Send.", + "title": "Autoriser app p\u00e5 ecobee.com" + }, + "user": { + "data": { + "api_key": "API-n\u00f8kkel" + }, + "description": "Vennligst skriv inn API-n\u00f8kkel som er innhentet fra ecobee.com.", + "title": "ecobee API-n\u00f8kkel" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ecobee/translations/pl.json b/homeassistant/components/ecobee/translations/pl.json new file mode 100644 index 0000000000000..c0fed1d075c65 --- /dev/null +++ b/homeassistant/components/ecobee/translations/pl.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "one_instance_only": "Komponent obs\u0142uguje tylko jedn\u0105 instancj\u0119 ecobee" + }, + "error": { + "pin_request_failed": "B\u0142\u0105d podczas \u017c\u0105dania kodu PIN od ecobee; sprawd\u017a, czy klucz API jest poprawny.", + "token_request_failed": "B\u0142\u0105d podczas \u017c\u0105dania token\u00f3w od ecobee. Spr\u00f3buj ponownie." + }, + "step": { + "authorize": { + "description": "Autoryzuj t\u0119 aplikacj\u0119 na https://www.ecobee.com/consumerportal/index.html za pomoc\u0105 kodu PIN: \n\n {pin} \n \n Nast\u0119pnie naci\u015bnij przycisk \"Zatwierd\u017a\".", + "title": "Autoryzuj aplikacj\u0119 na ecobee.com" + }, + "user": { + "data": { + "api_key": "Klucz API" + }, + "description": "Prosz\u0119 wprowadzi\u0107 klucz API uzyskany na ecobee.com.", + "title": "Klucz API" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ecobee/translations/pt-BR.json b/homeassistant/components/ecobee/translations/pt-BR.json new file mode 100644 index 0000000000000..8b970c140be11 --- /dev/null +++ b/homeassistant/components/ecobee/translations/pt-BR.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "one_instance_only": "Essa integra\u00e7\u00e3o atualmente suporta apenas uma inst\u00e2ncia ecobee." + }, + "error": { + "token_request_failed": "Erro ao solicitar tokens da ecobee; Por favor, tente novamente." + }, + "step": { + "authorize": { + "description": "Por favor, autorize este aplicativo em https://www.ecobee.com/consumerportal/index.html com c\u00f3digo PIN:\n\n{pin}\n\nEm seguida, pressione Submit.", + "title": "Autorizar aplicativo em ecobee.com" + }, + "user": { + "data": { + "api_key": "Chave API" + }, + "description": "Por favor, insira a chave de API obtida em ecobee.com.", + "title": "chave da API ecobee" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ecobee/translations/pt.json b/homeassistant/components/ecobee/translations/pt.json new file mode 100644 index 0000000000000..20bba0ede4bf1 --- /dev/null +++ b/homeassistant/components/ecobee/translations/pt.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "api_key": "Chave da API" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ecobee/translations/ru.json b/homeassistant/components/ecobee/translations/ru.json new file mode 100644 index 0000000000000..37c1f63822c22 --- /dev/null +++ b/homeassistant/components/ecobee/translations/ru.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "one_instance_only": "\u042d\u0442\u0430 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044f \u0432 \u043d\u0430\u0441\u0442\u043e\u044f\u0449\u0435\u0435 \u0432\u0440\u0435\u043c\u044f \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 ecobee." + }, + "error": { + "pin_request_failed": "\u041f\u0440\u043e\u0438\u0437\u043e\u0448\u043b\u0430 \u043e\u0448\u0438\u0431\u043a\u0430 \u0432\u043e \u0432\u0440\u0435\u043c\u044f \u0437\u0430\u043f\u0440\u043e\u0441\u0430 PIN-\u043a\u043e\u0434\u0430 \u0443 ecobee; \u043f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043f\u0440\u043e\u0432\u0435\u0440\u044c\u0442\u0435 \u043f\u0440\u0430\u0432\u0438\u043b\u044c\u043d\u043e\u0441\u0442\u044c \u043a\u043b\u044e\u0447\u0430 API.", + "token_request_failed": "\u041f\u0440\u043e\u0438\u0437\u043e\u0448\u043b\u0430 \u043e\u0448\u0438\u0431\u043a\u0430 \u0432\u043e \u0432\u0440\u0435\u043c\u044f \u0437\u0430\u043f\u0440\u043e\u0441\u0430 \u0442\u043e\u043a\u0435\u043d\u043e\u0432 \u0443 ecobee; \u043f\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." + }, + "step": { + "authorize": { + "description": "\u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043f\u0440\u043e\u0439\u0434\u0438\u0442\u0435 \u043f\u043e \u0430\u0434\u0440\u0435\u0441\u0443 https://www.ecobee.com/consumerportal/index.html \u0438 \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0443\u0439\u0442\u0435\u0441\u044c \u0441 \u043f\u043e\u043c\u043e\u0449\u044c\u044e PIN-\u043a\u043e\u0434\u0430: \n\n {pin} \n \n \u0417\u0430\u0442\u0435\u043c \u043d\u0430\u0436\u043c\u0438\u0442\u0435 \u041e\u0442\u043f\u0440\u0430\u0432\u0438\u0442\u044c.", + "title": "\u0410\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u044f \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u043d\u0430 ecobee.com" + }, + "user": { + "data": { + "api_key": "\u041a\u043b\u044e\u0447 API" + }, + "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u043a\u043b\u044e\u0447 API, \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u043d\u044b\u0439 \u043e\u0442 ecobee.com.", + "title": "ecobee" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ecobee/translations/sl.json b/homeassistant/components/ecobee/translations/sl.json new file mode 100644 index 0000000000000..ee84a98cf34fa --- /dev/null +++ b/homeassistant/components/ecobee/translations/sl.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "one_instance_only": "Ta integracija trenutno podpira samo en primerek ecobee." + }, + "error": { + "pin_request_failed": "Napaka pri zahtevi PIN-a od ecobee; preverite, ali je klju\u010d API pravilen.", + "token_request_failed": "Napaka pri zahtevanju \u017eetonov od ecobeeja; prosim poskusite ponovno." + }, + "step": { + "authorize": { + "description": "Prosimo, pooblastite to aplikacijo na https://www.ecobee.com/consumerportal/index.html s kodo PIN:\n\n{pin}\n\nNato pritisnite Po\u0161lji.", + "title": "Pooblasti aplikacijo na ecobee.com" + }, + "user": { + "data": { + "api_key": "API Klju\u010d" + }, + "description": "Prosimo vnesite API klju\u010d, pridobljen iz ecobee.com.", + "title": "ecobee API klju\u010d" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ecobee/translations/sv.json b/homeassistant/components/ecobee/translations/sv.json new file mode 100644 index 0000000000000..4bfb4e0ec1286 --- /dev/null +++ b/homeassistant/components/ecobee/translations/sv.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "one_instance_only": "Denna integration st\u00f6der f\u00f6r n\u00e4rvarande endast en ecobee-instans." + }, + "error": { + "pin_request_failed": "Fel vid beg\u00e4ran av PIN-kod fr\u00e5n ecobee. kontrollera API-nyckeln \u00e4r korrekt.", + "token_request_failed": "Fel vid beg\u00e4ran av tokens fr\u00e5n ecobee; v\u00e4nligen f\u00f6rs\u00f6k igen." + }, + "step": { + "authorize": { + "description": "V\u00e4nligen auktorisera denna app p\u00e5 https://www.ecobee.com/consumerportal/index.html med pin-kod:\n\n{pin}\n\nTryck sedan p\u00e5 Skicka.", + "title": "Auktorisera app p\u00e5 ecobee.com" + }, + "user": { + "data": { + "api_key": "API-nyckel" + }, + "description": "V\u00e4nligen ange API-nyckeln som erh\u00e5llits fr\u00e5n ecobee.com.", + "title": "ecobee API-nyckel" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ecobee/translations/zh-Hant.json b/homeassistant/components/ecobee/translations/zh-Hant.json new file mode 100644 index 0000000000000..db1ffa56c811f --- /dev/null +++ b/homeassistant/components/ecobee/translations/zh-Hant.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "one_instance_only": "\u6b64\u6574\u5408\u76ee\u524d\u50c5\u652f\u63f4\u4e00\u7d44 ecobee \u7269\u4ef6" + }, + "error": { + "pin_request_failed": "ecobee \u6240\u9700\u4ee3\u78bc\u932f\u8aa4\uff0c\u8acb\u78ba\u8a8d\u5bc6\u9470\u6b63\u78ba\u6027\u3002", + "token_request_failed": "ecobee \u6240\u9700\u5bc6\u9470\u932f\u8aa4\uff0c\u8acb\u518d\u8a66\u4e00\u6b21\u3002" + }, + "step": { + "authorize": { + "description": "\u8acb\u65bc https://www.ecobee.com/consumerportal/index.html \u8f38\u5165\u4e0b\u65b9\u4ee3\u78bc\uff0c\u8a8d\u8b49\u6b64 App\uff1a\n\n{pin}\n\n\u7136\u5f8c\u6309\u4e0b\u300cSubmit\u300d\u3002", + "title": "\u65bc ecobee.com \u4e0a\u8a8d\u8b49 App" + }, + "user": { + "data": { + "api_key": "API \u5bc6\u9470" + }, + "description": "\u8acb\u8f38\u5165\u7531 ecobee.com \u6240\u7372\u5f97\u7684 API \u5bc6\u9470\u3002", + "title": "ecobee API \u5bc6\u9470" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ecobee/util.py b/homeassistant/components/ecobee/util.py new file mode 100644 index 0000000000000..2f5d194fec04d --- /dev/null +++ b/homeassistant/components/ecobee/util.py @@ -0,0 +1,22 @@ +"""Validation utility functions for ecobee services.""" +from datetime import datetime + +import voluptuous as vol + + +def ecobee_date(date_string): + """Validate a date_string as valid for the ecobee API.""" + try: + datetime.strptime(date_string, "%Y-%m-%d") + except ValueError: + raise vol.Invalid("Date does not match ecobee date format YYYY-MM-DD") + return date_string + + +def ecobee_time(time_string): + """Validate a time_string as valid for the ecobee API.""" + try: + datetime.strptime(time_string, "%H:%M:%S") + except ValueError: + raise vol.Invalid("Time does not match ecobee 24-hour time format HH:MM:SS") + return time_string diff --git a/homeassistant/components/ecobee/weather.py b/homeassistant/components/ecobee/weather.py index f5058434f387b..a7fe8d8a0f87a 100644 --- a/homeassistant/components/ecobee/weather.py +++ b/homeassistant/components/ecobee/weather.py @@ -1,39 +1,46 @@ """Support for displaying weather info from Ecobee API.""" from datetime import datetime -from homeassistant.components import ecobee +from pyecobee.const import ECOBEE_STATE_UNKNOWN + from homeassistant.components.weather import ( - ATTR_FORECAST_CONDITION, ATTR_FORECAST_TEMP, ATTR_FORECAST_TEMP_LOW, - ATTR_FORECAST_TIME, ATTR_FORECAST_WIND_SPEED, WeatherEntity) + ATTR_FORECAST_CONDITION, + ATTR_FORECAST_TEMP, + ATTR_FORECAST_TEMP_LOW, + ATTR_FORECAST_TIME, + ATTR_FORECAST_WIND_BEARING, + 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 +from .const import ( + _LOGGER, + DOMAIN, + ECOBEE_MODEL_TO_NAME, + ECOBEE_WEATHER_SYMBOL_TO_HASS, + MANUFACTURER, +) -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 +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the ecobee weather platform.""" + data = hass.data[DOMAIN] + dev = [] for index in range(len(data.ecobee.thermostats)): thermostat = data.ecobee.get_thermostat(index) - if 'weather' in thermostat: - dev.append(EcobeeWeather(thermostat['name'], index)) + if "weather" in thermostat: + dev.append(EcobeeWeather(data, thermostat["name"], index)) - add_entities(dev, True) + async_add_entities(dev, True) class EcobeeWeather(WeatherEntity): """Representation of Ecobee weather data.""" - def __init__(self, name, index): + def __init__(self, data, name, index): """Initialize the Ecobee weather platform.""" + self.data = data self._name = name self._index = index self.weather = None @@ -41,7 +48,7 @@ def __init__(self, name, index): def get_forecast(self, index, param): """Retrieve forecast parameter.""" try: - forecast = self.weather['forecasts'][index] + forecast = self.weather["forecasts"][index] return forecast[param] except (ValueError, IndexError, KeyError): raise ValueError @@ -51,11 +58,40 @@ def name(self): """Return the name of the sensor.""" return self._name + @property + def unique_id(self): + """Return a unique identifier for the weather platform.""" + return self.data.ecobee.get_thermostat(self._index)["identifier"] + + @property + def device_info(self): + """Return device information for the ecobee weather platform.""" + thermostat = self.data.ecobee.get_thermostat(self._index) + try: + model = f"{ECOBEE_MODEL_TO_NAME[thermostat['modelNumber']]} Thermostat" + except KeyError: + _LOGGER.error( + "Model number for ecobee thermostat %s not recognized. " + "Please visit this link and provide the following information: " + "https://github.com/home-assistant/home-assistant/issues/27172 " + "Unrecognized model number: %s", + thermostat["name"], + thermostat["modelNumber"], + ) + return None + + return { + "identifiers": {(DOMAIN, thermostat["identifier"])}, + "name": self.name, + "manufacturer": MANUFACTURER, + "model": model, + } + @property def condition(self): """Return the current condition.""" try: - return self.get_forecast(0, 'condition') + return ECOBEE_WEATHER_SYMBOL_TO_HASS[self.get_forecast(0, "weatherSymbol")] except ValueError: return None @@ -63,7 +99,7 @@ def condition(self): def temperature(self): """Return the temperature.""" try: - return float(self.get_forecast(0, 'temperature')) / 10 + return float(self.get_forecast(0, "temperature")) / 10 except ValueError: return None @@ -76,7 +112,7 @@ def temperature_unit(self): def pressure(self): """Return the pressure.""" try: - return int(self.get_forecast(0, 'pressure')) + return int(self.get_forecast(0, "pressure")) except ValueError: return None @@ -84,7 +120,7 @@ def pressure(self): def humidity(self): """Return the humidity.""" try: - return int(self.get_forecast(0, 'relativeHumidity')) + return int(self.get_forecast(0, "relativeHumidity")) except ValueError: return None @@ -92,7 +128,7 @@ def humidity(self): def visibility(self): """Return the visibility.""" try: - return int(self.get_forecast(0, 'visibility')) + return int(self.get_forecast(0, "visibility")) / 1000 except ValueError: return None @@ -100,7 +136,7 @@ def visibility(self): def wind_speed(self): """Return the wind speed.""" try: - return int(self.get_forecast(0, 'windSpeed')) + return int(self.get_forecast(0, "windSpeed")) except ValueError: return None @@ -108,54 +144,66 @@ def wind_speed(self): def wind_bearing(self): """Return the wind direction.""" try: - return int(self.get_forecast(0, 'windBearing')) + 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 + if not self.weather: + return None + + station = self.weather.get("weatherStation", "UNKNOWN") + time = self.weather.get("timestamp", "UNKNOWN") + return f"Ecobee weather provided by {station} at {time} UTC" @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): + if "forecasts" not in self.weather: 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) + forecasts = [] + for day in range(1, 5): + forecast = _process_forecast(self.weather["forecasts"][day]) + if forecast is None: + continue + forecasts.append(forecast) + + if forecasts: + return forecasts + return None + + async def async_update(self): + """Get the latest weather data.""" + await self.data.update() + thermostat = self.data.ecobee.get_thermostat(self._index) + self.weather = thermostat.get("weather") + + +def _process_forecast(json): + """Process a single ecobee API forecast to return expected values.""" + forecast = {} + try: + forecast[ATTR_FORECAST_TIME] = datetime.strptime( + json["dateTime"], "%Y-%m-%d %H:%M:%S" + ).isoformat() + forecast[ATTR_FORECAST_CONDITION] = ECOBEE_WEATHER_SYMBOL_TO_HASS[ + json["weatherSymbol"] + ] + if json["tempHigh"] != ECOBEE_STATE_UNKNOWN: + forecast[ATTR_FORECAST_TEMP] = float(json["tempHigh"]) / 10 + if json["tempLow"] != ECOBEE_STATE_UNKNOWN: + forecast[ATTR_FORECAST_TEMP_LOW] = float(json["tempLow"]) / 10 + if json["windBearing"] != ECOBEE_STATE_UNKNOWN: + forecast[ATTR_FORECAST_WIND_BEARING] = int(json["windBearing"]) + if json["windSpeed"] != ECOBEE_STATE_UNKNOWN: + forecast[ATTR_FORECAST_WIND_SPEED] = int(json["windSpeed"]) + + except (ValueError, IndexError, KeyError): + return None + + if forecast: + return forecast + return None diff --git a/homeassistant/components/econet/const.py b/homeassistant/components/econet/const.py new file mode 100644 index 0000000000000..88b1b851aa6d2 --- /dev/null +++ b/homeassistant/components/econet/const.py @@ -0,0 +1,5 @@ +"""Constants for Econet integration.""" + +DOMAIN = "econet" +SERVICE_ADD_VACATION = "add_vacation" +SERVICE_DELETE_VACATION = "delete_vacation" diff --git a/homeassistant/components/econet/manifest.json b/homeassistant/components/econet/manifest.json index 3ae6b1eac3555..21476d2b7ff36 100644 --- a/homeassistant/components/econet/manifest.json +++ b/homeassistant/components/econet/manifest.json @@ -1,10 +1,7 @@ { "domain": "econet", - "name": "Econet", - "documentation": "https://www.home-assistant.io/components/econet", - "requirements": [ - "pyeconet==0.0.11" - ], - "dependencies": [], + "name": "Rheem EcoNET Water Products", + "documentation": "https://www.home-assistant.io/integrations/econet", + "requirements": ["pyeconet==0.0.11"], "codeowners": [] } diff --git a/homeassistant/components/econet/services.yaml b/homeassistant/components/econet/services.yaml index e69de29bb2d1d..b531764c2901a 100644 --- a/homeassistant/components/econet/services.yaml +++ b/homeassistant/components/econet/services.yaml @@ -0,0 +1,19 @@ +add_vacation: + description: Add a vacation to your water heater. + fields: + entity_id: + description: Name(s) of entities to change. + example: "water_heater.econet" + start_date: + description: The timestamp of when the vacation should start. (Optional, defaults to now) + example: 1513186320 + end_date: + description: The timestamp of when the vacation should end. + example: 1513445520 + +delete_vacation: + description: Delete your existing vacation from your water heater. + fields: + entity_id: + description: Name(s) of entities to change. + example: "water_heater.econet" diff --git a/homeassistant/components/econet/water_heater.py b/homeassistant/components/econet/water_heater.py index 4c47e24d705bb..0c31e3e50e0ba 100644 --- a/homeassistant/components/econet/water_heater.py +++ b/homeassistant/components/econet/water_heater.py @@ -2,69 +2,84 @@ import datetime import logging +from pyeconet.api import PyEcoNet 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) + PLATFORM_SCHEMA, + STATE_ECO, + STATE_ELECTRIC, + STATE_GAS, + STATE_HEAT_PUMP, + STATE_HIGH_DEMAND, + STATE_OFF, + STATE_PERFORMANCE, + SUPPORT_OPERATION_MODE, + SUPPORT_TARGET_TEMPERATURE, + WaterHeaterEntity, +) from homeassistant.const import ( - ATTR_ENTITY_ID, ATTR_TEMPERATURE, CONF_PASSWORD, CONF_USERNAME, - TEMP_FAHRENHEIT) + ATTR_ENTITY_ID, + ATTR_TEMPERATURE, + CONF_PASSWORD, + CONF_USERNAME, + TEMP_FAHRENHEIT, +) import homeassistant.helpers.config_validation as cv +from .const import DOMAIN, SERVICE_ADD_VACATION, SERVICE_DELETE_VACATION + _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_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' +ATTR_START_DATE = "start_date" +ATTR_END_DATE = "end_date" -SUPPORT_FLAGS_HEATER = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE) +ATTR_LOWER_TEMP = "lower_temp" +ATTR_UPPER_TEMP = "upper_temp" +ATTR_IS_ENABLED = "is_enabled" -SERVICE_ADD_VACATION = 'econet_add_vacation' -SERVICE_DELETE_VACATION = 'econet_delete_vacation' +SUPPORT_FLAGS_HEATER = SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE -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, -}) +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, -}) +DELETE_VACATION_SCHEMA = vol.Schema({vol.Optional(ATTR_ENTITY_ID): cv.entity_ids}) -ECONET_DATA = 'econet' +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 + "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, -}) +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'] = [] + hass.data[ECONET_DATA]["water_heaters"] = [] username = config.get(CONF_USERNAME) password = config.get(CONF_PASSWORD) @@ -72,17 +87,18 @@ def setup_platform(hass, config, add_entities, discovery_info=None): econet = PyEcoNet(username, password) water_heaters = econet.get_water_heaters() hass_water_heaters = [ - EcoNetWaterHeater(water_heater) for water_heater in 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) + 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'] + 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] + 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: @@ -95,14 +111,16 @@ def service_handle(service): _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_ADD_VACATION, service_handle, schema=ADD_VACATION_SCHEMA + ) - hass.services.register(DOMAIN, SERVICE_DELETE_VACATION, service_handle, - schema=DELETE_VACATION_SCHEMA) + hass.services.register( + DOMAIN, SERVICE_DELETE_VACATION, service_handle, schema=DELETE_VACATION_SCHEMA + ) -class EcoNetWaterHeater(WaterHeaterDevice): +class EcoNetWaterHeater(WaterHeaterEntity): """Representation of an EcoNet water heater.""" def __init__(self, water_heater): @@ -118,8 +136,7 @@ def __init__(self, water_heater): 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." + error = f"Invalid operation mode mapping. {mode} doesn't map. Please report this." _LOGGER.error(error) @property @@ -151,6 +168,13 @@ def device_state_attributes(self): data[ATTR_TODAYS_ENERGY_USAGE] = todays_usage data[ATTR_IN_USE] = self.water_heater.in_use + if self.water_heater.lower_temp is not None: + data[ATTR_LOWER_TEMP] = round(self.water_heater.lower_temp, 2) + if self.water_heater.upper_temp is not None: + data[ATTR_UPPER_TEMP] = round(self.water_heater.upper_temp, 2) + if self.water_heater.is_enabled is not None: + data[ATTR_IS_ENABLED] = self.water_heater.is_enabled + return data @property diff --git a/homeassistant/components/ecovacs/__init__.py b/homeassistant/components/ecovacs/__init__.py index da87af722a60a..964dd7a3f2ac4 100644 --- a/homeassistant/components/ecovacs/__init__.py +++ b/homeassistant/components/ecovacs/__init__.py @@ -3,10 +3,10 @@ import random import string +from sucks import EcoVacsAPI, VacBot import voluptuous as vol -from homeassistant.const import ( - CONF_PASSWORD, CONF_USERNAME, EVENT_HOMEASSISTANT_STOP) +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, EVENT_HOMEASSISTANT_STOP from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv @@ -17,19 +17,24 @@ 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) +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( +ECOVACS_API_DEVICEID = "".join( random.choice(string.ascii_uppercase + string.digits) for _ in range(8) ) @@ -40,13 +45,13 @@ def setup(hass, config): 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)) + 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) @@ -54,21 +59,26 @@ def setup(hass, config): 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) + 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']) + _LOGGER.info( + "Shutting down connection to Ecovacs device %s", device.vacuum["did"] + ) device.disconnect() # Listen for HA stop to disconnect. diff --git a/homeassistant/components/ecovacs/manifest.json b/homeassistant/components/ecovacs/manifest.json index 4495cb3c2f904..aa67be422c5c1 100644 --- a/homeassistant/components/ecovacs/manifest.json +++ b/homeassistant/components/ecovacs/manifest.json @@ -1,12 +1,7 @@ { "domain": "ecovacs", "name": "Ecovacs", - "documentation": "https://www.home-assistant.io/components/ecovacs", - "requirements": [ - "sucks==0.9.4" - ], - "dependencies": [], - "codeowners": [ - "@OverloadUT" - ] + "documentation": "https://www.home-assistant.io/integrations/ecovacs", + "requirements": ["sucks==0.9.4"], + "codeowners": ["@OverloadUT"] } diff --git a/homeassistant/components/ecovacs/vacuum.py b/homeassistant/components/ecovacs/vacuum.py index ee374871d3180..6ad51e6c4741c 100644 --- a/homeassistant/components/ecovacs/vacuum.py +++ b/homeassistant/components/ecovacs/vacuum.py @@ -1,10 +1,21 @@ """Support for Ecovacs Ecovacs Vaccums.""" import logging +import sucks + 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) + 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, + VacuumEntity, +) from homeassistant.helpers.icon import icon_for_battery_level from . import ECOVACS_DEVICES @@ -12,12 +23,20 @@ _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_' + 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): @@ -25,22 +44,22 @@ def setup_platform(hass, config, add_entities, discovery_info=None): vacuums = [] for device in hass.data[ECOVACS_DEVICES]: vacuums.append(EcovacsVacuum(device)) - _LOGGER.debug("Adding Ecovacs Vacuums to Hass: %s", vacuums) + _LOGGER.debug("Adding Ecovacs Vacuums to Home Assistant: %s", vacuums) add_entities(vacuums, True) -class EcovacsVacuum(VacuumDevice): +class EcovacsVacuum(VacuumEntity): """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']) + if self.device.vacuum.get("nick") is not None: + self._name = str(self.device.vacuum["nick"]) else: # In case there is no nickname defined, use the device id - self._name = '{}'.format(self.device.vacuum['did']) + self._name = str(format(self.device.vacuum["did"])) self._fan_speed = None self._error = None @@ -48,12 +67,9 @@ def __init__(self, device): 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.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): @@ -62,15 +78,14 @@ def on_error(self, error): 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': + 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.hass.bus.fire( + "ecovacs_error", {"entity_id": self.entity_id, "error": error} + ) self.schedule_update_ha_state() @property @@ -81,7 +96,7 @@ def should_poll(self) -> bool: @property def unique_id(self) -> str: """Return an unique ID.""" - return self.device.vacuum.get('did', None) + return self.device.vacuum.get("did") @property def is_on(self): @@ -110,14 +125,15 @@ def status(self): def return_to_base(self, **kwargs): """Set the vacuum cleaner to return to the dock.""" - from sucks import Charge - self.device.run(Charge()) + + self.device.run(sucks.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) + battery_level=self.battery_level, charging=self.is_charging + ) @property def battery_level(self): @@ -135,13 +151,13 @@ def fan_speed(self): @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] + + return [sucks.FAN_SPEED_NORMAL, sucks.FAN_SPEED_HIGH] def turn_on(self, **kwargs): """Turn the vacuum on and start cleaning.""" - from sucks import Clean - self.device.run(Clean()) + + self.device.run(sucks.Clean()) def turn_off(self, **kwargs): """Turn the vacuum off stopping the cleaning and returning home.""" @@ -149,30 +165,28 @@ def turn_off(self, **kwargs): def stop(self, **kwargs): """Stop the vacuum cleaner.""" - from sucks import Stop - self.device.run(Stop()) + + self.device.run(sucks.Stop()) def clean_spot(self, **kwargs): """Perform a spot clean-up.""" - from sucks import Spot - self.device.run(Spot()) + + self.device.run(sucks.Spot()) def locate(self, **kwargs): """Locate the vacuum cleaner.""" - from sucks import PlaySound - self.device.run(PlaySound()) + + self.device.run(sucks.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)) + + self.device.run(sucks.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)) + self.device.run(sucks.VacBotCommand(command, params)) @property def device_state_attributes(self): diff --git a/homeassistant/components/eddystone_temperature/manifest.json b/homeassistant/components/eddystone_temperature/manifest.json index 4684655aa372a..c59cb6a9c7fc7 100644 --- a/homeassistant/components/eddystone_temperature/manifest.json +++ b/homeassistant/components/eddystone_temperature/manifest.json @@ -1,11 +1,7 @@ { "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": [], + "name": "Eddystone", + "documentation": "https://www.home-assistant.io/integrations/eddystone_temperature", + "requirements": ["beacontools[scan]==1.2.3", "construct==2.9.45"], "codeowners": [] } diff --git a/homeassistant/components/eddystone_temperature/sensor.py b/homeassistant/components/eddystone_temperature/sensor.py index aad279934e585..1d6ff61bf59c9 100644 --- a/homeassistant/components/eddystone_temperature/sensor.py +++ b/homeassistant/components/eddystone_temperature/sensor.py @@ -3,38 +3,45 @@ 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 +# pylint: disable=import-error +from beacontools import BeaconScanner, EddystoneFilter, EddystoneTLMFrame 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) + 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' +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 -}) +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}), -}) +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): @@ -52,8 +59,8 @@ def setup_platform(hass, config, add_entities, discovery_info=None): if instance is None or namespace is None: _LOGGER.error("Skipping %s", dev_name) continue - else: - devices.append(EddystoneTemp(name, namespace, instance)) + + devices.append(EddystoneTemp(name, namespace, instance)) if devices: mon = Monitor(hass, devices, bt_device_id) @@ -80,8 +87,12 @@ 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) + _LOGGER.error( + "Error in configuration parameter %s: Must be exactly %d " + "bytes. Device will not be added", + config_key, + length / 2, + ) return None return string @@ -133,16 +144,16 @@ def __init__(self, hass, devices, bt_device_id): def callback(bt_addr, _, packet, additional_info): """Handle new packets.""" self.process_packet( - additional_info['namespace'], additional_info['instance'], - packet.temperature) + 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] + device_filters = [EddystoneFilter(d.namespace, d.instance) for d in devices] self.scanner = BeaconScanner( - callback, bt_device_id, device_filters, EddystoneTLMFrame) + callback, bt_device_id, device_filters, EddystoneTLMFrame + ) self.scanning = False def start(self): @@ -151,13 +162,13 @@ def start(self): self.scanner.start() self.scanning = True else: - _LOGGER.debug( - "start() called, but scanner is already running") + _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) + _LOGGER.debug( + "Received temperature for <%s,%s>: %d", namespace, instance, temperature + ) for dev in self.devices: if dev.namespace == namespace and dev.instance == instance: @@ -173,5 +184,4 @@ def stop(self): _LOGGER.debug("Stopped") self.scanning = False else: - _LOGGER.debug( - "stop() called but scanner was not running") + _LOGGER.debug("stop() called but scanner was not running") diff --git a/homeassistant/components/edimax/manifest.json b/homeassistant/components/edimax/manifest.json index 9fe0e4c50c969..20d72b30a6a60 100644 --- a/homeassistant/components/edimax/manifest.json +++ b/homeassistant/components/edimax/manifest.json @@ -1,10 +1,7 @@ { "domain": "edimax", "name": "Edimax", - "documentation": "https://www.home-assistant.io/components/edimax", - "requirements": [ - "pyedimax==0.1" - ], - "dependencies": [], + "documentation": "https://www.home-assistant.io/integrations/edimax", + "requirements": ["pyedimax==0.2.1"], "codeowners": [] } diff --git a/homeassistant/components/edimax/switch.py b/homeassistant/components/edimax/switch.py index 535ae65800fb1..17a43f36235d5 100644 --- a/homeassistant/components/edimax/switch.py +++ b/homeassistant/components/edimax/switch.py @@ -1,39 +1,41 @@ """Support for Edimax switches.""" import logging +from pyedimax.smartplug import SmartPlug import voluptuous as vol -from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA) -from homeassistant.const import ( - CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME) +from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchEntity +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' +DOMAIN = "edimax" -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, -}) +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)]) + add_entities([SmartPlugSwitch(SmartPlug(host, auth), name)], True) -class SmartPlugSwitch(SwitchDevice): +class SmartPlugSwitch(SwitchEntity): """Representation an Edimax Smart Plug switch.""" def __init__(self, smartplug, name): @@ -43,6 +45,14 @@ def __init__(self, smartplug, name): self._now_power = None self._now_energy_day = None self._state = False + self._supports_power_monitoring = False + self._info = None + self._mac = None + + @property + def unique_id(self): + """Return the device's MAC address.""" + return self._mac @property def name(self): @@ -66,22 +76,28 @@ def is_on(self): def turn_on(self, **kwargs): """Turn the switch on.""" - self.smartplug.state = 'ON' + self.smartplug.state = "ON" def turn_off(self, **kwargs): """Turn the switch off.""" - self.smartplug.state = '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' + if not self._info: + self._info = self.smartplug.info + self._mac = self._info["mac"] + self._supports_power_monitoring = self._info["model"] != "SP1101W" + + if self._supports_power_monitoring: + 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/edl21/__init__.py b/homeassistant/components/edl21/__init__.py new file mode 100644 index 0000000000000..f1cd598474446 --- /dev/null +++ b/homeassistant/components/edl21/__init__.py @@ -0,0 +1 @@ +"""The edl21 component.""" diff --git a/homeassistant/components/edl21/manifest.json b/homeassistant/components/edl21/manifest.json new file mode 100644 index 0000000000000..3e469e44601e0 --- /dev/null +++ b/homeassistant/components/edl21/manifest.json @@ -0,0 +1,7 @@ +{ + "domain": "edl21", + "name": "EDL21", + "documentation": "https://www.home-assistant.io/integrations/edl21", + "requirements": ["pysml==0.0.2"], + "codeowners": ["@mtdcr"] +} diff --git a/homeassistant/components/edl21/sensor.py b/homeassistant/components/edl21/sensor.py new file mode 100644 index 0000000000000..a4e0ca734fabc --- /dev/null +++ b/homeassistant/components/edl21/sensor.py @@ -0,0 +1,196 @@ +"""Support for EDL21 Smart Meters.""" + +from datetime import timedelta +import logging + +from sml import SmlGetListResponse +from sml.asyncio import SmlProtocol +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.typing import Optional +from homeassistant.util.dt import utcnow + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "edl21" +CONF_SERIAL_PORT = "serial_port" +ICON_POWER = "mdi:flash" +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) +SIGNAL_EDL21_TELEGRAM = "edl21_telegram" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({vol.Required(CONF_SERIAL_PORT): cv.string}) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the EDL21 sensor.""" + hass.data[DOMAIN] = EDL21(hass, config, async_add_entities) + await hass.data[DOMAIN].connect() + + +class EDL21: + """EDL21 handles telegrams sent by a compatible smart meter.""" + + # OBIS format: A-B:C.D.E*F + _OBIS_NAMES = { + # A=1: Electricity + # C=0: General purpose objects + "1-0:0.0.9*255": "Electricity ID", + # C=1: Active power + + # D=8: Time integral 1 + # E=0: Total + "1-0:1.8.0*255": "Positive active energy total", + # E=1: Rate 1 + "1-0:1.8.1*255": "Positive active energy in tariff T1", + # E=2: Rate 2 + "1-0:1.8.2*255": "Positive active energy in tariff T2", + # D=17: Time integral 7 + # E=0: Total + "1-0:1.17.0*255": "Last signed positive active energy total", + # C=15: Active power absolute + # D=7: Instantaneous value + # E=0: Total + "1-0:15.7.0*255": "Absolute active instantaneous power", + # C=16: Active power sum + # D=7: Instantaneous value + # E=0: Total + "1-0:16.7.0*255": "Sum active instantaneous power", + } + _OBIS_BLACKLIST = { + # A=129: Manufacturer specific + "129-129:199.130.3*255", # Iskraemeco: Manufacturer + "129-129:199.130.5*255", # Iskraemeco: Public Key + } + + def __init__(self, hass, config, async_add_entities) -> None: + """Initialize an EDL21 object.""" + self._registered_obis = set() + self._hass = hass + self._async_add_entities = async_add_entities + self._proto = SmlProtocol(config[CONF_SERIAL_PORT]) + self._proto.add_listener(self.event, ["SmlGetListResponse"]) + + async def connect(self): + """Connect to an EDL21 reader.""" + await self._proto.connect(self._hass.loop) + + def event(self, message_body) -> None: + """Handle events from pysml.""" + assert isinstance(message_body, SmlGetListResponse) + + new_entities = [] + for telegram in message_body.get("valList", []): + obis = telegram.get("objName") + if not obis: + continue + + if obis in self._registered_obis: + async_dispatcher_send(self._hass, SIGNAL_EDL21_TELEGRAM, telegram) + else: + name = self._OBIS_NAMES.get(obis) + if name: + new_entities.append(EDL21Entity(obis, name, telegram)) + self._registered_obis.add(obis) + elif obis not in self._OBIS_BLACKLIST: + _LOGGER.warning( + "Unhandled sensor %s detected. Please report at " + 'https://github.com/home-assistant/home-assistant/issues?q=is%%3Aissue+label%%3A"integration%%3A+edl21"+', + obis, + ) + self._OBIS_BLACKLIST.add(obis) + + if new_entities: + self._async_add_entities(new_entities, update_before_add=True) + + +class EDL21Entity(Entity): + """Entity reading values from EDL21 telegram.""" + + def __init__(self, obis, name, telegram): + """Initialize an EDL21Entity.""" + self._obis = obis + self._name = name + self._telegram = telegram + self._min_time = MIN_TIME_BETWEEN_UPDATES + self._last_update = utcnow() + self._state_attrs = { + "status": "status", + "valTime": "val_time", + "scaler": "scaler", + "valueSignature": "value_signature", + } + self._async_remove_dispatcher = None + + async def async_added_to_hass(self): + """Run when entity about to be added to hass.""" + + @callback + def handle_telegram(telegram): + """Update attributes from last received telegram for this object.""" + if self._obis != telegram.get("objName"): + return + if self._telegram == telegram: + return + + now = utcnow() + if now - self._last_update < self._min_time: + return + + self._telegram = telegram + self._last_update = now + self.async_write_ha_state() + + self._async_remove_dispatcher = async_dispatcher_connect( + self.hass, SIGNAL_EDL21_TELEGRAM, handle_telegram + ) + + async def async_will_remove_from_hass(self): + """Run when entity will be removed from hass.""" + if self._async_remove_dispatcher: + self._async_remove_dispatcher() + + @property + def should_poll(self) -> bool: + """Do not poll.""" + return False + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return self._obis + + @property + def name(self) -> Optional[str]: + """Return a name.""" + return self._name + + @property + def state(self) -> str: + """Return the value of the last received telegram.""" + return self._telegram.get("value") + + @property + def device_state_attributes(self): + """Enumerate supported attributes.""" + return { + self._state_attrs[k]: v + for k, v in self._telegram.items() + if k in self._state_attrs + } + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self._telegram.get("unit") + + @property + def icon(self): + """Return an icon.""" + return ICON_POWER diff --git a/homeassistant/components/edp_redy/__init__.py b/homeassistant/components/edp_redy/__init__.py deleted file mode 100644 index af01206419468..0000000000000 --- a/homeassistant/components/edp_redy/__init__.py +++ /dev/null @@ -1,127 +0,0 @@ -"""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 deleted file mode 100644 index 90404b2167832..0000000000000 --- a/homeassistant/components/edp_redy/manifest.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "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 deleted file mode 100644 index cf9766ede66d0..0000000000000 --- a/homeassistant/components/edp_redy/sensor.py +++ /dev/null @@ -1,114 +0,0 @@ -"""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 deleted file mode 100644 index 3f6dfe6b82d49..0000000000000 --- a/homeassistant/components/edp_redy/switch.py +++ /dev/null @@ -1,93 +0,0 @@ -"""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/device_tracker.py b/homeassistant/components/ee_brightbox/device_tracker.py index 6af5065ed2e69..845d557e029f1 100644 --- a/homeassistant/components/ee_brightbox/device_tracker.py +++ b/homeassistant/components/ee_brightbox/device_tracker.py @@ -1,27 +1,33 @@ """Support for EE Brightbox router.""" import logging +from eebrightbox import EEBrightBox, EEBrightBoxException import voluptuous as vol from homeassistant.components.device_tracker import ( - DOMAIN, PLATFORM_SCHEMA, DeviceScanner) + 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_VERSION = "version" -CONF_DEFAULT_IP = '192.168.1.1' -CONF_DEFAULT_USERNAME = 'admin' +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, -}) +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): @@ -41,8 +47,6 @@ def __init__(self, config): 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()) @@ -52,21 +56,19 @@ def check_config(self): 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()} + 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']] + macs = [d["mac"] for d in self.devices.values() if d["activity_ip"]] - _LOGGER.debug('Scan devices %s', macs) + _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 self.devices[device]["hostname"] or None return None @@ -81,20 +83,20 @@ def get_extra_attributes(self, device): - last_active """ port_map = { - 'wl1': 'wifi5Ghz', - 'wl0': 'wifi2.4Ghz', - 'eth0': 'eth0', - 'eth1': 'eth1', - 'eth2': 'eth2', - 'eth3': 'eth3', + "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'], + "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 index 967f04228a825..361df9575df4d 100644 --- a/homeassistant/components/ee_brightbox/manifest.json +++ b/homeassistant/components/ee_brightbox/manifest.json @@ -1,10 +1,7 @@ { "domain": "ee_brightbox", - "name": "Ee brightbox", - "documentation": "https://www.home-assistant.io/components/ee_brightbox", - "requirements": [ - "eebrightbox==0.0.4" - ], - "dependencies": [], + "name": "EE Bright Box", + "documentation": "https://www.home-assistant.io/integrations/ee_brightbox", + "requirements": ["eebrightbox==0.0.4"], "codeowners": [] } diff --git a/homeassistant/components/efergy/manifest.json b/homeassistant/components/efergy/manifest.json index f4ca116a64708..cb9cfb17ac551 100644 --- a/homeassistant/components/efergy/manifest.json +++ b/homeassistant/components/efergy/manifest.json @@ -1,8 +1,6 @@ { "domain": "efergy", "name": "Efergy", - "documentation": "https://www.home-assistant.io/components/efergy", - "requirements": [], - "dependencies": [], + "documentation": "https://www.home-assistant.io/integrations/efergy", "codeowners": [] } diff --git a/homeassistant/components/efergy/sensor.py b/homeassistant/components/efergy/sensor.py index eb8912abe18a3..8c16317beda40 100644 --- a/homeassistant/components/efergy/sensor.py +++ b/homeassistant/components/efergy/sensor.py @@ -5,51 +5,54 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import (CONF_CURRENCY, POWER_WATT, - ENERGY_KILO_WATT_HOUR) +from homeassistant.const import CONF_CURRENCY, ENERGY_KILO_WATT_HOUR, POWER_WATT import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) -_RESOURCE = 'https://engage.efergy.com/mobile_proxy/' +_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_APPTOKEN = "app_token" +CONF_UTC_OFFSET = "utc_offset" +CONF_MONITORED_VARIABLES = "monitored_variables" +CONF_SENSOR_TYPE = "type" -CONF_PERIOD = 'period' +CONF_PERIOD = "period" -CONF_INSTANT = 'instant_readings' -CONF_AMOUNT = 'amount' -CONF_BUDGET = 'budget' -CONF_COST = 'cost' -CONF_CURRENT_VALUES = 'current_values' +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' +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] + 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, -}) +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] -}) +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): @@ -60,17 +63,29 @@ def setup_platform(hass, config, add_entities, discovery_info=None): dev = [] for variable in config[CONF_MONITORED_VARIABLES]: if variable[CONF_SENSOR_TYPE] == CONF_CURRENT_VALUES: - url_string = '{}getCurrentValuesSummary?token={}'.format( - _RESOURCE, app_token) + url_string = f"{_RESOURCE}getCurrentValuesSummary?token={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])) + 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) @@ -78,12 +93,11 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class EfergySensor(Entity): """Implementation of an Efergy sensor.""" - def __init__(self, sensor_type, app_token, utc_offset, period, - currency, sid=None): + 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) + self._name = f"efergy_{sid}" else: self._name = SENSOR_TYPES[sensor_type][0] self.type = sensor_type @@ -92,9 +106,8 @@ def __init__(self, sensor_type, app_token, utc_offset, period, self._state = None self.period = period self.currency = currency - if self.type == 'cost': - self._unit_of_measurement = '{}/{}'.format( - self.currency, self.period) + if self.type == "cost": + self._unit_of_measurement = f"{self.currency}/{self.period}" else: self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] @@ -116,33 +129,30 @@ def unit_of_measurement(self): 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) + if self.type == "instant_readings": + url_string = f"{_RESOURCE}getInstant?token={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) + self._state = response.json()["reading"] + elif self.type == "amount": + url_string = f"{_RESOURCE}getEnergy?token={self.app_token}&offset={self.utc_offset}&period={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) + self._state = response.json()["sum"] + elif self.type == "budget": + url_string = f"{_RESOURCE}getBudget?token={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) + self._state = response.json()["status"] + elif self.type == "cost": + url_string = f"{_RESOURCE}getCost?token={self.app_token}&offset={self.utc_offset}&period={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) + self._state = response.json()["sum"] + elif self.type == "current_values": + url_string = ( + f"{_RESOURCE}getCurrentValuesSummary?token={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())) + if self.sid == sensor["sid"]: + measurement = next(iter(sensor["data"][0].values())) self._state = measurement else: self._state = None diff --git a/homeassistant/components/egardia/__init__.py b/homeassistant/components/egardia/__init__.py index cf0bb20f0fcec..8b67d23d3cc8d 100644 --- a/homeassistant/components/egardia/__init__.py +++ b/homeassistant/components/egardia/__init__.py @@ -1,71 +1,85 @@ """Interfaces with Egardia/Woonveilig alarm control panel.""" import logging +from pythonegardia import egardiadevice, egardiaserver import requests import voluptuous as vol from homeassistant.const import ( - CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT, CONF_USERNAME, - EVENT_HOMEASSISTANT_STOP) + 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' +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' +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_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) +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) @@ -76,10 +90,13 @@ def setup(hass, config): rs_port = conf.get(CONF_REPORT_SERVER_PORT) try: device = hass.data[EGARDIA_DEVICE] = egardiadevice.EgardiaDevice( - host, port, username, password, '', version) + host, port, username, password, "", version + ) except requests.exceptions.RequestException: - _LOGGER.error("An error occurred accessing your Egardia device. " - "Please check configuration") + _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") @@ -89,11 +106,12 @@ def setup(hass, config): _LOGGER.debug("Setting up EgardiaServer") try: if EGARDIA_SERVER not in hass.data: - server = egardiaserver.EgardiaServer('', rs_port) + server = egardiaserver.EgardiaServer("", rs_port) bound = server.bind() if not bound: - raise IOError("Binding error occurred while " + - "starting EgardiaServer.") + raise OSError( + "Binding error occurred while starting EgardiaServer." + ) hass.data[EGARDIA_SERVER] = server server.start() @@ -101,20 +119,21 @@ def handle_stop_event(event): """Handle Home Assistant stop event.""" server.stop() - # listen to home assistant stop event + # 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") + except OSError: + _LOGGER.error("Binding error occurred while starting EgardiaServer") return False - discovery.load_platform(hass, 'alarm_control_panel', DOMAIN, - discovered=conf, hass_config=config) + 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) + 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 index ab48181f9ede1..b133a96b82047 100644 --- a/homeassistant/components/egardia/alarm_control_panel.py +++ b/homeassistant/components/egardia/alarm_control_panel.py @@ -4,25 +4,37 @@ import requests import homeassistant.components.alarm_control_panel as alarm +from homeassistant.components.alarm_control_panel.const import ( + SUPPORT_ALARM_ARM_AWAY, + SUPPORT_ALARM_ARM_HOME, +) from homeassistant.const import ( - STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, - STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED) + 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) + 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 + "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, } @@ -31,20 +43,22 @@ def setup_platform(hass, config, add_entities, discovery_info=None): if discovery_info is None: return device = EgardiaAlarm( - discovery_info['name'], + 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]) + discovery_info[CONF_REPORT_SERVER_PORT], + ) add_entities([device], True) -class EgardiaAlarm(alarm.AlarmControlPanel): +class EgardiaAlarm(alarm.AlarmControlPanelEntity): """Representation of a Egardia alarm.""" - def __init__(self, name, egardiasystem, - rs_enabled=False, rs_codes=None, rs_port=52010): + def __init__( + self, name, egardiasystem, rs_enabled=False, rs_codes=None, rs_port=52010 + ): """Initialize the Egardia alarm.""" self._name = name self._egardiasystem = egardiasystem @@ -57,8 +71,7 @@ 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) + self.hass.data[EGARDIA_SERVER].register_callback(self.handle_status_event) @property def name(self): @@ -70,6 +83,11 @@ def state(self): """Return the state of the device.""" return self._status + @property + def supported_features(self) -> int: + """Return the list of supported features.""" + return SUPPORT_ALARM_ARM_HOME | SUPPORT_ALARM_ARM_AWAY + @property def should_poll(self): """Poll if no report server is enabled.""" @@ -79,7 +97,7 @@ def should_poll(self): def handle_status_event(self, event): """Handle the Egardia system status event.""" - statuscode = event.get('status') + statuscode = event.get("status") if statuscode is not None: status = self.lookupstatusfromcode(statuscode) self.parsestatus(status) @@ -87,10 +105,15 @@ def handle_status_event(self, event): 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') + 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): @@ -115,21 +138,29 @@ def alarm_disarm(self, code=None): try: self._egardiasystem.alarm_disarm() except requests.exceptions.RequestException as err: - _LOGGER.error("Egardia device exception occurred when " - "sending disarm command: %s", 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) + _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) + _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 index 965b2dd1d5509..4be443a36f44b 100644 --- a/homeassistant/components/egardia/binary_sensor.py +++ b/homeassistant/components/egardia/binary_sensor.py @@ -1,7 +1,7 @@ """Interfaces with Egardia/Woonveilig alarm control panel.""" import logging -from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.const import STATE_OFF, STATE_ON from . import ATTR_DISCOVER_DEVICES, EGARDIA_DEVICE @@ -9,17 +9,15 @@ _LOGGER = logging.getLogger(__name__) EGARDIA_TYPE_TO_DEVICE_CLASS = { - 'IR Sensor': 'motion', - 'Door Contact': 'opening', - 'IR': 'motion', + "IR Sensor": "motion", + "Door Contact": "opening", + "IR": "motion", } -async def async_setup_platform(hass, config, async_add_entities, - discovery_info=None): +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): + if discovery_info is None or discovery_info[ATTR_DISCOVER_DEVICES] is None: return disc_info = discovery_info[ATTR_DISCOVER_DEVICES] @@ -27,17 +25,20 @@ async def async_setup_platform(hass, config, async_add_entities, async_add_entities( ( EgardiaBinarySensor( - sensor_id=disc_info[sensor]['id'], - name=disc_info[sensor]['name'], + 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) + disc_info[sensor]["type"], None + ), ) for sensor in disc_info - ), True) + ), + True, + ) -class EgardiaBinarySensor(BinarySensorDevice): +class EgardiaBinarySensor(BinarySensorEntity): """Represents a sensor based on an Egardia sensor (IR, Door Contact).""" def __init__(self, sensor_id, name, egardia_system, device_class): @@ -63,12 +64,6 @@ 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.""" diff --git a/homeassistant/components/egardia/manifest.json b/homeassistant/components/egardia/manifest.json index 3a95b90db9900..94953a773c29f 100644 --- a/homeassistant/components/egardia/manifest.json +++ b/homeassistant/components/egardia/manifest.json @@ -1,12 +1,7 @@ { "domain": "egardia", "name": "Egardia", - "documentation": "https://www.home-assistant.io/components/egardia", - "requirements": [ - "pythonegardia==1.0.39" - ], - "dependencies": [], - "codeowners": [ - "@jeroenterheerdt" - ] + "documentation": "https://www.home-assistant.io/integrations/egardia", + "requirements": ["pythonegardia==1.0.40"], + "codeowners": ["@jeroenterheerdt"] } diff --git a/homeassistant/components/eight_sleep/__init__.py b/homeassistant/components/eight_sleep/__init__.py index d74218796a316..022878c8276dc 100644 --- a/homeassistant/components/eight_sleep/__init__.py +++ b/homeassistant/components/eight_sleep/__init__.py @@ -1,86 +1,105 @@ """Support for Eight smart mattress covers and mattresses.""" -import logging from datetime import timedelta +import logging +from pyeight.eight import EightSleep 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) + ATTR_ENTITY_ID, + CONF_BINARY_SENSORS, + CONF_PASSWORD, + CONF_SENSORS, + CONF_USERNAME, + EVENT_HOMEASSISTANT_STOP, +) +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, async_dispatcher_connect) + async_dispatcher_connect, + async_dispatcher_send, +) from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.util.dt import utcnow _LOGGER = logging.getLogger(__name__) -CONF_PARTNER = 'partner' +CONF_PARTNER = "partner" -DATA_EIGHT = 'eight_sleep' +DATA_EIGHT = "eight_sleep" DEFAULT_PARTNER = False -DOMAIN = 'eight_sleep' +DOMAIN = "eight_sleep" -HEAT_ENTITY = 'heat' -USER_ENTITY = 'user' +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' +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', + "left_current_sleep": "Left Sleep Session", + "left_current_sleep_fitness": "Left Sleep Fitness", + "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_current_sleep_fitness": "Right Sleep Fitness", + "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'] +SENSORS = [ + "current_sleep", + "current_sleep_fitness", + "last_sleep", + "bed_state", + "bed_temp", + "sleep_stage", +] -SERVICE_HEAT_SET = 'heat_set' +SERVICE_HEAT_SET = "heat_set" -ATTR_TARGET_HEAT = 'target' -ATTR_HEAT_DURATION = 'duration' +ATTR_TARGET_HEAT = "target" +ATTR_HEAT_DURATION = "duration" -VALID_TARGET_HEAT = vol.All(vol.Coerce(int), vol.Clamp(min=0, max=100)) +VALID_TARGET_HEAT = vol.All(vol.Coerce(int), vol.Clamp(min=-100, 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) +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) @@ -88,10 +107,10 @@ async def async_setup(hass, config): partner = conf.get(CONF_PARTNER) if hass.config.time_zone is None: - _LOGGER.error('Timezone is not set in Home Assistant.') + _LOGGER.error("Timezone is not set in Home Assistant.") return False - timezone = hass.config.time_zone + timezone = str(hass.config.time_zone) eight = EightSleep(user, password, timezone, partner, None, hass.loop) @@ -109,7 +128,8 @@ async def async_update_heat_data(now): async_dispatcher_send(hass, SIGNAL_UPDATE_HEAT) async_track_point_in_utc_time( - hass, async_update_heat_data, utcnow() + HEAT_SCAN_INTERVAL) + 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.""" @@ -117,7 +137,8 @@ async def async_update_user_data(now): async_dispatcher_send(hass, SIGNAL_UPDATE_USER) async_track_point_in_utc_time( - hass, async_update_user_data, utcnow() + USER_SCAN_INTERVAL) + hass, async_update_user_data, utcnow() + USER_SCAN_INTERVAL + ) await async_update_heat_data(None) await async_update_user_data(None) @@ -129,22 +150,24 @@ async def async_update_user_data(now): 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') + sensors.append(f"{obj.side}_{sensor}") + binary_sensors.append(f"{obj.side}_presence") + 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, "sensor", DOMAIN, {CONF_SENSORS: sensors}, config + ) + ) - hass.async_create_task(discovery.async_load_platform( - hass, 'binary_sensor', DOMAIN, { - CONF_BINARY_SENSORS: binary_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.""" @@ -155,7 +178,7 @@ async def async_service_handler(service): duration = params.pop(ATTR_HEAT_DURATION, 0) for sens in sensor: - side = sens.split('_')[1] + side = sens.split("_")[1] userid = eight.fetch_userid(side) usrobj = eight.users[userid] await usrobj.set_heating_level(target, duration) @@ -164,8 +187,8 @@ async def async_service_handler(service): # Register services hass.services.async_register( - DOMAIN, SERVICE_HEAT_SET, async_service_handler, - schema=SERVICE_EIGHT_SCHEMA) + DOMAIN, SERVICE_HEAT_SET, async_service_handler, schema=SERVICE_EIGHT_SCHEMA + ) async def stop_eight(event): """Handle stopping eight api session.""" @@ -185,13 +208,17 @@ def __init__(self, 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) + self.async_on_remove( + async_dispatcher_connect( + self.hass, SIGNAL_UPDATE_USER, async_eight_user_update + ) + ) @property def should_poll(self): @@ -208,13 +235,17 @@ def __init__(self, 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) + self.async_on_remove( + async_dispatcher_connect( + self.hass, SIGNAL_UPDATE_HEAT, async_eight_heat_update + ) + ) @property def should_poll(self): diff --git a/homeassistant/components/eight_sleep/binary_sensor.py b/homeassistant/components/eight_sleep/binary_sensor.py index b384210672306..803b20383b6f2 100644 --- a/homeassistant/components/eight_sleep/binary_sensor.py +++ b/homeassistant/components/eight_sleep/binary_sensor.py @@ -1,20 +1,19 @@ """Support for Eight Sleep binary sensors.""" import logging -from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.components.binary_sensor import BinarySensorEntity 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): +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' + name = "Eight" sensors = discovery_info[CONF_BINARY_SENSORS] eight = hass.data[DATA_EIGHT] @@ -26,7 +25,7 @@ async def async_setup_platform(hass, config, async_add_entities, async_add_entities(all_sensors, True) -class EightHeatSensor(EightSleepHeatEntity, BinarySensorDevice): +class EightHeatSensor(EightSleepHeatEntity, BinarySensorEntity): """Representation of a Eight Sleep heat-based sensor.""" def __init__(self, name, eight, sensor): @@ -35,15 +34,19 @@ def __init__(self, name, eight, sensor): self._sensor = sensor self._mapped_name = NAME_MAP.get(self._sensor, self._sensor) - self._name = '{} {}'.format(name, self._mapped_name) + self._name = f"{name} {self._mapped_name}" self._state = None - self._side = self._sensor.split('_')[0] + 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) + _LOGGER.debug( + "Presence Sensor: %s, Side: %s, User: %s", + self._sensor, + self._side, + self._userid, + ) @property def name(self): diff --git a/homeassistant/components/eight_sleep/manifest.json b/homeassistant/components/eight_sleep/manifest.json index 2b008c3c370fb..b8be5757df951 100644 --- a/homeassistant/components/eight_sleep/manifest.json +++ b/homeassistant/components/eight_sleep/manifest.json @@ -1,12 +1,7 @@ { "domain": "eight_sleep", - "name": "Eight sleep", - "documentation": "https://www.home-assistant.io/components/eight_sleep", - "requirements": [ - "pyeight==0.1.1" - ], - "dependencies": [], - "codeowners": [ - "@mezz64" - ] + "name": "Eight Sleep", + "documentation": "https://www.home-assistant.io/integrations/eight_sleep", + "requirements": ["pyeight==0.1.4"], + "codeowners": ["@mezz64"] } diff --git a/homeassistant/components/eight_sleep/sensor.py b/homeassistant/components/eight_sleep/sensor.py index b7b0f5881552b..ff6dff85acaa9 100644 --- a/homeassistant/components/eight_sleep/sensor.py +++ b/homeassistant/components/eight_sleep/sensor.py @@ -1,54 +1,64 @@ """Support for Eight Sleep sensors.""" import logging +from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT, UNIT_PERCENTAGE + 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' + 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 = f"Light Sleep {UNIT_PERCENTAGE}" +ATTR_DEEP_PERC = f"Deep Sleep {UNIT_PERCENTAGE}" +ATTR_REM_PERC = f"REM Sleep {UNIT_PERCENTAGE}" +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" +ATTR_FIT_DATE = "Fitness Date" +ATTR_FIT_DURATION_SCORE = "Fitness Duration Score" +ATTR_FIT_ASLEEP_SCORE = "Fitness Asleep Score" +ATTR_FIT_OUT_SCORE = "Fitness Out-of-Bed Score" +ATTR_FIT_WAKEUP_SCORE = "Fitness Wakeup Score" _LOGGER = logging.getLogger(__name__) -async def async_setup_platform(hass, config, async_add_entities, - discovery_info=None): +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' + name = "Eight" sensors = discovery_info[CONF_SENSORS] eight = hass.data[DATA_EIGHT] if hass.config.units.is_metric: - units = 'si' + units = "si" else: - units = 'us' + units = "us" all_sensors = [] for sensor in sensors: - if 'bed_state' in sensor: + if "bed_state" in sensor: all_sensors.append(EightHeatSensor(name, eight, sensor)) - elif 'room_temp' in sensor: + elif "room_temp" in sensor: all_sensors.append(EightRoomSensor(name, eight, sensor, units)) else: all_sensors.append(EightUserSensor(name, eight, sensor, units)) @@ -65,15 +75,19 @@ def __init__(self, name, eight, sensor): self._sensor = sensor self._mapped_name = NAME_MAP.get(self._sensor, self._sensor) - self._name = '{} {}'.format(name, self._mapped_name) + self._name = f"{name} {self._mapped_name}" self._state = None - self._side = self._sensor.split('_')[0] + 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) + _LOGGER.debug( + "Heat Sensor: %s, Side: %s, User: %s", + self._sensor, + self._side, + self._userid, + ) @property def name(self): @@ -88,7 +102,7 @@ def state(self): @property def unit_of_measurement(self): """Return the unit the value is expressed in.""" - return '%' + return UNIT_PERCENTAGE async def async_update(self): """Retrieve latest state.""" @@ -113,19 +127,23 @@ def __init__(self, name, eight, sensor, units): super().__init__(eight) self._sensor = sensor - self._sensor_root = self._sensor.split('_', 1)[1] + 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._name = f"{name} {self._mapped_name}" self._state = None self._attr = None self._units = units - self._side = self._sensor.split('_', 1)[0] + 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) + _LOGGER.debug( + "User Sensor: %s, Side: %s, User: %s", + self._sensor, + self._side, + self._userid, + ) @property def name(self): @@ -140,40 +158,48 @@ def state(self): @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' + if ( + "current_sleep" in self._sensor + or "last_sleep" in self._sensor + or "current_sleep_fitness" in self._sensor + ): + return "Score" + if "bed_temp" in self._sensor: + if self._units == "si": + return TEMP_CELSIUS + return TEMP_FAHRENHEIT return None @property def icon(self): """Icon to use in the frontend, if any.""" - if 'bed_temp' in self._sensor: - return 'mdi:thermometer' + 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: + if "current" in self._sensor: + if "fitness" in self._sensor: + self._state = self._usrobj.current_sleep_fitness_score + self._attr = self._usrobj.current_fitness_values + else: + 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'] + elif "bed_temp" in self._sensor: + temp = self._usrobj.current_values["bed_temp"] try: - if self._units == 'si': + if self._units == "si": self._state = round(temp, 2) else: - self._state = round((temp*1.8)+32, 2) + 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'] + elif "sleep_stage" in self._sensor: + self._state = self._usrobj.current_values["stage"] @property def device_state_attributes(self): @@ -182,56 +208,81 @@ def device_state_attributes(self): # 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'] + if "fitness" in self._sensor_root: + state_attr = { + ATTR_FIT_DATE: self._attr["date"], + ATTR_FIT_DURATION_SCORE: self._attr["duration"], + ATTR_FIT_ASLEEP_SCORE: self._attr["asleep"], + ATTR_FIT_OUT_SCORE: self._attr["out"], + ATTR_FIT_WAKEUP_SCORE: self._attr["wakeup"], + } + return state_attr + + 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) + 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) + 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) + 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) + if self._units == "si": + room_temp = round(self._attr["room_temp"], 2) else: - room_temp = round((self._attr['room_temp']*1.8)+32, 2) + 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) + if self._units == "si": + bed_temp = round(self._attr["bed_temp"], 2) else: - bed_temp = round((self._attr['bed_temp']*1.8)+32, 2) + 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'] + if "current" in self._sensor_root: + try: + state_attr[ATTR_RESP_RATE] = round(self._attr["resp_rate"], 2) + except TypeError: + state_attr[ATTR_RESP_RATE] = None + try: + state_attr[ATTR_HEART_RATE] = round(self._attr["heart_rate"], 2) + except TypeError: + state_attr[ATTR_HEART_RATE] = None + 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) + elif "last" in self._sensor_root: + try: + state_attr[ATTR_AVG_RESP_RATE] = round(self._attr["resp_rate"], 2) + except TypeError: + state_attr[ATTR_AVG_RESP_RATE] = None + try: + state_attr[ATTR_AVG_HEART_RATE] = round(self._attr["heart_rate"], 2) + except TypeError: + state_attr[ATTR_AVG_HEART_RATE] = None state_attr[ATTR_AVG_ROOM_TEMP] = room_temp state_attr[ATTR_AVG_BED_TEMP] = bed_temp @@ -247,7 +298,7 @@ def __init__(self, name, eight, sensor, units): self._sensor = sensor self._mapped_name = NAME_MAP.get(self._sensor, self._sensor) - self._name = '{} {}'.format(name, self._mapped_name) + self._name = f"{name} {self._mapped_name}" self._state = None self._attr = None self._units = units @@ -267,21 +318,21 @@ async def async_update(self): _LOGGER.debug("Updating Room sensor: %s", self._sensor) temp = self._eight.room_temperature() try: - if self._units == 'si': + if self._units == "si": self._state = round(temp, 2) else: - self._state = round((temp*1.8)+32, 2) + 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' + if self._units == "si": + return TEMP_CELSIUS + return TEMP_FAHRENHEIT @property def icon(self): """Icon to use in the frontend, if any.""" - return 'mdi:thermometer' + return "mdi:thermometer" diff --git a/homeassistant/components/eight_sleep/services.yaml b/homeassistant/components/eight_sleep/services.yaml index db7690730dd91..05354bccc685f 100644 --- a/homeassistant/components/eight_sleep/services.yaml +++ b/homeassistant/components/eight_sleep/services.yaml @@ -1,6 +1,12 @@ heat_set: - description: Set heating level for eight sleep. + description: Set heating/cooling 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} + duration: + description: Duration to heat/cool 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 cooling/heating level from -100 to 100. + example: 35 diff --git a/homeassistant/components/elgato/__init__.py b/homeassistant/components/elgato/__init__.py new file mode 100644 index 0000000000000..993748033b53a --- /dev/null +++ b/homeassistant/components/elgato/__init__.py @@ -0,0 +1,55 @@ +"""Support for Elgato Key Lights.""" +import logging + +from elgato import Elgato, ElgatoConnectionError + +from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.typing import ConfigType + +from .const import DATA_ELGATO_CLIENT, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Elgato Key Light components.""" + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Elgato Key Light from a config entry.""" + session = async_get_clientsession(hass) + elgato = Elgato(entry.data[CONF_HOST], port=entry.data[CONF_PORT], session=session,) + + # Ensure we can connect to it + try: + await elgato.info() + except ElgatoConnectionError as exception: + raise ConfigEntryNotReady from exception + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = {DATA_ELGATO_CLIENT: elgato} + + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, LIGHT_DOMAIN) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload Elgato Key Light config entry.""" + # Unload entities for this entry/device. + await hass.config_entries.async_forward_entry_unload(entry, LIGHT_DOMAIN) + + # Cleanup + del hass.data[DOMAIN][entry.entry_id] + if not hass.data[DOMAIN]: + del hass.data[DOMAIN] + + return True diff --git a/homeassistant/components/elgato/config_flow.py b/homeassistant/components/elgato/config_flow.py new file mode 100644 index 0000000000000..2f3e05fd72099 --- /dev/null +++ b/homeassistant/components/elgato/config_flow.py @@ -0,0 +1,136 @@ +"""Config flow to configure the Elgato Key Light integration.""" +import logging +from typing import Any, Dict, Optional + +from elgato import Elgato, ElgatoError, Info +import voluptuous as vol + +from homeassistant.config_entries import CONN_CLASS_LOCAL_POLL, ConfigFlow +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.typing import ConfigType + +from .const import CONF_SERIAL_NUMBER, DOMAIN # pylint: disable=unused-import + +_LOGGER = logging.getLogger(__name__) + + +class ElgatoFlowHandler(ConfigFlow, domain=DOMAIN): + """Handle a Elgato Key Light config flow.""" + + VERSION = 1 + CONNECTION_CLASS = CONN_CLASS_LOCAL_POLL + + async def async_step_user( + self, user_input: Optional[ConfigType] = None + ) -> Dict[str, Any]: + """Handle a flow initiated by the user.""" + if user_input is None: + return self._show_setup_form() + + try: + info = await self._get_elgato_info( + user_input[CONF_HOST], user_input[CONF_PORT] + ) + except ElgatoError: + return self._show_setup_form({"base": "connection_error"}) + + # Check if already configured + await self.async_set_unique_id(info.serial_number) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=info.serial_number, + data={ + CONF_HOST: user_input[CONF_HOST], + CONF_PORT: user_input[CONF_PORT], + CONF_SERIAL_NUMBER: info.serial_number, + }, + ) + + async def async_step_zeroconf( + self, user_input: Optional[ConfigType] = None + ) -> Dict[str, Any]: + """Handle zeroconf discovery.""" + if user_input is None: + return self.async_abort(reason="connection_error") + + # Hostname is format: my-ke.local. + host = user_input["hostname"].rstrip(".") + try: + info = await self._get_elgato_info(host, user_input[CONF_PORT]) + except ElgatoError: + return self.async_abort(reason="connection_error") + + # Check if already configured + await self.async_set_unique_id(info.serial_number) + self._abort_if_unique_id_configured() + + # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 + self.context.update( + { + CONF_HOST: host, + CONF_PORT: user_input[CONF_PORT], + CONF_SERIAL_NUMBER: info.serial_number, + "title_placeholders": {"serial_number": info.serial_number}, + } + ) + + # Prepare configuration flow + return self._show_confirm_dialog() + + # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 + async def async_step_zeroconf_confirm( + self, user_input: ConfigType = None + ) -> Dict[str, Any]: + """Handle a flow initiated by zeroconf.""" + if user_input is None: + return self._show_confirm_dialog() + + try: + info = await self._get_elgato_info( + self.context.get(CONF_HOST), self.context.get(CONF_PORT) + ) + except ElgatoError: + return self.async_abort(reason="connection_error") + + # Check if already configured + await self.async_set_unique_id(info.serial_number) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=self.context.get(CONF_SERIAL_NUMBER), + data={ + CONF_HOST: self.context.get(CONF_HOST), + CONF_PORT: self.context.get(CONF_PORT), + CONF_SERIAL_NUMBER: self.context.get(CONF_SERIAL_NUMBER), + }, + ) + + def _show_setup_form(self, errors: Optional[Dict] = None) -> Dict[str, Any]: + """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.Optional(CONF_PORT, default=9123): int, + } + ), + errors=errors or {}, + ) + + def _show_confirm_dialog(self) -> Dict[str, Any]: + """Show the confirm dialog to the user.""" + # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 + serial_number = self.context.get(CONF_SERIAL_NUMBER) + return self.async_show_form( + step_id="zeroconf_confirm", + description_placeholders={"serial_number": serial_number}, + ) + + async def _get_elgato_info(self, host: str, port: int) -> Info: + """Get device information from an Elgato Key Light device.""" + session = async_get_clientsession(self.hass) + elgato = Elgato(host, port=port, session=session,) + return await elgato.info() diff --git a/homeassistant/components/elgato/const.py b/homeassistant/components/elgato/const.py new file mode 100644 index 0000000000000..2b6caa37a8f9c --- /dev/null +++ b/homeassistant/components/elgato/const.py @@ -0,0 +1,17 @@ +"""Constants for the Elgato Key Light integration.""" + +# Integration domain +DOMAIN = "elgato" + +# Home Assistant data keys +DATA_ELGATO_CLIENT = "elgato_client" + +# Attributes +ATTR_IDENTIFIERS = "identifiers" +ATTR_MANUFACTURER = "manufacturer" +ATTR_MODEL = "model" +ATTR_ON = "on" +ATTR_SOFTWARE_VERSION = "sw_version" +ATTR_TEMPERATURE = "temperature" + +CONF_SERIAL_NUMBER = "serial_number" diff --git a/homeassistant/components/elgato/light.py b/homeassistant/components/elgato/light.py new file mode 100644 index 0000000000000..9dae0cd1f401d --- /dev/null +++ b/homeassistant/components/elgato/light.py @@ -0,0 +1,158 @@ +"""Support for LED lights.""" +from datetime import timedelta +import logging +from typing import Any, Callable, Dict, List, Optional + +from elgato import Elgato, ElgatoError, Info, State + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP, + SUPPORT_BRIGHTNESS, + SUPPORT_COLOR_TEMP, + LightEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_NAME +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.typing import HomeAssistantType + +from .const import ( + ATTR_IDENTIFIERS, + ATTR_MANUFACTURER, + ATTR_MODEL, + ATTR_ON, + ATTR_SOFTWARE_VERSION, + ATTR_TEMPERATURE, + DATA_ELGATO_CLIENT, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + +PARALLEL_UPDATES = 1 +SCAN_INTERVAL = timedelta(seconds=10) + + +async def async_setup_entry( + hass: HomeAssistantType, + entry: ConfigEntry, + async_add_entities: Callable[[List[Entity], bool], None], +) -> None: + """Set up Elgato Key Light based on a config entry.""" + elgato: Elgato = hass.data[DOMAIN][entry.entry_id][DATA_ELGATO_CLIENT] + info = await elgato.info() + async_add_entities([ElgatoLight(entry.entry_id, elgato, info)], True) + + +class ElgatoLight(LightEntity): + """Defines a Elgato Key Light.""" + + def __init__( + self, entry_id: str, elgato: Elgato, info: Info, + ): + """Initialize Elgato Key Light.""" + self._brightness: Optional[int] = None + self._info: Info = info + self._state: Optional[bool] = None + self._temperature: Optional[int] = None + self._available = True + self.elgato = elgato + + @property + def name(self) -> str: + """Return the name of the entity.""" + # Return the product name, if display name is not set + if not self._info.display_name: + return self._info.product_name + return self._info.display_name + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._available + + @property + def unique_id(self) -> str: + """Return the unique ID for this sensor.""" + return self._info.serial_number + + @property + def brightness(self) -> Optional[int]: + """Return the brightness of this light between 1..255.""" + return self._brightness + + @property + def color_temp(self): + """Return the CT color value in mireds.""" + return self._temperature + + @property + def min_mireds(self): + """Return the coldest color_temp that this light supports.""" + return 143 + + @property + def max_mireds(self): + """Return the warmest color_temp that this light supports.""" + return 344 + + @property + def supported_features(self) -> int: + """Flag supported features.""" + return SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP + + @property + def is_on(self) -> bool: + """Return the state of the light.""" + return bool(self._state) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the light.""" + await self.async_turn_on(on=False) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the light.""" + data = {} + + data[ATTR_ON] = True + if ATTR_ON in kwargs: + data[ATTR_ON] = kwargs[ATTR_ON] + + if ATTR_COLOR_TEMP in kwargs: + data[ATTR_TEMPERATURE] = kwargs[ATTR_COLOR_TEMP] + + if ATTR_BRIGHTNESS in kwargs: + data[ATTR_BRIGHTNESS] = round((kwargs[ATTR_BRIGHTNESS] / 255) * 100) + + try: + await self.elgato.light(**data) + except ElgatoError: + _LOGGER.error("An error occurred while updating the Elgato Key Light") + self._available = False + + async def async_update(self) -> None: + """Update Elgato entity.""" + try: + state: State = await self.elgato.state() + except ElgatoError: + if self._available: + _LOGGER.error("An error occurred while updating the Elgato Key Light") + self._available = False + return + + self._available = True + self._brightness = round((state.brightness * 255) / 100) + self._state = state.on + self._temperature = state.temperature + + @property + def device_info(self) -> Dict[str, Any]: + """Return device information about this Elgato Key Light.""" + return { + ATTR_IDENTIFIERS: {(DOMAIN, self._info.serial_number)}, + ATTR_NAME: self._info.product_name, + ATTR_MANUFACTURER: "Elgato", + ATTR_MODEL: self._info.product_name, + ATTR_SOFTWARE_VERSION: f"{self._info.firmware_version} ({self._info.firmware_build_number})", + } diff --git a/homeassistant/components/elgato/manifest.json b/homeassistant/components/elgato/manifest.json new file mode 100644 index 0000000000000..f1a92ec727f77 --- /dev/null +++ b/homeassistant/components/elgato/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "elgato", + "name": "Elgato Key Light", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/elgato", + "requirements": ["elgato==0.2.0"], + "zeroconf": ["_elg._tcp.local."], + "codeowners": ["@frenck"], + "quality_scale": "platinum" +} diff --git a/homeassistant/components/elgato/strings.json b/homeassistant/components/elgato/strings.json new file mode 100644 index 0000000000000..f2123731412b4 --- /dev/null +++ b/homeassistant/components/elgato/strings.json @@ -0,0 +1,22 @@ +{ + "config": { + "flow_title": "Elgato Key Light: {serial_number}", + "step": { + "user": { + "description": "Set up your Elgato Key Light to integrate with Home Assistant.", + "data": { "host": "Host or IP address", "port": "Port number" } + }, + "zeroconf_confirm": { + "description": "Do you want to add the Elgato Key Light with serial number `{serial_number}` to Home Assistant?", + "title": "Discovered Elgato Key Light device" + } + }, + "error": { + "connection_error": "Failed to connect to Elgato Key Light device." + }, + "abort": { + "already_configured": "This Elgato Key Light device is already configured.", + "connection_error": "Failed to connect to Elgato Key Light device." + } + } +} diff --git a/homeassistant/components/elgato/translations/ca.json b/homeassistant/components/elgato/translations/ca.json new file mode 100644 index 0000000000000..0a15bb125730a --- /dev/null +++ b/homeassistant/components/elgato/translations/ca.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "Aquest dispositiu Elgato Key Light ja est\u00e0 configurat.", + "connection_error": "No s'ha pogut connectar amb el dispositiu Elgato Key Light." + }, + "error": { + "connection_error": "No s'ha pogut connectar amb el dispositiu Elgato Key Light." + }, + "flow_title": "Elgato Key Light: {serial_number}", + "step": { + "user": { + "data": { + "host": "Amfitri\u00f3 o adre\u00e7a IP", + "port": "N\u00famero de port" + }, + "description": "Configura l'Elgato Key Light per integrar-lo amb Home Assistant.", + "title": "Enlla\u00e7 amb Elgato Key Light" + }, + "zeroconf_confirm": { + "description": "Vols afegir l'Elgato Key Light amb n\u00famero de s\u00e8rie `{serial_number}` a Home Assistant?", + "title": "Dispositiu Elgato Key Light descobert" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/elgato/translations/da.json b/homeassistant/components/elgato/translations/da.json new file mode 100644 index 0000000000000..99ae908db6b10 --- /dev/null +++ b/homeassistant/components/elgato/translations/da.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "Denne Elgato Key Light-enhed er allerede konfigureret.", + "connection_error": "Kunne ikke oprette forbindelse til Elgato Key Light-enheden." + }, + "error": { + "connection_error": "Kunne ikke oprette forbindelse til Elgato Key Light-enheden." + }, + "flow_title": "Elgato Key Light: {serial_number}", + "step": { + "user": { + "data": { + "host": "V\u00e6rt eller IP-adresse", + "port": "Portnummer" + }, + "description": "Indstil din Elgato Key Light til at integrere med Home Assistant.", + "title": "Forbind din Elgato Key Light" + }, + "zeroconf_confirm": { + "description": "Vil du tilf\u00f8je Elgato Key Light med serienummer `{serial_number}` til Home Assistant?", + "title": "Fandt Elgato Key Light-enhed" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/elgato/translations/de.json b/homeassistant/components/elgato/translations/de.json new file mode 100644 index 0000000000000..fcebb7aaa05cc --- /dev/null +++ b/homeassistant/components/elgato/translations/de.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "Dieses Elgato Key Light-Ger\u00e4t ist bereits konfiguriert.", + "connection_error": "Verbindung zum Elgato Key Light-Ger\u00e4t fehlgeschlagen." + }, + "error": { + "connection_error": "Verbindung zum Elgato Key Light-Ger\u00e4t fehlgeschlagen." + }, + "flow_title": "Elgato Key Light: {serial_number}", + "step": { + "user": { + "data": { + "host": "Host oder IP-Adresse", + "port": "Port-Nummer" + }, + "description": "Richten dein Elgato Key Light f\u00fcr die Integration mit Home Assistant ein.", + "title": "Verkn\u00fcpfe dein Elgato Key Light" + }, + "zeroconf_confirm": { + "description": "M\u00f6chtest du das Elgato Key Light mit der Seriennummer \"{serial_number} \" zu Home Assistant hinzuf\u00fcgen?", + "title": "Elgato Key Light Ger\u00e4t entdeckt" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/elgato/translations/en.json b/homeassistant/components/elgato/translations/en.json new file mode 100644 index 0000000000000..1a2ff6d97f0d9 --- /dev/null +++ b/homeassistant/components/elgato/translations/en.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "This Elgato Key Light device is already configured.", + "connection_error": "Failed to connect to Elgato Key Light device." + }, + "error": { + "connection_error": "Failed to connect to Elgato Key Light device." + }, + "flow_title": "Elgato Key Light: {serial_number}", + "step": { + "user": { + "data": { + "host": "Host or IP address", + "port": "Port number" + }, + "description": "Set up your Elgato Key Light to integrate with Home Assistant.", + "title": "Link your Elgato Key Light" + }, + "zeroconf_confirm": { + "description": "Do you want to add the Elgato Key Light with serial number `{serial_number}` to Home Assistant?", + "title": "Discovered Elgato Key Light device" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/elgato/translations/es-419.json b/homeassistant/components/elgato/translations/es-419.json new file mode 100644 index 0000000000000..46a008009b9fe --- /dev/null +++ b/homeassistant/components/elgato/translations/es-419.json @@ -0,0 +1,16 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "Host o direcci\u00f3n IP", + "port": "N\u00famero de puerto" + }, + "description": "Configure su Elgato Key Light para integrarse con Home Assistant." + }, + "zeroconf_confirm": { + "title": "Dispositivo Elgato Key Light descubierto" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/elgato/translations/es.json b/homeassistant/components/elgato/translations/es.json new file mode 100644 index 0000000000000..3860a09de92f8 --- /dev/null +++ b/homeassistant/components/elgato/translations/es.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "Este dispositivo Elgato Key Light ya est\u00e1 configurado.", + "connection_error": "No se pudo conectar al dispositivo Elgato Key Light." + }, + "error": { + "connection_error": "No se pudo conectar al dispositivo Elgato Key Light." + }, + "flow_title": "Elgato Key Light: {serial_number}", + "step": { + "user": { + "data": { + "host": "Host o direcci\u00f3n IP", + "port": "N\u00famero de puerto" + }, + "description": "Configura tu Elgato Key Light para integrarlo con Home Assistant.", + "title": "Conecte su Elgato Key Light" + }, + "zeroconf_confirm": { + "description": "\u00bfDesea agregar Elgato Key Light con el n\u00famero de serie `{serial_number}` a Home Assistant?", + "title": "Descubierto dispositivo Elgato Key Light" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/elgato/translations/fr.json b/homeassistant/components/elgato/translations/fr.json new file mode 100644 index 0000000000000..7eca88dc2bd6d --- /dev/null +++ b/homeassistant/components/elgato/translations/fr.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "Cet appareil Elgato Key Light est d\u00e9j\u00e0 configur\u00e9.", + "connection_error": "Impossible de se connecter au p\u00e9riph\u00e9rique Elgato Key Light." + }, + "error": { + "connection_error": "Impossible de se connecter au p\u00e9riph\u00e9rique Elgato Key Light." + }, + "flow_title": "Elgato Key Light: {serial_number}", + "step": { + "user": { + "data": { + "host": "H\u00f4te ou adresse IP", + "port": "Port" + }, + "description": "Configurez votre Elgato Key Light pour l'int\u00e9grer \u00e0 Home Assistant.", + "title": "Associez votre Elgato Key Light" + }, + "zeroconf_confirm": { + "description": "Voulez-vous ajouter l'Elgato Key Light avec le num\u00e9ro de s\u00e9rie `{serial_number}` \u00e0 Home Assistant?", + "title": "Appareil Elgato Key Light d\u00e9couvert" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/elgato/translations/hu.json b/homeassistant/components/elgato/translations/hu.json new file mode 100644 index 0000000000000..d3618d0039dd6 --- /dev/null +++ b/homeassistant/components/elgato/translations/hu.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "already_configured": "Ez az Elgato Key Light eszk\u00f6z m\u00e1r konfigur\u00e1lva van.", + "connection_error": "Nem siker\u00fclt csatlakozni az Elgato Key Light eszk\u00f6zh\u00f6z." + }, + "step": { + "user": { + "data": { + "host": "Hosztn\u00e9v vagy IP c\u00edm", + "port": "Portsz\u00e1m" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/elgato/translations/it.json b/homeassistant/components/elgato/translations/it.json new file mode 100644 index 0000000000000..6d9e860767636 --- /dev/null +++ b/homeassistant/components/elgato/translations/it.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "Questo dispositivo Elgato Key Light \u00e8 gi\u00e0 configurato.", + "connection_error": "Impossibile connettersi al dispositivo Elgato Key Light." + }, + "error": { + "connection_error": "Impossibile connettersi al dispositivo Elgato Key Light." + }, + "flow_title": "Elgato Key Light: {serial_number}", + "step": { + "user": { + "data": { + "host": "Host o indirizzo IP", + "port": "Numero porta" + }, + "description": "Configura Elgato Key Light per l'integrazione con Home Assistant.", + "title": "Collega il tuo Elgato Key Light" + }, + "zeroconf_confirm": { + "description": "Vuoi aggiungere il dispositivo Elgato Key Light con il numero di serie {serial_number} a Home Assistant?", + "title": "Dispositivo Elgato Key Light rilevato" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/elgato/translations/ko.json b/homeassistant/components/elgato/translations/ko.json new file mode 100644 index 0000000000000..05531c4501158 --- /dev/null +++ b/homeassistant/components/elgato/translations/ko.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "Elgato Key Light \uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", + "connection_error": "Elgato Key Light \uae30\uae30\uc5d0 \uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4." + }, + "error": { + "connection_error": "Elgato Key Light \uae30\uae30\uc5d0 \uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4." + }, + "flow_title": "Elgato Key Light: {serial_number}", + "step": { + "user": { + "data": { + "host": "\ud638\uc2a4\ud2b8 \ub610\ub294 IP \uc8fc\uc18c", + "port": "\ud3ec\ud2b8 \ubc88\ud638" + }, + "description": "Home Assistant \uc5d0 Elgato Key Light \ud1b5\ud569 \uad6c\uc131\uc694\uc18c\ub97c \uc124\uc815\ud569\ub2c8\ub2e4.", + "title": "Elgato Key Light \uc5f0\uacb0\ud558\uae30" + }, + "zeroconf_confirm": { + "description": "Elgato Key Light \uc2dc\ub9ac\uc5bc \ubc88\ud638 `{serial_number}` \uc744(\ub97c) Home Assistant \uc5d0 \ucd94\uac00\ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?", + "title": "\ubc1c\uacac\ub41c Elgato Key Light \uae30\uae30" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/elgato/translations/lb.json b/homeassistant/components/elgato/translations/lb.json new file mode 100644 index 0000000000000..7c2e3cf4e9fce --- /dev/null +++ b/homeassistant/components/elgato/translations/lb.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "D\u00ebsen Elgato Key Light Apparat ass scho konfigur\u00e9iert.", + "connection_error": "Feeler beim verbannen mam Elgato key Light Apparat." + }, + "error": { + "connection_error": "Feeler beim verbannen mam Elgato key Light Apparat." + }, + "flow_title": "Elgato Key Light: {serial_number}", + "step": { + "user": { + "data": { + "host": "Numm oder IP Adresse", + "port": "Port Nummer" + }, + "description": "\u00c4ren Elgator Key Light als Integratioun mam Home Assistant ariichten.", + "title": "\u00c4ren Elgato Key Light verbannen" + }, + "zeroconf_confirm": { + "description": "W\u00ebllt dir den Elgato Key Light mat der Seriennummer `{serial_number}` am Home Assistant dob\u00e4isetzen?", + "title": "Entdeckten Elgato Key Light Apparat" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/elgato/translations/lv.json b/homeassistant/components/elgato/translations/lv.json new file mode 100644 index 0000000000000..5babfa037ac61 --- /dev/null +++ b/homeassistant/components/elgato/translations/lv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "port": "Porta numurs" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/elgato/translations/nl.json b/homeassistant/components/elgato/translations/nl.json new file mode 100644 index 0000000000000..7f027b63f8dec --- /dev/null +++ b/homeassistant/components/elgato/translations/nl.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "Dit Elgato Key Light apparaat is al geconfigureerd.", + "connection_error": "Kan geen verbinding maken met het Elgato Key Light apparaat." + }, + "error": { + "connection_error": "Kan geen verbinding maken met het Elgato Key Light apparaat." + }, + "flow_title": "Elgato Key Light: {serial_number}", + "step": { + "user": { + "data": { + "host": "Hostnaam of IP-adres", + "port": "Poortnummer" + }, + "description": "Stel uw Elgato Key Light in om te integreren met Home Assistant.", + "title": "Koppel uw Elgato Key Light" + }, + "zeroconf_confirm": { + "description": "Wilt u de Elgato Key Light met serienummer ` {serial_number} ` toevoegen aan Home Assistant?", + "title": "Elgato Key Light apparaat ontdekt" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/elgato/translations/no.json b/homeassistant/components/elgato/translations/no.json new file mode 100644 index 0000000000000..34a9bfed772c8 --- /dev/null +++ b/homeassistant/components/elgato/translations/no.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "Denne Elgato Key Light-enheten er allerede konfigurert.", + "connection_error": "Kunne ikke koble til Elgato Key Light-enheten." + }, + "error": { + "connection_error": "Kunne ikke koble til Elgato Key Light-enheten." + }, + "flow_title": "", + "step": { + "user": { + "data": { + "host": "Vert eller IP-adresse", + "port": "Portnummer" + }, + "description": "Sett opp Elgato Key Light for \u00e5 integrere med Home Assistant.", + "title": "Linken ditt Elgato Key Light" + }, + "zeroconf_confirm": { + "description": "Vil du legge Elgato Key Light med serienummer ` {serial_number} til Home Assistant?", + "title": "Oppdaget Elgato Key Light-enheten" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/elgato/translations/pl.json b/homeassistant/components/elgato/translations/pl.json new file mode 100644 index 0000000000000..344c2c086ea44 --- /dev/null +++ b/homeassistant/components/elgato/translations/pl.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "To urz\u0105dzenie Elgato Key Light jest ju\u017c skonfigurowane.", + "connection_error": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia z urz\u0105dzeniem Elgato Key Light." + }, + "error": { + "connection_error": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia z urz\u0105dzeniem Elgato Key Light." + }, + "flow_title": "Elgato Key Light: {serial_number}", + "step": { + "user": { + "data": { + "host": "Nazwa hosta lub adres IP", + "port": "Port" + }, + "description": "Konfiguracja Elgato Key Light w celu integracji z Home Assistant'em.", + "title": "Po\u0142\u0105cz swoje Elgato Key Light" + }, + "zeroconf_confirm": { + "description": "Czy chcesz doda\u0107 urz\u0105dzenie Elgato Key Light o numerze seryjnym `{serial_number}` do Home Assistant'a?", + "title": "Wykryto urz\u0105dzenie Elgato Key Light" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/elgato/translations/pt-BR.json b/homeassistant/components/elgato/translations/pt-BR.json new file mode 100644 index 0000000000000..1684ce824331f --- /dev/null +++ b/homeassistant/components/elgato/translations/pt-BR.json @@ -0,0 +1,10 @@ +{ + "config": { + "step": { + "zeroconf_confirm": { + "description": "Deseja adicionar o Elgato Key Light n\u00famero de s\u00e9rie ` {serial_number} ` ao Home Assistant?", + "title": "Dispositivo Elgato Key Light descoberto" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/elgato/translations/pt.json b/homeassistant/components/elgato/translations/pt.json new file mode 100644 index 0000000000000..89c332cea25a3 --- /dev/null +++ b/homeassistant/components/elgato/translations/pt.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "Nome servidor ou endere\u00e7o IP", + "port": "N\u00famero da porta" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/elgato/translations/ru.json b/homeassistant/components/elgato/translations/ru.json new file mode 100644 index 0000000000000..a09a00b840c3e --- /dev/null +++ b/homeassistant/components/elgato/translations/ru.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u044d\u0442\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430.", + "connection_error": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443 Elgato Key Light." + }, + "error": { + "connection_error": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443 Elgato Key Light." + }, + "flow_title": "Elgato Key Light: {serial_number}", + "step": { + "user": { + "data": { + "host": "\u0414\u043e\u043c\u0435\u043d\u043d\u043e\u0435 \u0438\u043c\u044f \u0438\u043b\u0438 IP-\u0430\u0434\u0440\u0435\u0441", + "port": "\u041d\u043e\u043c\u0435\u0440 \u043f\u043e\u0440\u0442\u0430" + }, + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 Elgato Key Light \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 Home Assistant.", + "title": "Elgato Key Light" + }, + "zeroconf_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 Elgato Key Light \u0441 \u0441\u0435\u0440\u0438\u0439\u043d\u044b\u043c \u043d\u043e\u043c\u0435\u0440\u043e\u043c `{serial_number}`?", + "title": "\u041e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e Elgato Key Light" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/elgato/translations/sl.json b/homeassistant/components/elgato/translations/sl.json new file mode 100644 index 0000000000000..eac8bcdb295b4 --- /dev/null +++ b/homeassistant/components/elgato/translations/sl.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "Ta naprava Elgato Key Light je \u017ee nastavljena.", + "connection_error": "Povezava z napravo Elgato Key Light ni uspela." + }, + "error": { + "connection_error": "Povezava z napravo Elgato Key Light ni uspela." + }, + "flow_title": "Elgato Key Light: {serial_number}", + "step": { + "user": { + "data": { + "host": "Gostitelj ali IP naslov", + "port": "\u0160tevilka vrat" + }, + "description": "Nastavite svojo Elgato Key Light tako, da se bo vklju\u010dila v Home Assistant.", + "title": "Pove\u017eite svojo Elgato Key Light" + }, + "zeroconf_confirm": { + "description": "Ali \u017eelite dodati Elgato Key Light s serijsko \u0161tevilko ' {serial_number} ' v Home Assistant-a?", + "title": "Odkrita naprava Elgato Key Light" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/elgato/translations/sv.json b/homeassistant/components/elgato/translations/sv.json new file mode 100644 index 0000000000000..f2b3001ae1448 --- /dev/null +++ b/homeassistant/components/elgato/translations/sv.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "Den h\u00e4r Elgato Key Light-enheten \u00e4r redan konfigurerad.", + "connection_error": "Det gick inte att ansluta till Elgato Key Light-enheten." + }, + "error": { + "connection_error": "Det gick inte att ansluta till Elgato Key Light-enheten." + }, + "flow_title": "Elgato Key Light: {serial_number}", + "step": { + "user": { + "data": { + "host": "V\u00e4rd eller IP-adress", + "port": "Portnummer" + }, + "description": "St\u00e4ll in ditt Elgato Key Light f\u00f6r att integrera med Home Assistant.", + "title": "L\u00e4nk din Elgato Key Light" + }, + "zeroconf_confirm": { + "description": "Vill du l\u00e4gga till Elgato Key Light med serienummer `{serial_number}` till Home Assistant?", + "title": "Uppt\u00e4ckte Elgato Key Light-enhet" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/elgato/translations/zh-Hant.json b/homeassistant/components/elgato/translations/zh-Hant.json new file mode 100644 index 0000000000000..87fe1df06338d --- /dev/null +++ b/homeassistant/components/elgato/translations/zh-Hant.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "Elgato Key \u7167\u660e\u8a2d\u5099\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210\u3002", + "connection_error": "Elgato Key \u7167\u660e\u8a2d\u5099\u9023\u7dda\u5931\u6557\u3002" + }, + "error": { + "connection_error": "Elgato Key \u7167\u660e\u8a2d\u5099\u9023\u7dda\u5931\u6557\u3002" + }, + "flow_title": "Elgato Key \u7167\u660e\uff1a{serial_number}", + "step": { + "user": { + "data": { + "host": "\u4e3b\u6a5f\u6216 IP \u4f4d\u5740", + "port": "\u901a\u8a0a\u57e0" + }, + "description": "\u8a2d\u5b9a Elgato Key \u7167\u660e\u4ee5\u6574\u5408\u81f3 Home Assistant\u3002", + "title": "\u9023\u7d50 Elgato Key \u7167\u660e\u3002" + }, + "zeroconf_confirm": { + "description": "\u662f\u5426\u8981\u5c07 Elgato Key \u7167\u660e\u5e8f\u865f `{serial_number}` \u65b0\u589e\u81f3 Home Assistant\uff1f", + "title": "\u81ea\u52d5\u63a2\u7d22\u5230 Elgato Key \u7167\u660e\u8a2d\u5099" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/eliqonline/manifest.json b/homeassistant/components/eliqonline/manifest.json index 98d94fd009ea3..6860ff003c4d5 100644 --- a/homeassistant/components/eliqonline/manifest.json +++ b/homeassistant/components/eliqonline/manifest.json @@ -1,10 +1,7 @@ { "domain": "eliqonline", "name": "Eliqonline", - "documentation": "https://www.home-assistant.io/components/eliqonline", - "requirements": [ - "eliqonline==1.2.2" - ], - "dependencies": [], + "documentation": "https://www.home-assistant.io/integrations/eliqonline", + "requirements": ["eliqonline==1.2.2"], "codeowners": [] } diff --git a/homeassistant/components/eliqonline/sensor.py b/homeassistant/components/eliqonline/sensor.py index 03d6ad895913d..b3d56e42325e0 100644 --- a/homeassistant/components/eliqonline/sensor.py +++ b/homeassistant/components/eliqonline/sensor.py @@ -1,47 +1,46 @@ """Monitors home energy use for the ELIQ Online service.""" +import asyncio from datetime import timedelta import logging -import asyncio +import eliqonline 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.const import CONF_ACCESS_TOKEN, CONF_NAME, POWER_WATT 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__) -CONF_CHANNEL_ID = 'channel_id' +CONF_CHANNEL_ID = "channel_id" -DEFAULT_NAME = 'ELIQ Online' +DEFAULT_NAME = "ELIQ Online" -ICON = 'mdi:gauge' +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, -}) +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): +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) + api = eliqonline.API(session=session, access_token=access_token) try: _LOGGER.debug("Probing for access to ELIQ Online API") @@ -92,5 +91,4 @@ async def async_update(self): 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) + _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 index 564f0e74c750f..946e40b7e236d 100644 --- a/homeassistant/components/elkm1/__init__.py +++ b/homeassistant/components/elkm1/__init__.py @@ -1,170 +1,348 @@ """Support the ElkM1 Gold and ElkM1 EZ8 alarm/integration panels.""" +import asyncio import logging import re +import async_timeout +import elkm1_lib as elkm1 import voluptuous as vol + +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( - CONF_EXCLUDE, CONF_HOST, CONF_INCLUDE, CONF_PASSWORD, - CONF_TEMPERATURE_UNIT, CONF_USERNAME) -from homeassistant.core import HomeAssistant, callback # noqa + CONF_EXCLUDE, + CONF_HOST, + CONF_INCLUDE, + CONF_PASSWORD, + CONF_TEMPERATURE_UNIT, + CONF_USERNAME, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryNotReady 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' +from homeassistant.helpers.typing import ConfigType + +from .const import ( + BARE_TEMP_CELSIUS, + BARE_TEMP_FAHRENHEIT, + CONF_AREA, + CONF_AUTO_CONFIGURE, + CONF_COUNTER, + CONF_ENABLED, + CONF_KEYPAD, + CONF_OUTPUT, + CONF_PLC, + CONF_PREFIX, + CONF_SETTING, + CONF_TASK, + CONF_THERMOSTAT, + CONF_ZONE, + DOMAIN, + ELK_ELEMENTS, +) + +SYNC_TIMEOUT = 120 _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)) -}) +SERVICE_ALARM_DISPLAY_MESSAGE = "alarm_display_message" +SERVICE_ALARM_ARM_VACATION = "alarm_arm_vacation" +SERVICE_ALARM_ARM_HOME_INSTANT = "alarm_arm_home_instant" +SERVICE_ALARM_ARM_NIGHT_INSTANT = "alarm_arm_night_instant" + +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)), + vol.Optional("prefix", default=""): cv.string, + } +) def _host_validator(config): """Validate that a host is properly configured.""" - if config[CONF_HOST].startswith('elks://'): + 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://'): + 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()) + 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)) + 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('-')] + 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], -}) +def _has_all_unique_prefixes(value): + """Validate that each m1 configured has a unique prefix. -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) + Uniqueness is determined case-independently. + """ + prefixes = [device[CONF_PREFIX] for device in value] + schema = vol.Schema(vol.Unique()) + schema(prefixes) + return value + + +DEVICE_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], + } +) + +DEVICE_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PREFIX, default=""): vol.All(cv.string, vol.Lower), + vol.Optional(CONF_USERNAME, default=""): cv.string, + vol.Optional(CONF_PASSWORD, default=""): cv.string, + vol.Optional(CONF_AUTO_CONFIGURE, default=False): cv.boolean, + # cv.temperature_unit will mutate 'C' -> '°C' and 'F' -> '°F' + vol.Optional( + CONF_TEMPERATURE_UNIT, default=BARE_TEMP_FAHRENHEIT + ): cv.temperature_unit, + vol.Optional(CONF_AREA, default={}): DEVICE_SCHEMA_SUBDOMAIN, + vol.Optional(CONF_COUNTER, default={}): DEVICE_SCHEMA_SUBDOMAIN, + vol.Optional(CONF_KEYPAD, default={}): DEVICE_SCHEMA_SUBDOMAIN, + vol.Optional(CONF_OUTPUT, default={}): DEVICE_SCHEMA_SUBDOMAIN, + vol.Optional(CONF_PLC, default={}): DEVICE_SCHEMA_SUBDOMAIN, + vol.Optional(CONF_SETTING, default={}): DEVICE_SCHEMA_SUBDOMAIN, + vol.Optional(CONF_TASK, default={}): DEVICE_SCHEMA_SUBDOMAIN, + vol.Optional(CONF_THERMOSTAT, default={}): DEVICE_SCHEMA_SUBDOMAIN, + vol.Optional(CONF_ZONE, default={}): DEVICE_SCHEMA_SUBDOMAIN, + }, + _host_validator, +) + +CONFIG_SCHEMA = vol.Schema( + {DOMAIN: vol.All(cv.ensure_list, [DEVICE_SCHEMA], _has_all_unique_prefixes)}, + 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, - } + hass.data.setdefault(DOMAIN, {}) + _create_elk_services(hass) + + if DOMAIN not in hass_config: + return True + + for index, conf in enumerate(hass_config[DOMAIN]): + _LOGGER.debug("Importing elkm1 #%d - %s", index, conf[CONF_HOST]) + + # The update of the config entry is done in async_setup + # to ensure the entry if updated before async_setup_entry + # is called to avoid a situation where the user has to restart + # twice for the changes to take effect + current_config_entry = _async_find_matching_config_entry( + hass, conf[CONF_PREFIX] + ) + if current_config_entry: + # If they alter the yaml config we import the changes + # since there currently is no practical way to do an options flow + # with the large amount of include/exclude/enabled options that elkm1 has. + hass.config_entries.async_update_entry(current_config_entry, data=conf) + continue - 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]}) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=conf, + ) + ) + + return True + + +@callback +def _async_find_matching_config_entry(hass, prefix): + for entry in hass.config_entries.async_entries(DOMAIN): + if entry.unique_id == prefix: + return entry + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up Elk-M1 Control from a config entry.""" + + conf = entry.data + + _LOGGER.debug("Setting up elkm1 %s", conf["host"]) + + temperature_unit = TEMP_FAHRENHEIT + if conf[CONF_TEMPERATURE_UNIT] in (BARE_TEMP_CELSIUS, TEMP_CELSIUS): + temperature_unit = TEMP_CELSIUS + + config = {"temperature_unit": temperature_unit} + + if not conf[CONF_AUTO_CONFIGURE]: + # With elkm1-lib==0.7.16 and later auto configure is available + config["panel"] = {"enabled": True, "included": [True]} + for item, max_ in ELK_ELEMENTS.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) + if not await async_wait_for_elk_to_sync(elk, SYNC_TIMEOUT): + _LOGGER.error( + "Timed out after %d seconds while trying to sync with ElkM1 at %s", + SYNC_TIMEOUT, + conf[CONF_HOST], + ) + elk.disconnect() + raise ConfigEntryNotReady + + if elk.invalid_auth: + _LOGGER.error("Authentication failed for ElkM1") + return False + + hass.data[DOMAIN][entry.entry_id] = { + "elk": elk, + "prefix": conf[CONF_PREFIX], + "auto_configure": conf[CONF_AUTO_CONFIGURE], + "config": config, + "keypads": {}, + } - 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)) + hass.config_entries.async_forward_entry_setup(entry, component) + ) return True -def _create_elk_services(hass, elk): +def _included(ranges, set_to, values): + for rng in ranges: + if not rng[0] <= rng[1] <= len(values): + raise vol.Invalid(f"Invalid range {rng}") + values[rng[0] - 1 : rng[1]] = [set_to] * (rng[1] - rng[0] + 1) + + +def _find_elk_by_prefix(hass, prefix): + """Search all config entries for a given prefix.""" + for entry_id in hass.data[DOMAIN]: + if hass.data[DOMAIN][entry_id]["prefix"] == prefix: + return hass.data[DOMAIN][entry_id]["elk"] + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in SUPPORTED_DOMAINS + ] + ) + ) + + # disconnect cleanly + hass.data[DOMAIN][entry.entry_id]["elk"].disconnect() + + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +async def async_wait_for_elk_to_sync(elk, timeout): + """Wait until the elk system has finished sync.""" + try: + with async_timeout.timeout(timeout): + await elk.sync_complete() + return True + except asyncio.TimeoutError: + elk.disconnect() + + return False + + +def _create_elk_services(hass): def _speak_word_service(service): - elk.panel.speak_word(service.data.get('number')) + prefix = service.data["prefix"] + elk = _find_elk_by_prefix(hass, prefix) + if elk is None: + _LOGGER.error("No elk m1 with prefix for speak_word: '%s'", prefix) + return + elk.panel.speak_word(service.data["number"]) def _speak_phrase_service(service): - elk.panel.speak_phrase(service.data.get('number')) + prefix = service.data["prefix"] + elk = _find_elk_by_prefix(hass, prefix) + if elk is None: + _LOGGER.error("No elk m1 with prefix for speak_phrase: '%s'", prefix) + return + elk.panel.speak_phrase(service.data["number"]) hass.services.async_register( - DOMAIN, 'speak_word', _speak_word_service, SPEAK_SERVICE_SCHEMA) + DOMAIN, "speak_word", _speak_word_service, SPEAK_SERVICE_SCHEMA + ) hass.services.async_register( - DOMAIN, 'speak_phrase', _speak_phrase_service, SPEAK_SERVICE_SCHEMA) + DOMAIN, "speak_phrase", _speak_phrase_service, SPEAK_SERVICE_SCHEMA + ) -def create_elk_entities(hass, elk_elements, element_type, class_, entities): +def create_elk_entities(elk_data, 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)) + auto_configure = elk_data["auto_configure"] + + if not auto_configure and not elk_data["config"][element_type]["enabled"]: + return + + elk = elk_data["elk"] + _LOGGER.debug("Creating elk entities for %s", elk) + + for element in elk_elements: + if auto_configure: + if not element.configured: + continue + # Only check the included list if auto configure is not + elif not elk_data["config"][element_type]["included"][element.index]: + continue + + entities.append(class_(element, elk, elk_data)) return entities @@ -175,14 +353,26 @@ 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()) + self._prefix = elk_data["prefix"] + self._temperature_unit = elk_data["config"]["temperature_unit"] + # unique_id starts with elkm1_ iff there is no prefix + # it starts with elkm1m_{prefix} iff there is a prefix + # this is to avoid a conflict between + # prefix=foo, name=bar (which would be elkm1_foo_bar) + # - and - + # prefix="", name="foo bar" (which would be elkm1_foo_bar also) + # we could have used elkm1__foo_bar for the latter, but that + # would have been a breaking change + if self._prefix != "": + uid_start = f"elkm1m_{self._prefix}" + else: + uid_start = "elkm1" + self._unique_id = f"{uid_start}_{self._element.default_name('_')}".lower() @property def name(self): """Name of the element.""" - return self._element.name + return f"{self._prefix}{self._element.name}" @property def unique_id(self): @@ -207,7 +397,7 @@ def available(self): def initial_attrs(self): """Return the underlying element's attributes as a dict.""" attrs = {} - attrs['index'] = self._element.index + 1 + attrs["index"] = self._element.index + 1 return attrs def _element_changed(self, element, changeset): @@ -217,9 +407,34 @@ def _element_changed(self, element, changeset): 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) + self.async_write_ha_state() 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, {}) + + @property + def device_info(self): + """Device info connecting via the ElkM1 system.""" + return { + "via_device": (DOMAIN, f"{self._prefix}_system"), + } + + +class ElkAttachedEntity(ElkEntity): + """An elk entity that is attached to the elk system.""" + + @property + def device_info(self): + """Device info for the underlying ElkM1 system.""" + device_name = "ElkM1" + if self._prefix: + device_name += f" {self._prefix}" + return { + "name": device_name, + "identifiers": {(DOMAIN, f"{self._prefix}_system")}, + "sw_version": self._elk.panel.elkm1_version, + "manufacturer": "ELK Products, Inc.", + "model": "M1", + } diff --git a/homeassistant/components/elkm1/alarm_control_panel.py b/homeassistant/components/elkm1/alarm_control_panel.py index b885913a0df04..3e9ab11483786 100644 --- a/homeassistant/components/elkm1/alarm_control_panel.py +++ b/homeassistant/components/elkm1/alarm_control_panel.py @@ -1,89 +1,125 @@ """Each ElkM1 area will be created as a separate alarm_control_panel.""" +import logging + +from elkm1_lib.const import AlarmState, ArmedStatus, ArmLevel, ArmUpState +from elkm1_lib.util import username import voluptuous as vol -import homeassistant.components.alarm_control_panel as alarm +from homeassistant.components.alarm_control_panel import ( + ATTR_CHANGED_BY, + FORMAT_NUMBER, + AlarmControlPanelEntity, +) +from homeassistant.components.alarm_control_panel.const import ( + SUPPORT_ALARM_ARM_AWAY, + SUPPORT_ALARM_ARM_HOME, + SUPPORT_ALARM_ARM_NIGHT, +) 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) + 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, +) +from homeassistant.helpers import entity_platform 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)), -}) +from homeassistant.helpers.restore_state import RestoreEntity + +from . import ( + SERVICE_ALARM_ARM_HOME_INSTANT, + SERVICE_ALARM_ARM_NIGHT_INSTANT, + SERVICE_ALARM_ARM_VACATION, + SERVICE_ALARM_DISPLAY_MESSAGE, + ElkAttachedEntity, + create_elk_entities, +) +from .const import ( + ATTR_CHANGED_BY_ID, + ATTR_CHANGED_BY_KEYPAD, + ATTR_CHANGED_BY_TIME, + DOMAIN, +) + +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.All(vol.Coerce(int), vol.In([0, 1, 2])), + vol.Optional("beep", default=False): cv.boolean, + vol.Optional("timeout", default=0): vol.All( + vol.Coerce(int), vol.Range(min=0, max=65535) + ), + vol.Optional("line1", default=""): cv.string, + vol.Optional("line2", default=""): cv.string, + } +) -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, -}) +_LOGGER = logging.getLogger(__name__) -async def async_setup_platform(hass, config, async_add_entities, - discovery_info=None): +async def async_setup_entry(hass, config_entry, async_add_entities): """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, []) + elk_data = hass.data[DOMAIN][config_entry.entry_id] + entities = [] + + elk = elk_data["elk"] + areas_with_keypad = set() + for keypad in elk.keypads: + areas_with_keypad.add(keypad.area) + + areas = [] + for area in elk.areas: + if area.index in areas_with_keypad or elk_data["auto_configure"] is False: + areas.append(area) + create_elk_entities(elk_data, areas, "area", ElkArea, entities) 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): + platform = entity_platform.current_platform.get() + + platform.async_register_entity_service( + SERVICE_ALARM_ARM_VACATION, + ELK_ALARM_SERVICE_SCHEMA, + "async_alarm_arm_vacation", + ) + platform.async_register_entity_service( + SERVICE_ALARM_ARM_HOME_INSTANT, + ELK_ALARM_SERVICE_SCHEMA, + "async_alarm_arm_home_instant", + ) + platform.async_register_entity_service( + SERVICE_ALARM_ARM_NIGHT_INSTANT, + ELK_ALARM_SERVICE_SCHEMA, + "async_alarm_arm_night_instant", + ) + platform.async_register_entity_service( + SERVICE_ALARM_DISPLAY_MESSAGE, + DISPLAY_MESSAGE_SERVICE_SCHEMA, + "async_display_message", + ) + + +class ElkArea(ElkAttachedEntity, AlarmControlPanelEntity, RestoreEntity): """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._elk = elk + self._changed_by_keypad = None + self._changed_by_time = None + self._changed_by_id = None + self._changed_by = None self._state = None async def async_added_to_hass(self): @@ -91,54 +127,71 @@ async def async_added_to_hass(self): 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) + + # We do not get changed_by back from resync. + last_state = await self.async_get_last_state() + if not last_state: + return + + if ATTR_CHANGED_BY_KEYPAD in last_state.attributes: + self._changed_by_keypad = last_state.attributes[ATTR_CHANGED_BY_KEYPAD] + if ATTR_CHANGED_BY_TIME in last_state.attributes: + self._changed_by_time = last_state.attributes[ATTR_CHANGED_BY_TIME] + if ATTR_CHANGED_BY_ID in last_state.attributes: + self._changed_by_id = last_state.attributes[ATTR_CHANGED_BY_ID] + if ATTR_CHANGED_BY in last_state.attributes: + self._changed_by = last_state.attributes[ATTR_CHANGED_BY] 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) + if changeset.get("last_user") is not None: + self._changed_by_keypad = keypad.name + self._changed_by_time = keypad.last_user_time.isoformat() + self._changed_by_id = keypad.last_user + 1 + self._changed_by = username(self._elk, keypad.last_user) + self.async_write_ha_state() @property def code_format(self): """Return the alarm code format.""" - return alarm.FORMAT_NUMBER + return FORMAT_NUMBER @property def state(self): """Return the state of the element.""" return self._state + @property + def supported_features(self) -> int: + """Return the list of supported features.""" + return SUPPORT_ALARM_ARM_HOME | SUPPORT_ALARM_ARM_AWAY | SUPPORT_ALARM_ARM_NIGHT + @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 + 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() + 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() + 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 + attrs["alarm_state"] = AlarmState(elmt.alarm_state).name.lower() + attrs[ATTR_CHANGED_BY_KEYPAD] = self._changed_by_keypad + attrs[ATTR_CHANGED_BY_TIME] = self._changed_by_time + attrs[ATTR_CHANGED_BY_ID] = self._changed_by_id return attrs - def _element_changed(self, element, changeset): - from elkm1_lib.const import ArmedStatus + @property + def changed_by(self): + """Last change triggered by.""" + return self._changed_by + def _element_changed(self, element, changeset): elk_state_to_hass_state = { ArmedStatus.DISARMED.value: STATE_ALARM_DISARMED, ArmedStatus.ARMED_AWAY.value: STATE_ALARM_ARMED_AWAY, @@ -154,8 +207,9 @@ def _element_changed(self, element, changeset): 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 + 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] @@ -163,8 +217,6 @@ 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): @@ -173,25 +225,28 @@ async def async_alarm_disarm(self, code=None): 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 async_alarm_arm_home_instant(self, code=None): + """Send arm stay instant command.""" + self._element.arm(ArmLevel.ARMED_STAY_INSTANT.value, int(code)) + + async def async_alarm_arm_night_instant(self, code=None): + """Send arm night instant command.""" + self._element.arm(ArmLevel.ARMED_NIGHT_INSTANT.value, int(code)) + + async def async_alarm_arm_vacation(self, code=None): + """Send arm vacation command.""" + self._element.arm(ArmLevel.ARMED_VACATION.value, int(code)) - async def _display_message(self, clear, beep, timeout, line1, line2): + async def async_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 index 23c1831286310..6d10df45adfd7 100644 --- a/homeassistant/components/elkm1/climate.py +++ b/homeassistant/components/elkm1/climate.py @@ -1,27 +1,45 @@ """Support for control of Elk-M1 connected thermostats.""" -from homeassistant.components.climate import ClimateDevice +from elkm1_lib.const import ThermostatFan, ThermostatMode, ThermostatSetting + +from homeassistant.components.climate import ClimateEntity 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) + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + HVAC_MODE_AUTO, + HVAC_MODE_COOL, + HVAC_MODE_FAN_ONLY, + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + SUPPORT_AUX_HEAT, + SUPPORT_FAN_MODE, + SUPPORT_TARGET_TEMPERATURE_RANGE, +) from homeassistant.const import PRECISION_WHOLE, STATE_ON -from . import DOMAIN as ELK_DOMAIN, ElkEntity, create_elk_entities +from . import ElkEntity, create_elk_entities +from .const import DOMAIN +SUPPORT_HVAC = [ + HVAC_MODE_OFF, + HVAC_MODE_HEAT, + HVAC_MODE_COOL, + HVAC_MODE_AUTO, + HVAC_MODE_FAN_ONLY, +] -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) +async def async_setup_entry(hass, config_entry, async_add_entities): + """Create the Elk-M1 thermostat platform.""" + elk_data = hass.data[DOMAIN][config_entry.entry_id] + entities = [] + elk = elk_data["elk"] + create_elk_entities( + elk_data, elk.thermostats, "thermostat", ElkThermostat, entities + ) + async_add_entities(entities, True) -class ElkThermostat(ElkEntity, ClimateDevice): +class ElkThermostat(ElkEntity, ClimateEntity): """Representation of an Elk-M1 Thermostat.""" def __init__(self, element, elk, elk_data): @@ -32,9 +50,7 @@ def __init__(self, element, elk, elk_data): @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) + return SUPPORT_FAN_MODE | SUPPORT_AUX_HEAT | SUPPORT_TARGET_TEMPERATURE_RANGE @property def temperature_unit(self): @@ -49,9 +65,9 @@ def current_temperature(self): @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): + self._element.mode == ThermostatMode.EMERGENCY_HEAT.value + ): return self._element.heat_setpoint if self._element.mode == ThermostatMode.COOL.value: return self._element.cool_setpoint @@ -78,14 +94,14 @@ def current_humidity(self): return self._element.humidity @property - def current_operation(self): + def hvac_mode(self): """Return current operation ie. heat, cool, idle.""" return self._state @property - def operation_list(self): + def hvac_modes(self): """Return the list of available operation modes.""" - return [STATE_IDLE, STATE_HEAT, STATE_COOL, STATE_AUTO, STATE_FAN_ONLY] + return SUPPORT_HVAC @property def precision(self): @@ -93,9 +109,8 @@ def precision(self): return PRECISION_WHOLE @property - def is_aux_heat_on(self): + def is_aux_heat(self): """Return if aux heater is on.""" - from elkm1_lib.const import ThermostatMode return self._element.mode == ThermostatMode.EMERGENCY_HEAT.value @property @@ -109,79 +124,68 @@ def max_temp(self): return 99 @property - def current_fan_mode(self): + def fan_mode(self): """Return the fan setting.""" - from elkm1_lib.const import ThermostatFan if self._element.fan == ThermostatFan.AUTO.value: - return STATE_AUTO + return HVAC_MODE_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): + async def async_set_hvac_mode(self, hvac_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) + HVAC_MODE_OFF: (ThermostatMode.OFF.value, ThermostatFan.AUTO.value), + HVAC_MODE_HEAT: (ThermostatMode.HEAT.value, None), + HVAC_MODE_COOL: (ThermostatMode.COOL.value, None), + HVAC_MODE_AUTO: (ThermostatMode.AUTO.value, None), + HVAC_MODE_FAN_ONLY: (ThermostatMode.OFF.value, ThermostatFan.ON.value), } - self._elk_set(settings[operation_mode][0], settings[operation_mode][1]) + self._elk_set(settings[hvac_mode][0], settings[hvac_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): + def fan_modes(self): """Return the list of available fan modes.""" - return [STATE_AUTO, STATE_ON] + return [HVAC_MODE_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: + if fan_mode == HVAC_MODE_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)) + 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)) + 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, + ThermostatMode.OFF.value: HVAC_MODE_OFF, + ThermostatMode.COOL.value: HVAC_MODE_COOL, + ThermostatMode.HEAT.value: HVAC_MODE_HEAT, + ThermostatMode.EMERGENCY_HEAT.value: HVAC_MODE_HEAT, + ThermostatMode.AUTO.value: HVAC_MODE_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 + if self._state == HVAC_MODE_OFF and self._element.fan == ThermostatFan.ON.value: + self._state = HVAC_MODE_FAN_ONLY diff --git a/homeassistant/components/elkm1/config_flow.py b/homeassistant/components/elkm1/config_flow.py new file mode 100644 index 0000000000000..419e4df755217 --- /dev/null +++ b/homeassistant/components/elkm1/config_flow.py @@ -0,0 +1,169 @@ +"""Config flow for Elk-M1 Control integration.""" +import logging +from urllib.parse import urlparse + +import elkm1_lib as elkm1 +import voluptuous as vol + +from homeassistant import config_entries, exceptions +from homeassistant.const import ( + CONF_ADDRESS, + CONF_HOST, + CONF_PASSWORD, + CONF_PROTOCOL, + CONF_TEMPERATURE_UNIT, + CONF_USERNAME, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, +) +from homeassistant.util import slugify + +from . import async_wait_for_elk_to_sync +from .const import CONF_AUTO_CONFIGURE, CONF_PREFIX +from .const import DOMAIN # pylint:disable=unused-import + +_LOGGER = logging.getLogger(__name__) + +PROTOCOL_MAP = {"secure": "elks://", "non-secure": "elk://", "serial": "serial://"} + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_PROTOCOL, default="secure"): vol.In( + ["secure", "non-secure", "serial"] + ), + vol.Required(CONF_ADDRESS): str, + vol.Optional(CONF_USERNAME, default=""): str, + vol.Optional(CONF_PASSWORD, default=""): str, + vol.Optional(CONF_PREFIX, default=""): str, + vol.Optional(CONF_TEMPERATURE_UNIT, default=TEMP_FAHRENHEIT): vol.In( + [TEMP_FAHRENHEIT, TEMP_CELSIUS] + ), + } +) + +VALIDATE_TIMEOUT = 35 + + +async def validate_input(data): + """Validate the user input allows us to connect. + + Data has the keys from DATA_SCHEMA with values provided by the user. + """ + + userid = data.get(CONF_USERNAME) + password = data.get(CONF_PASSWORD) + + prefix = data[CONF_PREFIX] + url = _make_url_from_data(data) + requires_password = url.startswith("elks://") + + if requires_password and (not userid or not password): + raise InvalidAuth + + elk = elkm1.Elk( + {"url": url, "userid": userid, "password": password, "element_list": ["panel"]} + ) + elk.connect() + + timed_out = False + if not await async_wait_for_elk_to_sync(elk, VALIDATE_TIMEOUT): + _LOGGER.error( + "Timed out after %d seconds while trying to sync with ElkM1 at %s", + VALIDATE_TIMEOUT, + url, + ) + timed_out = True + + elk.disconnect() + + if timed_out: + raise CannotConnect + if elk.invalid_auth: + raise InvalidAuth + + device_name = data[CONF_PREFIX] if data[CONF_PREFIX] else "ElkM1" + # Return info that you want to store in the config entry. + return {"title": device_name, CONF_HOST: url, CONF_PREFIX: slugify(prefix)} + + +def _make_url_from_data(data): + host = data.get(CONF_HOST) + if host: + return host + + protocol = PROTOCOL_MAP[data[CONF_PROTOCOL]] + address = data[CONF_ADDRESS] + return f"{protocol}{address}" + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Elk-M1 Control.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH + + def __init__(self): + """Initialize the elkm1 config flow.""" + self.importing = False + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + if user_input is not None: + if self._url_already_configured(_make_url_from_data(user_input)): + return self.async_abort(reason="address_already_configured") + + try: + info = await validate_input(user_input) + + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + if "base" not in errors: + await self.async_set_unique_id(user_input[CONF_PREFIX]) + self._abort_if_unique_id_configured() + + if self.importing: + return self.async_create_entry(title=info["title"], data=user_input) + + return self.async_create_entry( + title=info["title"], + data={ + CONF_HOST: info[CONF_HOST], + CONF_USERNAME: user_input[CONF_USERNAME], + CONF_PASSWORD: user_input[CONF_PASSWORD], + CONF_AUTO_CONFIGURE: True, + CONF_TEMPERATURE_UNIT: user_input[CONF_TEMPERATURE_UNIT], + CONF_PREFIX: info[CONF_PREFIX], + }, + ) + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + async def async_step_import(self, user_input): + """Handle import.""" + self.importing = True + return await self.async_step_user(user_input) + + def _url_already_configured(self, url): + """See if we already have a elkm1 matching user input configured.""" + existing_hosts = { + urlparse(entry.data[CONF_HOST]).hostname + for entry in self._async_current_entries() + } + return urlparse(url).hostname in existing_hosts + + +class CannotConnect(exceptions.HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(exceptions.HomeAssistantError): + """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/elkm1/const.py b/homeassistant/components/elkm1/const.py new file mode 100644 index 0000000000000..27b6445a4c10c --- /dev/null +++ b/homeassistant/components/elkm1/const.py @@ -0,0 +1,39 @@ +"""Support the ElkM1 Gold and ElkM1 EZ8 alarm/integration panels.""" + +from elkm1_lib.const import Max + +DOMAIN = "elkm1" + +CONF_AUTO_CONFIGURE = "auto_configure" +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" +CONF_PREFIX = "prefix" + + +BARE_TEMP_FAHRENHEIT = "F" +BARE_TEMP_CELSIUS = "C" + +ELK_ELEMENTS = { + 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, +} + + +ATTR_CHANGED_BY_KEYPAD = "changed_by_keypad" +ATTR_CHANGED_BY_ID = "changed_by_id" +ATTR_CHANGED_BY_TIME = "changed_by_time" diff --git a/homeassistant/components/elkm1/light.py b/homeassistant/components/elkm1/light.py index ee6fe09a7a23f..19a09d13975cc 100644 --- a/homeassistant/components/elkm1/light.py +++ b/homeassistant/components/elkm1/light.py @@ -1,21 +1,25 @@ """Support for control of ElkM1 lighting (X10, UPB, etc).""" + from homeassistant.components.light import ( - ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, Light) + ATTR_BRIGHTNESS, + SUPPORT_BRIGHTNESS, + LightEntity, +) -from . import DOMAIN as ELK_DOMAIN, ElkEntity, create_elk_entities +from . import ElkEntity, create_elk_entities +from .const import DOMAIN -async def async_setup_platform( - hass, config, async_add_entities, discovery_info=None): +async def async_setup_entry(hass, config_entry, async_add_entities): """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) + elk_data = hass.data[DOMAIN][config_entry.entry_id] + entities = [] + elk = elk_data["elk"] + create_elk_entities(elk_data, elk.lights, "plc", ElkLight, entities) + async_add_entities(entities, True) -class ElkLight(ElkEntity, Light): +class ElkLight(ElkEntity, LightEntity): """Representation of an Elk lighting device.""" def __init__(self, element, elk, elk_data): diff --git a/homeassistant/components/elkm1/manifest.json b/homeassistant/components/elkm1/manifest.json index 73b48623260bf..5a88792208aed 100644 --- a/homeassistant/components/elkm1/manifest.json +++ b/homeassistant/components/elkm1/manifest.json @@ -1,10 +1,8 @@ { "domain": "elkm1", - "name": "Elkm1", - "documentation": "https://www.home-assistant.io/components/elkm1", - "requirements": [ - "elkm1-lib==0.7.13" - ], - "dependencies": [], - "codeowners": [] + "name": "Elk-M1 Control", + "documentation": "https://www.home-assistant.io/integrations/elkm1", + "requirements": ["elkm1-lib==0.7.17"], + "codeowners": ["@bdraco"], + "config_flow": true } diff --git a/homeassistant/components/elkm1/scene.py b/homeassistant/components/elkm1/scene.py index aaae8bb0a5cf4..208779b99d03e 100644 --- a/homeassistant/components/elkm1/scene.py +++ b/homeassistant/components/elkm1/scene.py @@ -1,22 +1,24 @@ """Support for control of ElkM1 tasks ("macros").""" +from typing import Any + from homeassistant.components.scene import Scene -from . import DOMAIN as ELK_DOMAIN, ElkEntity, create_elk_entities +from . import ElkAttachedEntity, create_elk_entities +from .const import DOMAIN -async def async_setup_platform( - hass, config, async_add_entities, discovery_info=None): +async def async_setup_entry(hass, config_entry, async_add_entities): """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, []) + elk_data = hass.data[DOMAIN][config_entry.entry_id] + entities = [] + elk = elk_data["elk"] + create_elk_entities(elk_data, elk.tasks, "task", ElkTask, entities) async_add_entities(entities, True) -class ElkTask(ElkEntity, Scene): +class ElkTask(ElkAttachedEntity, Scene): """Elk-M1 task as scene.""" - async def async_activate(self): + async def async_activate(self, **kwargs: Any) -> None: """Activate the task.""" self._element.activate() diff --git a/homeassistant/components/elkm1/sensor.py b/homeassistant/components/elkm1/sensor.py index 0e36726560510..20efb3e956d27 100644 --- a/homeassistant/components/elkm1/sensor.py +++ b/homeassistant/components/elkm1/sensor.py @@ -1,24 +1,30 @@ """Support for control of ElkM1 sensors.""" -from . import DOMAIN as ELK_DOMAIN, ElkEntity, create_elk_entities +from elkm1_lib.const import ( + SettingFormat, + ZoneLogicalStatus, + ZonePhysicalStatus, + ZoneType, +) +from elkm1_lib.util import pretty_const, username +from homeassistant.const import VOLT -async def async_setup_platform( - hass, config, async_add_entities, discovery_info=None): +from . import ElkAttachedEntity, create_elk_entities +from .const import DOMAIN + +UNDEFINED_TEMPATURE = -40 + + +async def async_setup_entry(hass, config_entry, async_add_entities): """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) + elk_data = hass.data[DOMAIN][config_entry.entry_id] + entities = [] + elk = elk_data["elk"] + create_elk_entities(elk_data, elk.counters, "counter", ElkCounter, entities) + create_elk_entities(elk_data, elk.keypads, "keypad", ElkKeypad, entities) + create_elk_entities(elk_data, [elk.panel], "panel", ElkPanel, entities) + create_elk_entities(elk_data, elk.settings, "setting", ElkSetting, entities) + create_elk_entities(elk_data, elk.zones, "zone", ElkZone, entities) async_add_entities(entities, True) @@ -27,7 +33,7 @@ def temperature_to_state(temperature, undefined_temperature): return temperature if temperature > undefined_temperature else None -class ElkSensor(ElkEntity): +class ElkSensor(ElkAttachedEntity): """Base representation of Elk-M1 sensor.""" def __init__(self, element, elk, elk_data): @@ -47,7 +53,7 @@ class ElkCounter(ElkSensor): @property def icon(self): """Icon to use in the frontend.""" - return 'mdi:numeric' + return "mdi:numeric" def _element_changed(self, element, changeset): self._state = self._element.value @@ -69,31 +75,25 @@ def unit_of_measurement(self): @property def icon(self): """Icon to use in the frontend.""" - return 'mdi:thermometer-lines' + 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 + attrs["area"] = self._element.area + 1 + attrs["temperature"] = self._state + 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 + self._state = temperature_to_state( + self._element.temperature, UNDEFINED_TEMPATURE + ) class ElkPanel(ElkSensor): @@ -108,15 +108,16 @@ def icon(self): def device_state_attributes(self): """Attributes of the sensor.""" attrs = self.initial_attrs() - attrs['system_trouble_status'] = self._element.system_trouble_status + 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' + self._state = ( + "Paused" if self._element.remote_programming_status else "Connected" + ) else: - self._state = 'Disconnected' + self._state = "Disconnected" class ElkSetting(ElkSensor): @@ -125,7 +126,7 @@ class ElkSetting(ElkSensor): @property def icon(self): """Icon to use in the frontend.""" - return 'mdi:numeric' + return "mdi:numeric" def _element_changed(self, element, changeset): self._state = self._element.value @@ -133,10 +134,8 @@ def _element_changed(self, element, changeset): @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() + attrs["value_format"] = SettingFormat(self._element.value_format).name.lower() return attrs @@ -146,53 +145,48 @@ class ElkZone(ElkSensor): @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' + 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')) + return f"mdi:{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 + 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 @@ -200,21 +194,20 @@ def temperature_unit(self): @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 VOLT 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) + self._state = temperature_to_state( + self._element.temperature, UNDEFINED_TEMPATURE + ) elif self._element.definition == ZoneType.ANALOG_ZONE.value: self._state = self._element.voltage else: - self._state = pretty_const(ZoneLogicalStatus( - self._element.logical_status).name) + self._state = pretty_const( + ZoneLogicalStatus(self._element.logical_status).name + ) diff --git a/homeassistant/components/elkm1/services.yaml b/homeassistant/components/elkm1/services.yaml index 405716569630e..beb4427d14c74 100644 --- a/homeassistant/components/elkm1/services.yaml +++ b/homeassistant/components/elkm1/services.yaml @@ -1,12 +1,65 @@ -speak_word: - description: Speak a word. See list of words in ElkM1 ASCII Protocol documentation. +alarm_arm_home_instant: + description: Arm the ElkM1 in home instant mode. fields: - number: - description: Word number to speak. - example: 142 + 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 + +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 + +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 + +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. + speak_phrase: description: Speak a phrase. See list of phrases in ElkM1 ASCII Protocol documentation. fields: number: description: Phrase number to speak. example: 42 + +speak_word: + description: Speak a word. See list of words in ElkM1 ASCII Protocol documentation. + fields: + number: + description: Word number to speak. + example: 142 diff --git a/homeassistant/components/elkm1/strings.json b/homeassistant/components/elkm1/strings.json new file mode 100644 index 0000000000000..be7d0aa1d7408 --- /dev/null +++ b/homeassistant/components/elkm1/strings.json @@ -0,0 +1,27 @@ +{ + "config": { + "step": { + "user": { + "title": "Connect to Elk-M1 Control", + "description": "The address string must be in the form 'address[:port]' for 'secure' and 'non-secure'. Example: '192.168.1.1'. The port is optional and defaults to 2101 for 'non-secure' and 2601 for 'secure'. For the serial protocol, the address must be in the form 'tty[:baud]'. Example: '/dev/ttyS1'. The baud is optional and defaults to 115200.", + "data": { + "protocol": "Protocol", + "address": "The IP address or domain or serial port if connecting via serial.", + "username": "Username (secure only).", + "password": "Password (secure only).", + "prefix": "A unique prefix (leave blank if you only have one ElkM1).", + "temperature_unit": "The temperature unit ElkM1 uses." + } + } + }, + "error": { + "cannot_connect": "Failed to connect, please try again", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "abort": { + "already_configured": "An ElkM1 with this prefix is already configured", + "address_already_configured": "An ElkM1 with this address is already configured" + } + } +} diff --git a/homeassistant/components/elkm1/switch.py b/homeassistant/components/elkm1/switch.py index df29491435e2c..d9eb59737b622 100644 --- a/homeassistant/components/elkm1/switch.py +++ b/homeassistant/components/elkm1/switch.py @@ -1,20 +1,20 @@ """Support for control of ElkM1 outputs (relays).""" -from homeassistant.components.switch import SwitchDevice +from homeassistant.components.switch import SwitchEntity -from . import DOMAIN as ELK_DOMAIN, ElkEntity, create_elk_entities +from . import ElkAttachedEntity, create_elk_entities +from .const import DOMAIN -async def async_setup_platform( - hass, config, async_add_entities, discovery_info=None): +async def async_setup_entry(hass, config_entry, async_add_entities): """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, []) + elk_data = hass.data[DOMAIN][config_entry.entry_id] + entities = [] + elk = elk_data["elk"] + create_elk_entities(elk_data, elk.outputs, "output", ElkOutput, entities) async_add_entities(entities, True) -class ElkOutput(ElkEntity, SwitchDevice): +class ElkOutput(ElkAttachedEntity, SwitchEntity): """Elk output as switch.""" @property diff --git a/homeassistant/components/elkm1/translations/ca.json b/homeassistant/components/elkm1/translations/ca.json new file mode 100644 index 0000000000000..73ca29cdca04c --- /dev/null +++ b/homeassistant/components/elkm1/translations/ca.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "address_already_configured": "Ja hi ha un Elk-M1 configurat amb aquesta adre\u00e7a", + "already_configured": "Ja hi ha un Elk-M1 configurat amb aquest prefix" + }, + "error": { + "cannot_connect": "No s'ha pogut connectar, torna-ho a provar", + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", + "unknown": "Error inesperat" + }, + "step": { + "user": { + "data": { + "address": "Adre\u00e7a IP, domini o port s\u00e8rie (si es est\u00e0 connectat amb una connexi\u00f3 s\u00e8rie).", + "password": "Contrasenya (nom\u00e9s segur).", + "prefix": "Prefix \u00fanic (deixa-ho en blanc si nom\u00e9s tens un \u00fanic controlador Elk-M1).", + "protocol": "Protocol", + "temperature_unit": "Unitats de temperatura que utilitza l'Elk-M1.", + "username": "Nom d'usuari (nom\u00e9s segur)." + }, + "description": "La cadena de car\u00e0cters (string) de l'adre\u00e7a ha de tenir el format: 'adre\u00e7a[:port]' tant per al mode 'segur' com el 'no segur'. Exemple: '192.168.1.1'. El port \u00e9s opcional, per defecte \u00e9s el 2101 pel mode 'no segur' i el 2601 pel 'segur'. Per al protocol s\u00e8rie, l'adre\u00e7a ha de tenir el format 'tty[:baud]'. Exemple: '/dev/ttyS1'. La velocitat en bauds \u00e9s opcional (115200 per defecte).", + "title": "Connexi\u00f3 amb el controlador Elk-M1" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/elkm1/translations/de.json b/homeassistant/components/elkm1/translations/de.json new file mode 100644 index 0000000000000..1574af9fa6c67 --- /dev/null +++ b/homeassistant/components/elkm1/translations/de.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "address_already_configured": "Ein ElkM1 mit dieser Adresse ist bereits konfiguriert", + "already_configured": "Ein ElkM1 mit diesem Pr\u00e4fix ist bereits konfiguriert" + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen, versuchen Sie es erneut", + "invalid_auth": "Ung\u00fcltige Authentifizierung", + "unknown": "Unerwarteter Fehler" + }, + "step": { + "user": { + "data": { + "address": "Die IP-Adresse, die Domain oder der serielle Port bei einer seriellen Verbindung.", + "password": "Passwort (Nur sicher).", + "prefix": "Ein eindeutiges Pr\u00e4fix (leer lassen, wenn Sie nur einen ElkM1 haben).", + "protocol": "Protokoll", + "temperature_unit": "Die von ElkM1 verwendete Temperatureinheit.", + "username": "Benutzername (Nur sicher)." + }, + "description": "Die Adresszeichenfolge muss in der Form 'adresse[:port]' f\u00fcr 'sicher' und 'nicht sicher' vorliegen. Beispiel: '192.168.1.1'. Der Port ist optional und standardm\u00e4\u00dfig 2101 f\u00fcr \"nicht sicher\" und 2601 f\u00fcr \"sicher\". F\u00fcr das serielle Protokoll muss die Adresse die Form 'tty[:baud]' haben. Beispiel: '/dev/ttyS1'. Der Baudrate ist optional und standardm\u00e4\u00dfig 115200.", + "title": "Stellen Sie eine Verbindung zur Elk-M1-Steuerung her" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/elkm1/translations/en.json b/homeassistant/components/elkm1/translations/en.json new file mode 100644 index 0000000000000..784a9feb642da --- /dev/null +++ b/homeassistant/components/elkm1/translations/en.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "address_already_configured": "An ElkM1 with this address is already configured", + "already_configured": "An ElkM1 with this prefix is already configured" + }, + "error": { + "cannot_connect": "Failed to connect, please try again", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "address": "The IP address or domain or serial port if connecting via serial.", + "password": "Password (secure only).", + "prefix": "A unique prefix (leave blank if you only have one ElkM1).", + "protocol": "Protocol", + "temperature_unit": "The temperature unit ElkM1 uses.", + "username": "Username (secure only)." + }, + "description": "The address string must be in the form 'address[:port]' for 'secure' and 'non-secure'. Example: '192.168.1.1'. The port is optional and defaults to 2101 for 'non-secure' and 2601 for 'secure'. For the serial protocol, the address must be in the form 'tty[:baud]'. Example: '/dev/ttyS1'. The baud is optional and defaults to 115200.", + "title": "Connect to Elk-M1 Control" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/elkm1/translations/es.json b/homeassistant/components/elkm1/translations/es.json new file mode 100644 index 0000000000000..9dc4f4839b66f --- /dev/null +++ b/homeassistant/components/elkm1/translations/es.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "address_already_configured": "Ya est\u00e1 configurado un Elk-M1 con esta direcci\u00f3n", + "already_configured": "Ya est\u00e1 configurado un Elk-M1 con este prefijo" + }, + "error": { + "cannot_connect": "No se pudo conectar, por favor, int\u00e9ntalo de nuevo", + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", + "unknown": "Error inesperado" + }, + "step": { + "user": { + "data": { + "address": "La direcci\u00f3n IP o dominio o puerto serie si se conecta a trav\u00e9s de serie.", + "password": "Contrase\u00f1a (s\u00f3lo seguro)", + "prefix": "Un prefijo \u00fanico (d\u00e9jalo en blanco si s\u00f3lo tienes un Elk-M1).", + "protocol": "Protocolo", + "temperature_unit": "La temperatura que usa la unidad Elk-M1", + "username": "Usuario (s\u00f3lo seguro)" + }, + "description": "La cadena de direcci\u00f3n debe estar en el formato 'direcci\u00f3n[:puerto]' para 'seguro' y 'no-seguro'. Ejemplo: '192.168.1.1'. El puerto es opcional y el valor predeterminado es 2101 para 'no-seguro' y 2601 para 'seguro'. Para el protocolo serie, la direcci\u00f3n debe tener la forma 'tty[:baudios]'. Ejemplo: '/dev/ttyS1'. Los baudios son opcionales y el valor predeterminado es 115200.", + "title": "Conectar con Control Elk-M1" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/elkm1/translations/fr.json b/homeassistant/components/elkm1/translations/fr.json new file mode 100644 index 0000000000000..27eac52143044 --- /dev/null +++ b/homeassistant/components/elkm1/translations/fr.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "address_already_configured": "Un ElkM1 avec cette adresse est d\u00e9j\u00e0 configur\u00e9", + "already_configured": "Un ElkM1 avec ce pr\u00e9fixe est d\u00e9j\u00e0 configur\u00e9" + }, + "error": { + "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer", + "invalid_auth": "Authentification non valide", + "unknown": "Erreur inattendue" + }, + "step": { + "user": { + "data": { + "address": "L'adresse IP ou le domaine ou le port s\u00e9rie si vous vous connectez via s\u00e9rie.", + "password": "Mot de passe (s\u00e9curis\u00e9 uniquement).", + "protocol": "Protocole", + "username": "Nom d'utilisateur (s\u00e9curis\u00e9 uniquement)." + }, + "title": "Se connecter a Elk-M1 Control" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/elkm1/translations/it.json b/homeassistant/components/elkm1/translations/it.json new file mode 100644 index 0000000000000..c9f3f0e187634 --- /dev/null +++ b/homeassistant/components/elkm1/translations/it.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "address_already_configured": "Un ElkM1 con questo indirizzo \u00e8 gi\u00e0 configurato", + "already_configured": "Un ElkM1 con questo prefisso \u00e8 gi\u00e0 configurato" + }, + "error": { + "cannot_connect": "Impossibile connettersi, si prega di riprovare", + "invalid_auth": "Autenticazione non valida", + "unknown": "Errore imprevisto" + }, + "step": { + "user": { + "data": { + "address": "L'indirizzo IP o il dominio o la porta seriale se ci si connette tramite seriale.", + "password": "Password (solo sicura).", + "prefix": "Un prefisso univoco (lasciare vuoto se si dispone di un solo ElkM1).", + "protocol": "Protocollo", + "temperature_unit": "L'unit\u00e0 di temperatura utilizzata da ElkM1.", + "username": "Nome utente (solo sicuro)." + }, + "description": "La stringa di indirizzi deve essere nella forma \"address[:port]\" per \"secure\" e \"non secure\". Esempio: '192.168.1.1.1'. La porta \u00e8 facoltativa e il valore predefinito \u00e8 2101 per 'non sicuro' e 2601 per 'sicuro'. Per il protocollo seriale, l'indirizzo deve essere nella forma 'tty[:baud]'. Esempio: '/dev/ttyS1'. Il baud \u00e8 opzionale e il valore predefinito \u00e8 115200.", + "title": "Collegamento al controllo Elk-M1" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/elkm1/translations/ko.json b/homeassistant/components/elkm1/translations/ko.json new file mode 100644 index 0000000000000..96837972007e2 --- /dev/null +++ b/homeassistant/components/elkm1/translations/ko.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "address_already_configured": "\uc774 \uc8fc\uc18c\ub85c ElkM1 \uc774 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "already_configured": "\uc774 \uc811\ub450\uc0ac\ub85c ElkM1 \uc774 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, + "error": { + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.", + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" + }, + "step": { + "user": { + "data": { + "address": "\uc2dc\ub9ac\uc5bc\uc744 \ud1b5\ud574 \uc5f0\uacb0\ud558\ub294 \uacbd\uc6b0\uc758 IP \uc8fc\uc18c \ub098 \ub3c4\uba54\uc778 \ub610\ub294 \uc2dc\ub9ac\uc5bc \ud3ec\ud2b8", + "password": "\ube44\ubc00\ubc88\ud638 (\ubcf4\uc548 \uc804\uc6a9).", + "prefix": "\uace0\uc720\ud55c \uc811\ub450\uc0ac (ElkM1 \uc774 \ud558\ub098\ub9cc \uc788\uc73c\uba74 \ube44\uc6cc\ub450\uc138\uc694).", + "protocol": "\ud504\ub85c\ud1a0\ucf5c", + "temperature_unit": "ElkM1 \uc774 \uc0ac\uc6a9\ud558\ub294 \uc628\ub3c4 \ub2e8\uc704", + "username": "\uc0ac\uc6a9\uc790 \uc774\ub984 (\ubcf4\uc548 \uc804\uc6a9)." + }, + "description": "\uc8fc\uc18c \ubb38\uc790\uc5f4\uc740 '\ubcf4\uc548' \ubc0f '\ube44\ubcf4\uc548' \uc758 \uacbd\uc6b0 'address[:port]' \ud615\uc2dd\uc774\uc5b4\uc57c \ud569\ub2c8\ub2e4. \uc608: '192.168.1.1'. \ud3ec\ud2b8\ub294 \uc120\ud0dd \uc0ac\ud56d\uc774\uba70 \uae30\ubcf8\uac12\uc740 '\ube44\ubcf4\uc548' \uc758 \uacbd\uc6b0 2101 \uc774\uace0 '\ubcf4\uc548' \uc758 \uacbd\uc6b0 2601 \uc785\ub2c8\ub2e4. \uc2dc\ub9ac\uc5bc \ud504\ub85c\ud1a0\ucf5c\uc758 \uacbd\uc6b0 \uc8fc\uc18c\ub294 'tty[:baud]' \ud615\uc2dd\uc774\uc5b4\uc57c \ud569\ub2c8\ub2e4. \uc608: '/dev/ttyS1'. \ud1b5\uc2e0\uc18d\ub3c4 \ubc14\uc6b0\ub4dc\ub294 \uc120\ud0dd \uc0ac\ud56d\uc774\uba70 \uae30\ubcf8\uac12\uc740 115200 \uc785\ub2c8\ub2e4.", + "title": "Elk-M1 \uc81c\uc5b4\uc5d0 \uc5f0\uacb0\ud558\uae30" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/elkm1/translations/lb.json b/homeassistant/components/elkm1/translations/lb.json new file mode 100644 index 0000000000000..6e1af6353f895 --- /dev/null +++ b/homeassistant/components/elkm1/translations/lb.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "address_already_configured": "Een ElkM1 mat d\u00ebser Adress ass scho konfigur\u00e9iert", + "already_configured": "Een ElkM1 mat d\u00ebsem Prefix ass scho konfigur\u00e9iert" + }, + "error": { + "cannot_connect": "Feeler beim verbannen, prob\u00e9ier w.e.g. nach emol.", + "invalid_auth": "Ong\u00eblteg Authentifikatioun", + "unknown": "Onerwaarte Feeler" + }, + "step": { + "user": { + "data": { + "address": "IP Adress oder Domain oder Serielle Port falls d'Verbindung seriell ass.", + "password": "Passwuert (n\u00ebmmen ges\u00e9chert)", + "prefix": "Een eenzegaartege Pr\u00e4fix (eidel lossen wann et n\u00ebmmen 1 ElkM1 g\u00ebtt)", + "protocol": "Protokoll", + "temperature_unit": "Temperatur Eenheet d\u00e9i den ElkM1 benotzt.", + "username": "Benotzernumm (n\u00ebmmen ges\u00e9chert)" + }, + "description": "D'Adress muss an der Form 'adress[:port]' fir 'ges\u00e9chert' an 'onges\u00e9chert' sinn. Beispill: '192.168.1.1'. De Port os optionell an ass standardm\u00e9isseg op 2101 fir 'onges\u00e9chert' an op 2601 fir 'ges\u00e9chert' d\u00e9fin\u00e9iert. Fir de serielle Protokoll, muss d'Adress an der Form 'tty[:baud]' sinn. Beispill: '/dev/ttyS1'. Baud Rate ass optionell an ass standardmlisseg op 115200 d\u00e9fin\u00e9iert.", + "title": "Mat Elk-M1 Control verbannen" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/elkm1/translations/nl.json b/homeassistant/components/elkm1/translations/nl.json new file mode 100644 index 0000000000000..9e7adf71c4b14 --- /dev/null +++ b/homeassistant/components/elkm1/translations/nl.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "address_already_configured": "Een ElkM1 met dit adres is al geconfigureerd", + "already_configured": "Een ElkM1 met dit voorvoegsel is al geconfigureerd" + }, + "error": { + "cannot_connect": "Verbinding mislukt, probeer het opnieuw", + "invalid_auth": "Ongeldige authenticatie", + "unknown": "Onverwachte fout" + }, + "step": { + "user": { + "data": { + "address": "Het IP-adres of domein of seri\u00eble poort bij verbinding via serieel.", + "password": "Wachtwoord (alleen beveiligd).", + "prefix": "Een uniek voorvoegsel (laat dit leeg als u maar \u00e9\u00e9n ElkM1 heeft).", + "protocol": "Protocol", + "temperature_unit": "De temperatuureenheid die ElkM1 gebruikt.", + "username": "Gebruikersnaam (alleen beveiligd)." + }, + "description": "De adresreeks moet de vorm 'adres [: poort]' hebben voor 'veilig' en 'niet-beveiligd'. Voorbeeld: '192.168.1.1'. De poort is optioneel en is standaard 2101 voor 'niet beveiligd' en 2601 voor 'beveiligd'. Voor het seri\u00eble protocol moet het adres de vorm 'tty [: baud]' hebben. Voorbeeld: '/ dev / ttyS1'. De baud is optioneel en is standaard ingesteld op 115200.", + "title": "Maak verbinding met Elk-M1 Control" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/elkm1/translations/no.json b/homeassistant/components/elkm1/translations/no.json new file mode 100644 index 0000000000000..6870ca4926d15 --- /dev/null +++ b/homeassistant/components/elkm1/translations/no.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "address_already_configured": "En ElkM1 med denne adressen er allerede konfigurert", + "already_configured": "En ElkM1 med dette prefikset er allerede konfigurert" + }, + "error": { + "cannot_connect": "Klarte ikke \u00e5 koble til, vennligst pr\u00f8v igjen", + "invalid_auth": "Ugyldig godkjenning", + "unknown": "Uventet feil" + }, + "step": { + "user": { + "data": { + "address": "IP-adressen eller domenet eller seriell port hvis du kobler til via seriell.", + "password": "Passord (bare sikkert).", + "prefix": "Et unikt prefiks (la v\u00e6re tomt hvis du bare har en ElkM1).", + "protocol": "protokoll", + "temperature_unit": "Temperaturenheten ElkM1 bruker.", + "username": "Brukernavn (bare sikkert)." + }, + "description": "Adressestrengen m\u00e5 v\u00e6re i formen 'adresse [: port]' for 'sikker' og 'ikke-sikker'. Eksempel: '192.168.1.1'. Porten er valgfri og er standard til 2101 for 'ikke-sikker' og 2601 for 'sikker'. For den serielle protokollen m\u00e5 adressen v\u00e6re i formen 'tty [: baud]'. Eksempel: '/ dev / ttyS1'. Baud er valgfri og er standard til 115200.", + "title": "Koble til Elk-M1-kontroll" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/elkm1/translations/pl.json b/homeassistant/components/elkm1/translations/pl.json new file mode 100644 index 0000000000000..3a445b1008ce9 --- /dev/null +++ b/homeassistant/components/elkm1/translations/pl.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "address_already_configured": "ElkM1 z tym adresem jest ju\u017c skonfigurowany.", + "already_configured": "ElkM1 z tym prefiksem jest ju\u017c skonfigurowany." + }, + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia, spr\u00f3buj ponownie.", + "invalid_auth": "Niepoprawne uwierzytelnienie.", + "unknown": "Niespodziewany b\u0142\u0105d." + }, + "step": { + "user": { + "data": { + "address": "Adres IP, domena lub port szeregowy w przypadku po\u0142\u0105czenia szeregowego.", + "password": "Has\u0142o (tylko bezpieczne).", + "prefix": "Unikatowy prefiks (pozostaw pusty, je\u015bli masz tylko jeden ElkM1).", + "protocol": "Protok\u00f3\u0142", + "temperature_unit": "Jednostka temperatury u\u017cywanej przez ElkM1.", + "username": "Nazwa u\u017cytkownika (tylko bezpieczne)" + }, + "description": "Adres musi by\u0107 w postaci 'adres[:port]' dla tryb\u00f3w 'zabezpieczony' i 'niezabezpieczony'. Przyk\u0142ad: '192.168.1.1'. Port jest opcjonalny i domy\u015blnie ustawiony na 2101 dla po\u0142\u0105cze\u0144 'niezabezpieczonych' i 2601 dla 'zabezpieczonych'. W przypadku protoko\u0142u szeregowego adres musi by\u0107 w formie 'tty[:baudrate]'. Przyk\u0142ad: '/dev/ttyS1'. Warto\u015b\u0107 transmisji jest opcjonalna i domy\u015blnie wynosi 115200.", + "title": "Pod\u0142\u0105czenie do sterownika Elk-M1" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/elkm1/translations/pt.json b/homeassistant/components/elkm1/translations/pt.json new file mode 100644 index 0000000000000..83e574aa2e231 --- /dev/null +++ b/homeassistant/components/elkm1/translations/pt.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "Palavra-passe (segura apenas)", + "username": "Nome de utilizador (apenas seguro)." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/elkm1/translations/ru.json b/homeassistant/components/elkm1/translations/ru.json new file mode 100644 index 0000000000000..45877e16992aa --- /dev/null +++ b/homeassistant/components/elkm1/translations/ru.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "address_already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0441 \u044d\u0442\u0438\u043c \u0430\u0434\u0440\u0435\u0441\u043e\u043c \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430.", + "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0441 \u044d\u0442\u0438\u043c \u043f\u0440\u0435\u0444\u0438\u043a\u0441\u043e\u043c \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430." + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f, \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0435\u0449\u0435 \u0440\u0430\u0437.", + "invalid_auth": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "step": { + "user": { + "data": { + "address": "IP-\u0430\u0434\u0440\u0435\u0441, \u0434\u043e\u043c\u0435\u043d\u043d\u043e\u0435 \u0438\u043c\u044f \u0438\u043b\u0438 \u043f\u043e\u0441\u043b\u0435\u0434\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u043d\u044b\u0439 \u043f\u043e\u0440\u0442.", + "password": "\u041f\u0430\u0440\u043e\u043b\u044c (\u0442\u043e\u043b\u044c\u043a\u043e \u0434\u043b\u044f \u043f\u0440\u043e\u0442\u043e\u043a\u043e\u043b\u0430 'secure')", + "prefix": "\u0423\u043d\u0438\u043a\u0430\u043b\u044c\u043d\u044b\u0439 \u043f\u0440\u0435\u0444\u0438\u043a\u0441 (\u043e\u0441\u0442\u0430\u0432\u044c\u0442\u0435 \u043f\u0443\u0441\u0442\u044b\u043c, \u0435\u0441\u043b\u0438 \u0443 \u0412\u0430\u0441 \u0442\u043e\u043b\u044c\u043a\u043e \u043e\u0434\u0438\u043d ElkM1).", + "protocol": "\u041f\u0440\u043e\u0442\u043e\u043a\u043e\u043b", + "temperature_unit": "\u0415\u0434\u0438\u043d\u0438\u0446\u0430 \u0438\u0437\u043c\u0435\u0440\u0435\u043d\u0438\u044f \u0442\u0435\u043c\u043f\u0435\u0440\u0430\u0442\u0443\u0440\u044b", + "username": "\u041b\u043e\u0433\u0438\u043d (\u0442\u043e\u043b\u044c\u043a\u043e \u0434\u043b\u044f \u043f\u0440\u043e\u0442\u043e\u043a\u043e\u043b\u0430 'secure')" + }, + "description": "\u0421\u0442\u0440\u043e\u043a\u0430 IP-\u0430\u0434\u0440\u0435\u0441\u0430 \u0434\u043e\u043b\u0436\u043d\u0430 \u0431\u044b\u0442\u044c \u0432 \u0444\u043e\u0440\u043c\u0430\u0442\u0435 'addres[:port]' (\u043d\u0430\u043f\u0440\u0438\u043c\u0435\u0440: '192.168.1.1'). \u041f\u0430\u0440\u0430\u043c\u0435\u0442\u0440 'port' \u043d\u0435 \u044f\u0432\u043b\u044f\u0435\u0442\u0441\u044f \u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u044b\u043c \u0438 \u043f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e 2101 \u0434\u043b\u044f \u043f\u0440\u043e\u0442\u043e\u043a\u043e\u043b\u0430 'non-secure' \u0438 2601 \u0434\u043b\u044f \u043f\u0440\u043e\u0442\u043e\u043a\u043e\u043b\u0430 'secure'. \u0414\u043b\u044f \u043f\u0440\u043e\u0442\u043e\u043a\u043e\u043b\u0430 'serial' \u0430\u0434\u0440\u0435\u0441 \u0434\u043e\u043b\u0436\u0435\u043d \u0431\u044b\u0442\u044c \u0432 \u0444\u043e\u0440\u043c\u0430\u0442\u0435 'tty[:baud]' (\u043d\u0430\u043f\u0440\u0438\u043c\u0435\u0440: '/dev/ttyS1'). \u041f\u0430\u0440\u0430\u043c\u0435\u0442\u0440 'baud' \u043d\u0435 \u044f\u0432\u043b\u044f\u0435\u0442\u0441\u044f \u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u044b\u043c \u0438 \u043f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e \u0440\u0430\u0432\u0435\u043d 115200.", + "title": "Elk-M1 Control" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/elkm1/translations/sl.json b/homeassistant/components/elkm1/translations/sl.json new file mode 100644 index 0000000000000..a815011988eb8 --- /dev/null +++ b/homeassistant/components/elkm1/translations/sl.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "address_already_configured": "ElkM1 s tem naslovom je \u017ee konfiguriran", + "already_configured": "ElkM1 s to predpono je \u017ee konfiguriran" + }, + "error": { + "cannot_connect": "Povezava ni uspela, poskusite znova", + "invalid_auth": "Neveljavna avtentikacija", + "unknown": "Nepri\u010dakovana napaka" + }, + "step": { + "user": { + "data": { + "address": "IP naslov, domena ali serijska vrata, \u010de se povezujete prek serijske povezave.", + "password": "Geslo (samo varno).", + "prefix": "Edinstvena predpona (pustite prazno, \u010de imate samo en ElkM1).", + "protocol": "Protokol", + "temperature_unit": "Temperaturna enota, ki jo uporablja ElkM1.", + "username": "Uporabni\u0161ko ime (samo varno)." + }, + "description": "Naslov mora biti v obliki \"naslov[:port]\" za \"varno\" in \"ne-varno'. Primer: '192.168.1.1'. Vrata so neobvezna in so privzeto nastavljena na 2101 za \"non-secure\" in 2601 za 'varno'. Za serijski protokol, mora biti naslov v obliki \" tty[:baud]'. Primer: '/dev/ttyS1'. Baud je neobvezen in privzeto nastavljen na 115200.", + "title": "Pove\u017eite se z Elk-M1 Control" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/elkm1/translations/sv.json b/homeassistant/components/elkm1/translations/sv.json new file mode 100644 index 0000000000000..23a7d475a6fa5 --- /dev/null +++ b/homeassistant/components/elkm1/translations/sv.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "cannot_connect": "Det gick inte att ansluta, f\u00f6rs\u00f6k igen", + "invalid_auth": "Ogiltig autentisering", + "unknown": "Ov\u00e4ntat fel" + }, + "step": { + "user": { + "data": { + "protocol": "Protokoll" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/elkm1/translations/zh-Hant.json b/homeassistant/components/elkm1/translations/zh-Hant.json new file mode 100644 index 0000000000000..7362ec42e934a --- /dev/null +++ b/homeassistant/components/elkm1/translations/zh-Hant.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "address_already_configured": "\u4f7f\u7528\u6b64\u4f4d\u5740\u7684\u4e00\u7d44 ElkM1 \u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "already_configured": "\u4f7f\u7528\u6b64 Prefix \u7684\u4e00\u7d44 ElkM1 \u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557\uff0c\u8acb\u518d\u8a66\u4e00\u6b21", + "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "step": { + "user": { + "data": { + "address": "IP \u6216\u7db2\u57df\u540d\u7a31\u3001\u5e8f\u5217\u57e0\uff08\u5047\u5982\u900f\u904e\u5e8f\u5217\u9023\u7dda\uff09\u3002", + "password": "\u5bc6\u78bc\uff08\u50c5\u52a0\u5bc6\uff09\u3002", + "prefix": "\u7368\u4e00\u7684 Prefix\uff08\u5047\u5982\u50c5\u6709\u4e00\u7d44 ElkM1 \u5247\u4fdd\u7559\u7a7a\u767d\uff09\u3002", + "protocol": "\u901a\u8a0a\u5354\u5b9a", + "temperature_unit": "ElkM1 \u4f7f\u7528\u6eab\u5ea6\u55ae\u4f4d\u3002", + "username": "\u4f7f\u7528\u8005\u540d\u7a31\uff08\u50c5\u52a0\u5bc6\uff09\u3002" + }, + "description": "\u52a0\u5bc6\u8207\u975e\u52a0\u5bc6\u4e4b\u4f4d\u5740\u5b57\u4e32\u683c\u5f0f\u5fc5\u9808\u70ba 'address[:port]'\u3002\u4f8b\u5982\uff1a'192.168.1.1'\u3002\u901a\u8a0a\u57e0\u70ba\u9078\u9805\u8f38\u5165\uff0c\u975e\u52a0\u5bc6\u9810\u8a2d\u503c\u70ba 2101\u3001\u52a0\u5bc6\u5247\u70ba 2601\u3002\u5e8f\u5217\u901a\u8a0a\u5354\u5b9a\u3001\u4f4d\u5740\u683c\u5f0f\u5fc5\u9808\u70ba 'tty[:baud]'\u3002\u4f8b\u5982\uff1a'/dev/ttyS1'\u3002\u50b3\u8f38\u7387\u70ba\u9078\u9805\u8f38\u5165\uff0c\u9810\u8a2d\u503c\u70ba 115200\u3002", + "title": "\u9023\u7dda\u81f3 Elk-M1 Control" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/elv/__init__.py b/homeassistant/components/elv/__init__.py new file mode 100644 index 0000000000000..b776c7f54530c --- /dev/null +++ b/homeassistant/components/elv/__init__.py @@ -0,0 +1,37 @@ +"""The Elv integration.""" + +import logging + +import voluptuous as vol + +from homeassistant.const import CONF_DEVICE +from homeassistant.helpers import discovery +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "elv" + +DEFAULT_DEVICE = "/dev/ttyUSB0" + +ELV_PLATFORMS = ["switch"] + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + {vol.Optional(CONF_DEVICE, default=DEFAULT_DEVICE): cv.string} + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +def setup(hass, config): + """Set up the PCA switch platform.""" + + for platform in ELV_PLATFORMS: + discovery.load_platform( + hass, platform, DOMAIN, {"device": config[DOMAIN][CONF_DEVICE]}, config + ) + + return True diff --git a/homeassistant/components/elv/manifest.json b/homeassistant/components/elv/manifest.json new file mode 100644 index 0000000000000..89b3751685a93 --- /dev/null +++ b/homeassistant/components/elv/manifest.json @@ -0,0 +1,7 @@ +{ + "domain": "elv", + "name": "ELV PCA", + "documentation": "https://www.home-assistant.io/integrations/pca", + "codeowners": ["@majuss"], + "requirements": ["pypca==0.0.7"] +} diff --git a/homeassistant/components/elv/switch.py b/homeassistant/components/elv/switch.py new file mode 100644 index 0000000000000..12b21c23d1a75 --- /dev/null +++ b/homeassistant/components/elv/switch.py @@ -0,0 +1,97 @@ +"""Support for PCA 301 smart switch.""" +import logging + +import pypca +from serial import SerialException + +from homeassistant.components.switch import ATTR_CURRENT_POWER_W, SwitchEntity +from homeassistant.const import EVENT_HOMEASSISTANT_STOP + +_LOGGER = logging.getLogger(__name__) + +ATTR_TOTAL_ENERGY_KWH = "total_energy_kwh" + +DEFAULT_NAME = "PCA 301" + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the PCA switch platform.""" + + if discovery_info is None: + return + + serial_device = discovery_info["device"] + + try: + pca = pypca.PCA(serial_device) + pca.open() + + entities = [SmartPlugSwitch(pca, device) for device in pca.get_devices()] + add_entities(entities, True) + + except SerialException as exc: + _LOGGER.warning("Unable to open serial port: %s", exc) + return + + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, pca.close) + + pca.start_scan() + + +class SmartPlugSwitch(SwitchEntity): + """Representation of a PCA Smart Plug switch.""" + + def __init__(self, pca, device_id): + """Initialize the switch.""" + self._device_id = device_id + self._name = "PCA 301" + self._state = None + self._available = True + self._emeter_params = {} + self._pca = pca + + @property + def name(self): + """Return the name of the Smart Plug, if any.""" + return self._name + + @property + def available(self) -> bool: + """Return if switch is available.""" + return self._available + + @property + def is_on(self): + """Return true if switch is on.""" + return self._state + + def turn_on(self, **kwargs): + """Turn the switch on.""" + self._pca.turn_on(self._device_id) + + def turn_off(self, **kwargs): + """Turn the switch off.""" + self._pca.turn_off(self._device_id) + + @property + def device_state_attributes(self): + """Return the state attributes of the device.""" + return self._emeter_params + + def update(self): + """Update the PCA switch's state.""" + try: + self._emeter_params[ + ATTR_CURRENT_POWER_W + ] = f"{self._pca.get_current_power(self._device_id):.1f}" + self._emeter_params[ + ATTR_TOTAL_ENERGY_KWH + ] = f"{self._pca.get_total_consumption(self._device_id):.2f}" + + self._available = True + self._state = self._pca.get_state(self._device_id) + + except (OSError) as ex: + if self._available: + _LOGGER.warning("Could not read state for %s: %s", self.name, ex) + self._available = False diff --git a/homeassistant/components/emby/manifest.json b/homeassistant/components/emby/manifest.json index 87688733e593a..c639d193298e1 100644 --- a/homeassistant/components/emby/manifest.json +++ b/homeassistant/components/emby/manifest.json @@ -1,12 +1,7 @@ { "domain": "emby", "name": "Emby", - "documentation": "https://www.home-assistant.io/components/emby", - "requirements": [ - "pyemby==1.6" - ], - "dependencies": [], - "codeowners": [ - "@mezz64" - ] + "documentation": "https://www.home-assistant.io/integrations/emby", + "requirements": ["pyemby==1.6"], + "codeowners": ["@mezz64"] } diff --git a/homeassistant/components/emby/media_player.py b/homeassistant/components/emby/media_player.py index fa1c096707be4..7872b8215a656 100644 --- a/homeassistant/components/emby/media_player.py +++ b/homeassistant/components/emby/media_player.py @@ -1,59 +1,77 @@ """Support to interface with the Emby API.""" import logging +from pyemby import EmbyServer import voluptuous as vol -from homeassistant.components.media_player import ( - MediaPlayerDevice, PLATFORM_SCHEMA) +from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerEntity 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) + 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) + 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" -MEDIA_TYPE_TRAILER = 'trailer' -MEDIA_TYPE_GENERIC_VIDEO = 'video' - -DEFAULT_HOST = 'localhost' +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): +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_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) + ssl = config[CONF_SSL] if port is None: port = DEFAULT_SSL_PORT if ssl else DEFAULT_PORT @@ -72,19 +90,20 @@ def device_update_callback(data): 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: + 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': + 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) @@ -98,8 +117,6 @@ def device_removal_callback(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): @@ -117,7 +134,7 @@ async def stop_emby(event): hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_emby) -class EmbyDevice(MediaPlayerDevice): +class EmbyDevice(MediaPlayerEntity): """Representation of an Emby device.""" def __init__(self, emby, device_id): @@ -127,7 +144,6 @@ def __init__(self, emby, device_id): 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 @@ -135,8 +151,7 @@ def __init__(self, emby, device_id): async def async_added_to_hass(self): """Register callback.""" - self.emby.add_update_callback( - self.async_update_callback, self.device_id) + self.emby.add_update_callback(self.async_update_callback, self.device_id) @callback def async_update_callback(self, msg): @@ -151,16 +166,7 @@ def async_update_callback(self, msg): 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 + self.async_write_ha_state() @property def available(self): @@ -184,8 +190,7 @@ def supports_remote_control(self): @property def name(self): """Return the name of the device.""" - return ('Emby - {} - {}'.format(self.device.client, self.device.name) - or DEVICE_DEFAULT_NAME) + return f"Emby {self.device.name}" or DEVICE_DEFAULT_NAME @property def should_poll(self): @@ -196,13 +201,13 @@ def should_poll(self): def state(self): """Return the state of the device.""" state = self.device.state - if state == 'Paused': + if state == "Paused": return STATE_PAUSED - if state == 'Playing': + if state == "Playing": return STATE_PLAYING - if state == 'Idle': + if state == "Idle": return STATE_IDLE - if state == 'Off': + if state == "Off": return STATE_OFF @property @@ -220,19 +225,19 @@ def media_content_id(self): def media_content_type(self): """Content type of current playing media.""" media_type = self.device.media_type - if media_type == 'Episode': + if media_type == "Episode": return MEDIA_TYPE_TVSHOW - if media_type == 'Movie': + if media_type == "Movie": return MEDIA_TYPE_MOVIE - if media_type == 'Trailer': + if media_type == "Trailer": return MEDIA_TYPE_TRAILER - if media_type == 'Music': + if media_type == "Music": return MEDIA_TYPE_MUSIC - if media_type == 'Video': + if media_type == "Video": return MEDIA_TYPE_GENERIC_VIDEO - if media_type == 'Audio': + if media_type == "Audio": return MEDIA_TYPE_MUSIC - if media_type == 'TvChannel': + if media_type == "TvChannel": return MEDIA_TYPE_CHANNEL return None @@ -300,46 +305,28 @@ 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. + return 0 - This method must be run in the event loop and returns a coroutine. - """ - return self.device.media_stop() + async def async_media_play(self): + """Play media.""" + await self.device.media_play() - def async_media_next_track(self): - """Send next track command. + async def async_media_pause(self): + """Pause the media player.""" + await self.device.media_pause() - This method must be run in the event loop and returns a coroutine. - """ - return self.device.media_next() + async def async_media_stop(self): + """Stop the media player.""" + await self.device.media_stop() - def async_media_previous_track(self): - """Send next track command. + async def async_media_next_track(self): + """Send next track command.""" + await self.device.media_next() - This method must be run in the event loop and returns a coroutine. - """ - return self.device.media_previous() + async def async_media_previous_track(self): + """Send next track command.""" + await 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) + async def async_media_seek(self, position): + """Send seek command.""" + await self.device.media_seek(position) diff --git a/homeassistant/components/emoncms/manifest.json b/homeassistant/components/emoncms/manifest.json index 90623c01d1be5..6ea57cf370492 100644 --- a/homeassistant/components/emoncms/manifest.json +++ b/homeassistant/components/emoncms/manifest.json @@ -1,8 +1,6 @@ { "domain": "emoncms", "name": "Emoncms", - "documentation": "https://www.home-assistant.io/components/emoncms", - "requirements": [], - "dependencies": [], - "codeowners": [] + "documentation": "https://www.home-assistant.io/integrations/emoncms", + "codeowners": ["@borpin"] } diff --git a/homeassistant/components/emoncms/sensor.py b/homeassistant/components/emoncms/sensor.py index 6e059e1a30f31..dca9c870022e1 100644 --- a/homeassistant/components/emoncms/sensor.py +++ b/homeassistant/components/emoncms/sensor.py @@ -2,58 +2,69 @@ from datetime import timedelta import logging -import voluptuous as vol 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_URL, CONF_VALUE_TEMPLATE, CONF_UNIT_OF_MEASUREMENT, - CONF_ID, CONF_SCAN_INTERVAL, STATE_UNKNOWN, POWER_WATT) -from homeassistant.helpers.entity import Entity + CONF_API_KEY, + CONF_ID, + CONF_SCAN_INTERVAL, + CONF_UNIT_OF_MEASUREMENT, + CONF_URL, + CONF_VALUE_TEMPLATE, + HTTP_OK, + POWER_WATT, + STATE_UNKNOWN, +) from homeassistant.helpers import template +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity 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' +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' +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, -}) +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) + return f"emoncms{sensorid}_{feedtag}_{feedname}_{feedid}_{feeduserid}" def setup_platform(hass, config, add_entities, discovery_info=None): @@ -62,7 +73,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): 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) + config_unit = 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) @@ -94,30 +105,46 @@ def setup_platform(hass, config, add_entities, discovery_info=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)) + unit = elem.get("unit") + if unit: + unit_of_measurement = unit + else: + unit_of_measurement = config_unit + + 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): + 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 + 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) + feed_name = elem.get("name") or f"Feed {elem['id']}" + self._name = f"EmonCMS{id_for_name} {feed_name}" else: self._name = name self._identifier = get_id( - sensorid, elem["tag"], elem["name"], elem["id"], elem["userid"]) + sensorid, elem["tag"], elem["name"], elem["id"], elem["userid"] + ) self._hass = hass self._data = data self._value_template = value_template @@ -127,7 +154,8 @@ def __init__(self, hass, data, name, value_template, if self._value_template is not None: self._state = self._value_template.render_with_possible_json_value( - elem["value"], STATE_UNKNOWN) + elem["value"], STATE_UNKNOWN + ) else: self._state = round(float(elem["value"]), DECIMALS) @@ -156,8 +184,7 @@ def device_state_attributes(self): ATTR_SIZE: self._elem["size"], ATTR_USERID: self._elem["userid"], ATTR_LASTUPDATETIME: self._elem["time"], - ATTR_LASTUPDATETIMESTR: template.timestamp_local( - float(self._elem["time"])), + ATTR_LASTUPDATETIMESTR: template.timestamp_local(float(self._elem["time"])), } def update(self): @@ -167,11 +194,21 @@ def update(self): 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) + 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 @@ -180,7 +217,8 @@ def update(self): if self._value_template is not None: self._state = self._value_template.render_with_possible_json_value( - elem["value"], STATE_UNKNOWN) + elem["value"], STATE_UNKNOWN + ) else: self._state = round(float(elem["value"]), DECIMALS) @@ -191,7 +229,7 @@ class EmonCmsData: def __init__(self, hass, url, apikey, interval): """Initialize the data object.""" self._apikey = apikey - self._url = '{}/feed/list.json'.format(url) + self._url = f"{url}/feed/list.json" self._interval = interval self._hass = hass self.data = None @@ -202,14 +240,18 @@ def update(self): try: parameters = {"apikey": self._apikey} req = requests.get( - self._url, params=parameters, allow_redirects=True, timeout=5) + 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: + if req.status_code == HTTP_OK: 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) + _LOGGER.error( + "Please verify if the specified configuration value " + "'%s' is correct! (HTTP Status_code = %d)", + CONF_URL, + req.status_code, + ) diff --git a/homeassistant/components/emoncms_history/__init__.py b/homeassistant/components/emoncms_history/__init__.py index 45fb358cecc63..85b48c557554a 100644 --- a/homeassistant/components/emoncms_history/__init__.py +++ b/homeassistant/components/emoncms_history/__init__.py @@ -1,32 +1,43 @@ """Support for sending data to Emoncms.""" -import logging from datetime import timedelta +import logging 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) + CONF_API_KEY, + CONF_SCAN_INTERVAL, + CONF_URL, + CONF_WHITELIST, + HTTP_OK, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) from homeassistant.helpers import state as state_helper +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import track_point_in_time from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) -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) +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): @@ -37,21 +48,24 @@ def setup(hass, config): def send_data(url, apikey, node, payload): """Send payload data to Emoncms.""" try: - fullurl = '{}/input/post.json'.format(url) + fullurl = f"{url}/input/post.json" data = {"apikey": apikey, "data": payload} parameters = {"node": node} req = requests.post( - fullurl, params=parameters, data=data, allow_redirects=True, - timeout=5) + 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: + if req.status_code != HTTP_OK: _LOGGER.error( "Error saving data %s to %s (http status code = %d)", - payload, fullurl, req.status_code) + payload, + fullurl, + req.status_code, + ) def update_emoncms(time): """Send whitelisted entities states regularly to Emoncms.""" @@ -60,8 +74,7 @@ def update_emoncms(time): for entity_id in whitelist: state = hass.states.get(entity_id) - if state is None or state.state in ( - STATE_UNKNOWN, '', STATE_UNAVAILABLE): + if state is None or state.state in (STATE_UNKNOWN, "", STATE_UNAVAILABLE): continue try: @@ -70,15 +83,20 @@ def update_emoncms(time): 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))) + payload = "{%s}" % ",".join( + f"{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 index 0cb09e3fb73b8..9c3066db215e1 100644 --- a/homeassistant/components/emoncms_history/manifest.json +++ b/homeassistant/components/emoncms_history/manifest.json @@ -1,8 +1,6 @@ { "domain": "emoncms_history", - "name": "Emoncms history", - "documentation": "https://www.home-assistant.io/components/emoncms_history", - "requirements": [], - "dependencies": [], + "name": "Emoncms History", + "documentation": "https://www.home-assistant.io/integrations/emoncms_history", "codeowners": [] } diff --git a/homeassistant/components/emulated_hue/__init__.py b/homeassistant/components/emulated_hue/__init__.py index 2ef0aaca13452..da6e7acab404e 100644 --- a/homeassistant/components/emulated_hue/__init__.py +++ b/homeassistant/components/emulated_hue/__init__.py @@ -1,80 +1,96 @@ -"""Support for local control of entities by emulating a Phillips Hue bridge.""" +"""Support for local control of entities by emulating a Philips 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.components.http import real_ip +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) + HueAllGroupsStateView, + HueAllLightsStateView, + HueFullStateView, + HueGroupView, + HueOneLightChangeView, + HueOneLightStateView, + HueUnauthorizedUser, + HueUsernameView, +) from .upnp import DescriptionXmlView, UPNPResponderThread -DOMAIN = 'emulated_hue' +DOMAIN = "emulated_hue" _LOGGER = logging.getLogger(__name__) -NUMBERS_FILE = 'emulated_hue_ids.json' +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' +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' +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_OFF_MAPS_TO_ON_DOMAINS = ["script", "scene"] DEFAULT_EXPOSE_BY_DEFAULT = True DEFAULT_EXPOSED_DOMAINS = [ - 'switch', 'light', 'group', 'input_boolean', 'media_player', 'fan' + "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' +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_NAME = "emulated_hue_name" async def async_setup(hass, yaml_config): @@ -82,7 +98,7 @@ async def async_setup(hass, yaml_config): config = Config(hass, yaml_config.get(DOMAIN, {})) app = web.Application() - app['hass'] = hass + app["hass"] = hass real_ip.setup_real_ip(app, False, []) # We misunderstood the startup signal. You're not allowed to change @@ -96,16 +112,21 @@ async def async_setup(hass, yaml_config): DescriptionXmlView(config).register(app, app.router) HueUsernameView().register(app, app.router) + HueUnauthorizedUser().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) + HueFullStateView(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) + 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.""" @@ -129,14 +150,15 @@ async def start_emulated_hue_bridge(event): try: await site.start() except OSError as error: - _LOGGER.error("Failed to create HTTP server at port %d: %s", - config.listen_port, 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) + EVENT_HOMEASSISTANT_STOP, stop_emulated_hue_bridge + ) - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, - start_emulated_hue_bridge) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, start_emulated_hue_bridge) return True @@ -153,8 +175,9 @@ def __init__(self, hass, conf): 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') + "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) @@ -162,20 +185,22 @@ def __init__(self, hass, conf): self.host_ip_addr = util.get_local_ip() _LOGGER.info( "Listen IP address not specified, auto-detected address is %s", - self.host_ip_addr) + 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) + "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) + 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 @@ -187,22 +212,28 @@ def __init__(self, hass, conf): # 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) + 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) + self.exposed_domains = set( + 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_ip = conf.get(CONF_ADVERTISE_IP) or self.host_ip_addr - self.advertise_port = conf.get( - CONF_ADVERTISE_PORT) or self.listen_port + self.advertise_port = conf.get(CONF_ADVERTISE_PORT) or self.listen_port self.entities = conf.get(CONF_ENTITIES, {}) + self._entities_with_hidden_attr_in_config = {} + for entity_id in self.entities: + hidden_value = self.entities[entity_id].get(CONF_ENTITY_HIDDEN) + if hidden_value is not None: + self._entities_with_hidden_attr_in_config[entity_id] = hidden_value + def entity_id_to_number(self, entity_id): """Get a unique number for the entity id.""" if self.type == TYPE_ALEXA: @@ -216,7 +247,7 @@ def entity_id_to_number(self, entity_id): if entity_id == ent_id: return number - number = '1' + number = "1" if self.numbers: number = str(max(int(k) for k in self.numbers) + 1) self.numbers[number] = entity_id @@ -237,8 +268,10 @@ def number_to_entity_id(self, 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]: + 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) @@ -248,41 +281,26 @@ def is_entity_exposed(self, entity): Async friendly. """ - if entity.attributes.get('view') is not None: + 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 + if entity.entity_id in self._entities_with_hidden_attr_in_config: + return not self._entities_with_hidden_attr_in_config[entity.entity_id] + if not self.expose_by_default: + return False # 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 + if entity.domain in self.exposed_domains: + return True - return is_default_exposed or expose + return False def _load_json(filename): - """Wrapper, because we actually want to handle invalid json.""" + """Load JSON, handling invalid syntax.""" try: return load_json(filename) except HomeAssistantError: diff --git a/homeassistant/components/emulated_hue/hue_api.py b/homeassistant/components/emulated_hue/hue_api.py index 44a9c6e53ef2b..9637b0fb37182 100644 --- a/homeassistant/components/emulated_hue/hue_api.py +++ b/homeassistant/components/emulated_hue/hue_api.py @@ -1,78 +1,142 @@ """Support for a Hue API to control Home Assistant.""" +import hashlib import logging -from aiohttp import web - from homeassistant import core from homeassistant.components import ( - climate, cover, fan, light, media_player, scene, script) + climate, + cover, + fan, + light, + media_player, + scene, + script, +) from homeassistant.components.climate.const import ( - SERVICE_SET_TEMPERATURE, SUPPORT_TARGET_TEMPERATURE) + SERVICE_SET_TEMPERATURE, + SUPPORT_TARGET_TEMPERATURE, +) from homeassistant.components.cover import ( - ATTR_CURRENT_POSITION, ATTR_POSITION, SERVICE_SET_COVER_POSITION, - SUPPORT_SET_POSITION) + 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) + 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) + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP, + ATTR_HS_COLOR, + SUPPORT_BRIGHTNESS, + SUPPORT_COLOR, + SUPPORT_COLOR_TEMP, +) from homeassistant.components.media_player.const import ( - ATTR_MEDIA_VOLUME_LEVEL, SUPPORT_VOLUME_SET) + 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) + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + ATTR_TEMPERATURE, + HTTP_BAD_REQUEST, + HTTP_NOT_FOUND, + HTTP_UNAUTHORIZED, + SERVICE_CLOSE_COVER, + SERVICE_OPEN_COVER, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + SERVICE_VOLUME_SET, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, +) 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 = "bri" +STATE_COLORMODE = "colormode" +STATE_HUE = "hue" +STATE_SATURATION = "sat" +STATE_COLOR_TEMP = "ct" + +# Hue API states, defined separately in case they change +HUE_API_STATE_ON = "on" +HUE_API_STATE_BRI = "bri" +HUE_API_STATE_COLORMODE = "colormode" +HUE_API_STATE_HUE = "hue" +HUE_API_STATE_SAT = "sat" +HUE_API_STATE_CT = "ct" +HUE_API_STATE_EFFECT = "effect" + +# Hue API min/max values - https://developers.meethue.com/develop/hue-api/lights-api/ +HUE_API_STATE_BRI_MIN = 1 # Brightness +HUE_API_STATE_BRI_MAX = 254 +HUE_API_STATE_HUE_MIN = 0 # Hue +HUE_API_STATE_HUE_MAX = 65535 +HUE_API_STATE_SAT_MIN = 0 # Saturation +HUE_API_STATE_SAT_MAX = 254 +HUE_API_STATE_CT_MIN = 153 # Color temp +HUE_API_STATE_CT_MAX = 500 + +HUE_API_USERNAME = "12345678901234567890" +UNAUTHORIZED_USER = [ + {"error": {"address": "/", "description": "unauthorized user", "type": "1"}} +] + + +class HueUnauthorizedUser(HomeAssistantView): + """Handle requests to find the emulated hue bridge.""" + + url = "/api" + name = "emulated_hue:api:unauthorized_user" + extra_urls = ["/api/"] + requires_auth = False -STATE_BRIGHTNESS = HUE_API_STATE_BRI -STATE_HUE = HUE_API_STATE_HUE -STATE_SATURATION = HUE_API_STATE_SAT + async def get(self, request): + """Handle a GET request.""" + return self.json(UNAUTHORIZED_USER) 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/'] + url = "/api" + name = "emulated_hue:api:create_username" + extra_urls = ["/api/"] requires_auth = False async def post(self, request): """Handle a POST request.""" + if not is_local(request[KEY_REAL_IP]): + return self.json_message("Only local IPs allowed", HTTP_UNAUTHORIZED) + try: data = await request.json() except ValueError: - return self.json_message('Invalid JSON', HTTP_BAD_REQUEST) + 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 "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'}}]) + return self.json([{"success": {"username": HUE_API_USERNAME}}]) class HueAllGroupsStateView(HomeAssistantView): - """Group handler.""" + """Handle requests for getting info about entity groups.""" - url = '/api/{username}/groups' - name = 'emulated_hue:all_groups:state' + url = "/api/{username}/groups" + name = "emulated_hue:all_groups:state" requires_auth = False def __init__(self, config): @@ -83,18 +147,16 @@ def __init__(self, config): 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_message("Only local IPs allowed", HTTP_UNAUTHORIZED) - return self.json({ - }) + return self.json({}) class HueGroupView(HomeAssistantView): """Group handler to get Logitech Pop working.""" - url = '/api/{username}/groups/0/action' - name = 'emulated_hue:groups:state' + url = "/api/{username}/groups/0/action" + name = "emulated_hue:groups:state" requires_auth = False def __init__(self, config): @@ -105,23 +167,26 @@ def __init__(self, config): 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' - } - }]) + return self.json_message("Only local IPs allowed", HTTP_UNAUTHORIZED) + + 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.""" + """Handle requests for getting info about all entities.""" - url = '/api/{username}/lights' - name = 'emulated_hue:lights:state' + url = "/api/{username}/lights" + name = "emulated_hue:lights:state" requires_auth = False def __init__(self, config): @@ -132,28 +197,48 @@ def __init__(self, config): 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) + return self.json_message("Only local IPs allowed", HTTP_UNAUTHORIZED) + + return self.json(create_list_of_entities(self.config, request)) + - hass = request.app['hass'] - json_response = {} +class HueFullStateView(HomeAssistantView): + """Return full state view of emulated hue.""" - for entity in hass.states.async_all(): - if self.config.is_entity_exposed(entity): - state = get_entity_state(self.config, entity) + url = "/api/{username}" + name = "emulated_hue:username:state" + requires_auth = False - number = self.config.entity_id_to_number(entity.entity_id) - json_response[number] = entity_to_json(self.config, - entity, state) + 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_UNAUTHORIZED) + if username != HUE_API_USERNAME: + return self.json(UNAUTHORIZED_USER) + + json_response = { + "lights": create_list_of_entities(self.config, request), + "config": { + "mac": "00:00:00:00:00:00", + "swversion": "01003542", + "whitelist": {HUE_API_USERNAME: {"name": "HASS BRIDGE"}}, + "ipaddress": f"{self.config.advertise_ip}:{self.config.advertise_port}", + }, + } return self.json(json_response) class HueOneLightStateView(HomeAssistantView): - """Handle requests for getting and setting info about entities.""" + """Handle requests for getting info about a single entity.""" - url = '/api/{username}/lights/{entity_id}' - name = 'emulated_hue:light:state' + url = "/api/{username}/lights/{entity_id}" + name = "emulated_hue:light:state" requires_auth = False def __init__(self, config): @@ -164,33 +249,38 @@ def __init__(self, config): 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) + return self.json_message("Only local IPs allowed", HTTP_UNAUTHORIZED) - hass = request.app['hass'] - entity_id = self.config.number_to_entity_id(entity_id) - entity = hass.states.get(entity_id) + hass = request.app["hass"] + hass_entity_id = self.config.number_to_entity_id(entity_id) + + if hass_entity_id is None: + _LOGGER.error( + "Unknown entity number: %s not found in emulated_hue_ids.json", + entity_id, + ) + return self.json_message("Entity not found", HTTP_NOT_FOUND) + + entity = hass.states.get(hass_entity_id) if entity is None: - _LOGGER.error('Entity not found: %s', entity_id) - return web.Response(text="Entity not found", status=404) + _LOGGER.error("Entity not found: %s", hass_entity_id) + return self.json_message("Entity not found", HTTP_NOT_FOUND) 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) + _LOGGER.error("Entity not exposed: %s", entity_id) + return self.json_message("Entity not exposed", HTTP_UNAUTHORIZED) - json_response = entity_to_json(self.config, entity, state) + json_response = entity_to_json(self.config, entity) return self.json(json_response) class HueOneLightChangeView(HomeAssistantView): - """Handle requests for getting and setting info about entities.""" + """Handle requests for setting info about entities.""" - url = '/api/{username}/lights/{entity_number}/state' - name = 'emulated_hue:light:state' + url = "/api/{username}/lights/{entity_number}/state" + name = "emulated_hue:light:state" requires_auth = False def __init__(self, config): @@ -200,39 +290,87 @@ def __init__(self, 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) + return self.json_message("Only local IPs allowed", HTTP_UNAUTHORIZED) config = self.config - hass = request.app['hass'] + 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) + _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) + _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) + _LOGGER.error("Entity not exposed: %s", entity_id) + return self.json_message("Entity not exposed", HTTP_UNAUTHORIZED) try: request_json = await request.json() except ValueError: - _LOGGER.error('Received invalid json') - return self.json_message('Invalid JSON', HTTP_BAD_REQUEST) + _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) + # Get the entity's supported features + entity_features = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - if parsed is None: - _LOGGER.error('Unable to parse data: %s', request_json) - return web.Response(text="Bad request", status=400) + # Parse the request + parsed = { + STATE_ON: False, + STATE_BRIGHTNESS: None, + STATE_HUE: None, + STATE_SATURATION: None, + STATE_COLOR_TEMP: None, + } + + if HUE_API_STATE_ON in request_json: + if not isinstance(request_json[HUE_API_STATE_ON], bool): + _LOGGER.error("Unable to parse data: %s", request_json) + return self.json_message("Bad request", HTTP_BAD_REQUEST) + parsed[STATE_ON] = request_json[HUE_API_STATE_ON] + else: + parsed[STATE_ON] = entity.state != STATE_OFF + + for (key, attr) in ( + (HUE_API_STATE_BRI, STATE_BRIGHTNESS), + (HUE_API_STATE_HUE, STATE_HUE), + (HUE_API_STATE_SAT, STATE_SATURATION), + (HUE_API_STATE_CT, STATE_COLOR_TEMP), + ): + if key in request_json: + try: + parsed[attr] = int(request_json[key]) + except ValueError: + _LOGGER.error("Unable to parse data (2): %s", request_json) + return self.json_message("Bad request", HTTP_BAD_REQUEST) + + if HUE_API_STATE_BRI in request_json: + if entity.domain == light.DOMAIN: + if entity_features & SUPPORT_BRIGHTNESS: + parsed[STATE_ON] = parsed[STATE_BRIGHTNESS] > 0 + else: + parsed[STATE_BRIGHTNESS] = None + + elif entity.domain == scene.DOMAIN: + parsed[STATE_BRIGHTNESS] = None + parsed[STATE_ON] = True + + elif entity.domain in [ + script.DOMAIN, + media_player.DOMAIN, + fan.DOMAIN, + cover.DOMAIN, + climate.DOMAIN, + ]: + # Convert 0-254 to 0-100 + level = (parsed[STATE_BRIGHTNESS] / HUE_API_STATE_BRI_MAX) * 100 + parsed[STATE_BRIGHTNESS] = round(level) + parsed[STATE_ON] = True # Choose general HA domain domain = core.DOMAIN @@ -246,36 +384,46 @@ async def put(self, request, username, entity_number): # 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 the requested entity is a light, set the brightness, hue, + # saturation and color temp 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] + data[ATTR_BRIGHTNESS] = hue_brightness_to_hass( + parsed[STATE_BRIGHTNESS] + ) + if entity_features & SUPPORT_COLOR: - if parsed[STATE_HUE] is not None: - if parsed[STATE_SATURATION]: + if any((parsed[STATE_HUE], parsed[STATE_SATURATION])): + if parsed[STATE_HUE] is not None: + hue = parsed[STATE_HUE] + else: + hue = 0 + + if parsed[STATE_SATURATION] is not None: 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) + sat = int((sat / HUE_API_STATE_SAT_MAX) * 100) data[ATTR_HS_COLOR] = (hue, sat) - # If the requested entity is a script add some variables + if entity_features & SUPPORT_COLOR_TEMP: + if parsed[STATE_COLOR_TEMP] is not None: + data[ATTR_COLOR_TEMP] = parsed[STATE_COLOR_TEMP] + + # 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 + 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] + data["variables"]["requested_level"] = parsed[STATE_BRIGHTNESS] # If the requested entity is a climate, set the temperature elif entity.domain == climate.DOMAIN: @@ -297,8 +445,7 @@ async def put(self, request, username, entity_number): 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 + 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: @@ -330,8 +477,8 @@ async def put(self, request, username, entity_number): elif 66.6 < brightness <= 100: data[ATTR_SPEED] = SPEED_HIGH + # Map the off command to on 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 @@ -343,157 +490,101 @@ async def put(self, request, username, entity_number): # 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)) + 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])) + hass.async_create_task( + hass.services.async_call(domain, service, data, blocking=True) + ) + + # Create success responses for all received keys + json_response = [ + create_hue_success_response(entity_id, HUE_API_STATE_ON, parsed[STATE_ON]) + ] + + for (key, val) in ( + (STATE_BRIGHTNESS, HUE_API_STATE_BRI), + (STATE_HUE, HUE_API_STATE_HUE), + (STATE_SATURATION, HUE_API_STATE_SAT), + (STATE_COLOR_TEMP, HUE_API_STATE_CT), + ): + if parsed[key] is not None: + json_response.append( + create_hue_success_response(entity_id, val, parsed[key]) + ) 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_ON: False, STATE_BRIGHTNESS: None, STATE_HUE: None, - STATE_ON: False, - STATE_SATURATION: None + STATE_SATURATION: None, + STATE_COLOR_TEMP: 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) - hue_sat = entity.attributes.get(ATTR_HS_COLOR, None) + data[STATE_BRIGHTNESS] = hass_to_hue_brightness( + entity.attributes.get(ATTR_BRIGHTNESS, 0) + ) + hue_sat = entity.attributes.get(ATTR_HS_COLOR) if hue_sat is not None: hue = hue_sat[0] sat = hue_sat[1] - # convert hass hs values back to hue hs values + # 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) + data[STATE_SATURATION] = int((sat / 100.0) * HUE_API_STATE_SAT_MAX) + else: + data[STATE_HUE] = HUE_API_STATE_HUE_MIN + data[STATE_SATURATION] = HUE_API_STATE_SAT_MIN + data[STATE_COLOR_TEMP] = entity.attributes.get(ATTR_COLOR_TEMP, 0) + else: data[STATE_BRIGHTNESS] = 0 data[STATE_HUE] = 0 data[STATE_SATURATION] = 0 + data[STATE_COLOR_TEMP] = 0 - # Make sure the entity actually supports brightness + # Get the entity's supported features 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) + # Convert 0-100 to 0-254 + data[STATE_BRIGHTNESS] = round(temperature * HUE_API_STATE_BRI_MAX / 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) + ATTR_MEDIA_VOLUME_LEVEL, 1.0 if data[STATE_ON] else 0.0 + ) + # Convert 0.0-1.0 to 0-254 + 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 + # Convert 0.0-1.0 to 0-254 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 + data[STATE_BRIGHTNESS] = HUE_API_STATE_BRI_MAX elif entity.domain == cover.DOMAIN: level = entity.attributes.get(ATTR_CURRENT_POSITION, 0) data[STATE_BRIGHTNESS] = round(level / 100 * HUE_API_STATE_BRI_MAX) @@ -501,7 +592,8 @@ def get_entity_state(config, entity): data = cached_state # Make sure brightness is valid if data[STATE_BRIGHTNESS] is None: - data[STATE_BRIGHTNESS] = 255 if data[STATE_ON] else 0 + data[STATE_BRIGHTNESS] = HUE_API_STATE_BRI_MAX 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 @@ -512,29 +604,136 @@ def get_entity_state(config, entity): data[STATE_HUE] = 0 data[STATE_SATURATION] = 0 + # Clamp brightness, hue, saturation, and color temp to valid values + for (key, v_min, v_max) in ( + (STATE_BRIGHTNESS, HUE_API_STATE_BRI_MIN, HUE_API_STATE_BRI_MAX), + (STATE_HUE, HUE_API_STATE_HUE_MIN, HUE_API_STATE_HUE_MAX), + (STATE_SATURATION, HUE_API_STATE_SAT_MIN, HUE_API_STATE_SAT_MAX), + (STATE_COLOR_TEMP, HUE_API_STATE_CT_MIN, HUE_API_STATE_CT_MAX), + ): + if data[key] is not None: + data[key] = max(v_min, min(data[key], v_max)) + return data -def entity_to_json(config, entity, state): +def entity_to_json(config, entity): """Convert an entity to its Hue bridge JSON representation.""" - return { - 'state': - { + entity_features = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + unique_id = hashlib.md5(entity.entity_id.encode()).hexdigest() + unique_id = f"00:{unique_id[0:2]}:{unique_id[2:4]}:{unique_id[4:6]}:{unique_id[6:8]}:{unique_id[8:10]}:{unique_id[10:12]}:{unique_id[12:14]}-{unique_id[14:16]}" + + state = get_entity_state(config, entity) + + retval = { + "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 + "reachable": entity.state != STATE_UNAVAILABLE, + "mode": "homeautomation", }, - 'type': 'Dimmable light', - 'name': config.get_entity_name(entity), - 'modelid': 'HASS123', - 'uniqueid': entity.entity_id, - 'swversion': '123' + "name": config.get_entity_name(entity), + "uniqueid": unique_id, + "manufacturername": "Home Assistant", + "swversion": "123", } + if ( + (entity_features & SUPPORT_BRIGHTNESS) + and (entity_features & SUPPORT_COLOR) + and (entity_features & SUPPORT_COLOR_TEMP) + ): + # Extended Color light (Zigbee Device ID: 0x0210) + # Same as Color light, but which supports additional setting of color temperature + retval["type"] = "Extended color light" + retval["modelid"] = "HASS231" + retval["state"].update( + { + HUE_API_STATE_BRI: state[STATE_BRIGHTNESS], + HUE_API_STATE_HUE: state[STATE_HUE], + HUE_API_STATE_SAT: state[STATE_SATURATION], + HUE_API_STATE_CT: state[STATE_COLOR_TEMP], + HUE_API_STATE_EFFECT: "none", + } + ) + if state[STATE_HUE] > 0 or state[STATE_SATURATION] > 0: + retval["state"][HUE_API_STATE_COLORMODE] = "hs" + else: + retval["state"][HUE_API_STATE_COLORMODE] = "ct" + elif (entity_features & SUPPORT_BRIGHTNESS) and (entity_features & SUPPORT_COLOR): + # Color light (Zigbee Device ID: 0x0200) + # Supports on/off, dimming and color control (hue/saturation, enhanced hue, color loop and XY) + retval["type"] = "Color light" + retval["modelid"] = "HASS213" + retval["state"].update( + { + HUE_API_STATE_BRI: state[STATE_BRIGHTNESS], + HUE_API_STATE_COLORMODE: "hs", + HUE_API_STATE_HUE: state[STATE_HUE], + HUE_API_STATE_SAT: state[STATE_SATURATION], + HUE_API_STATE_EFFECT: "none", + } + ) + elif (entity_features & SUPPORT_BRIGHTNESS) and ( + entity_features & SUPPORT_COLOR_TEMP + ): + # Color temperature light (Zigbee Device ID: 0x0220) + # Supports groups, scenes, on/off, dimming, and setting of a color temperature + retval["type"] = "Color temperature light" + retval["modelid"] = "HASS312" + retval["state"].update( + { + HUE_API_STATE_COLORMODE: "ct", + HUE_API_STATE_CT: state[STATE_COLOR_TEMP], + HUE_API_STATE_BRI: state[STATE_BRIGHTNESS], + } + ) + elif entity_features & ( + SUPPORT_BRIGHTNESS + | SUPPORT_SET_POSITION + | SUPPORT_SET_SPEED + | SUPPORT_VOLUME_SET + | SUPPORT_TARGET_TEMPERATURE + ): + # Dimmable light (Zigbee Device ID: 0x0100) + # Supports groups, scenes, on/off and dimming + retval["type"] = "Dimmable light" + retval["modelid"] = "HASS123" + retval["state"].update({HUE_API_STATE_BRI: state[STATE_BRIGHTNESS]}) + else: + # Dimmable light (Zigbee Device ID: 0x0100) + # Supports groups, scenes, on/off and dimming + # Reports fixed brightness for compatibility with Alexa. + retval["type"] = "Dimmable light" + retval["modelid"] = "HASS123" + retval["state"].update({HUE_API_STATE_BRI: HUE_API_STATE_BRI_MAX}) + + return retval + 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}} + success_key = f"/lights/{entity_id}/state/{attr}" + return {"success": {success_key: value}} + + +def create_list_of_entities(config, request): + """Create a list of all entities.""" + hass = request.app["hass"] + json_response = {} + + for entity in hass.states.async_all(): + if config.is_entity_exposed(entity): + number = config.entity_id_to_number(entity.entity_id) + json_response[number] = entity_to_json(config, entity) + + return json_response + + +def hue_brightness_to_hass(value): + """Convert hue brightness 1..254 to hass format 0..255.""" + return min(255, round((value / HUE_API_STATE_BRI_MAX) * 255)) + + +def hass_to_hue_brightness(value): + """Convert hass brightness 0..255 to hue 1..254 scale.""" + return max(1, round((value / 255) * HUE_API_STATE_BRI_MAX)) diff --git a/homeassistant/components/emulated_hue/manifest.json b/homeassistant/components/emulated_hue/manifest.json index 75fcbc4c55500..fdff91630f3b3 100644 --- a/homeassistant/components/emulated_hue/manifest.json +++ b/homeassistant/components/emulated_hue/manifest.json @@ -1,10 +1,9 @@ { "domain": "emulated_hue", - "name": "Emulated hue", - "documentation": "https://www.home-assistant.io/components/emulated_hue", - "requirements": [ - "aiohttp_cors==0.7.0" - ], - "dependencies": [], - "codeowners": [] + "name": "Emulated Hue", + "documentation": "https://www.home-assistant.io/integrations/emulated_hue", + "requirements": ["aiohttp_cors==0.7.0"], + "after_dependencies": ["http"], + "codeowners": [], + "quality_scale": "internal" } diff --git a/homeassistant/components/emulated_hue/upnp.py b/homeassistant/components/emulated_hue/upnp.py index a163d4b2e91f4..c10fb3b826b9b 100644 --- a/homeassistant/components/emulated_hue/upnp.py +++ b/homeassistant/components/emulated_hue/upnp.py @@ -1,8 +1,8 @@ """Support UPNP discovery method that mimics Hue hubs.""" -import threading -import socket import logging import select +import socket +import threading from aiohttp import web @@ -15,8 +15,8 @@ class DescriptionXmlView(HomeAssistantView): """Handles requests for the description.xml file.""" - url = '/description.xml' - name = 'description:xml' + url = "/description.xml" + name = "description:xml" requires_auth = False def __init__(self, config): @@ -26,16 +26,16 @@ def __init__(self, config): @core.callback def get(self, request): """Handle a GET request.""" - xml_template = """ + resp_text = f""" 1 0 -http://{0}:{1}/ +http://{self.config.advertise_ip}:{self.config.advertise_port}/ urn:schemas-upnp-org:device:Basic:1 -HASS Bridge ({0}) +Home Assistant Bridge ({self.config.advertise_ip}) Royal Philips Electronics http://www.philips.com Philips hue Personal Wireless Lighting @@ -48,10 +48,7 @@ def get(self, request): """ - resp_text = xml_template.format( - self.config.advertise_ip, self.config.advertise_port) - - return web.Response(text=resp_text, content_type='text/xml') + return web.Response(text=resp_text, content_type="text/xml") class UPNPResponderThread(threading.Thread): @@ -59,8 +56,14 @@ class UPNPResponderThread(threading.Thread): _interrupted = False - def __init__(self, host_ip_addr, listen_port, upnp_bind_multicast, - advertise_ip, advertise_port): + def __init__( + self, + host_ip_addr, + listen_port, + upnp_bind_multicast, + advertise_ip, + advertise_port, + ): """Initialize the class.""" threading.Thread.__init__(self) @@ -70,10 +73,10 @@ def __init__(self, host_ip_addr, listen_port, 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 + resp_template = f"""HTTP/1.1 200 OK CACHE-CONTROL: max-age=60 EXT: -LOCATION: http://{0}:{1}/description.xml +LOCATION: http://{advertise_ip}:{advertise_port}/description.xml SERVER: FreeRTOS/6.0.5, UPnP/1.0, IpBridge/0.1 hue-bridgeid: 1234 ST: urn:schemas-upnp-org:device:basic:1 @@ -81,9 +84,7 @@ def __init__(self, host_ip_addr, listen_port, upnp_bind_multicast, """ - self.upnp_response = resp_template.format( - advertise_ip, advertise_port).replace("\n", "\r\n") \ - .encode('utf-8') + self.upnp_response = resp_template.replace("\n", "\r\n").encode("utf-8") def run(self): """Run the server.""" @@ -95,15 +96,14 @@ def run(self): 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)) + 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)) + socket.inet_aton("239.255.255.250") + socket.inet_aton(self.host_ip_addr), + ) if self.upnp_bind_multicast: ssdp_socket.bind(("", 1900)) @@ -116,30 +116,28 @@ def run(self): return try: - read, _, _ = select.select( - [ssdp_socket], [], - [ssdp_socket], 2) + 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: + except OSError as ex: if self._interrupted: clean_socket_close(ssdp_socket) return - _LOGGER.error("UPNP Responder socket exception occurred: %s", - ex.__str__) + _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'): + 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 = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) resp_socket.sendto(self.upnp_response, addr) resp_socket.close() diff --git a/homeassistant/components/emulated_roku/.translations/bg.json b/homeassistant/components/emulated_roku/.translations/bg.json deleted file mode 100644 index 77a96095c25ee..0000000000000 --- a/homeassistant/components/emulated_roku/.translations/bg.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "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 deleted file mode 100644 index bdd38b8538c5e..0000000000000 --- a/homeassistant/components/emulated_roku/.translations/ca.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "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 deleted file mode 100644 index 0479dee437d6e..0000000000000 --- a/homeassistant/components/emulated_roku/.translations/da.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "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 deleted file mode 100644 index f9c8a21240a50..0000000000000 --- a/homeassistant/components/emulated_roku/.translations/de.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "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 deleted file mode 100644 index 376252966a37b..0000000000000 --- a/homeassistant/components/emulated_roku/.translations/en.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "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 deleted file mode 100644 index 51c18c764db4c..0000000000000 --- a/homeassistant/components/emulated_roku/.translations/es-419.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "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 deleted file mode 100644 index a4c8503b3f39c..0000000000000 --- a/homeassistant/components/emulated_roku/.translations/es.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "config": { - "abort": { - "name_exists": "El nombre ya existe" - }, - "step": { - "user": { - "data": { - "host_ip": "IP del host", - "listen_port": "Puerto de escucha", - "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/et.json b/homeassistant/components/emulated_roku/.translations/et.json deleted file mode 100644 index e284f6c37321d..0000000000000 --- a/homeassistant/components/emulated_roku/.translations/et.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "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 deleted file mode 100644 index 629e006564be4..0000000000000 --- a/homeassistant/components/emulated_roku/.translations/fr.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "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 deleted file mode 100644 index 9b6f77062537e..0000000000000 --- a/homeassistant/components/emulated_roku/.translations/hu.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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 deleted file mode 100644 index cba89add79948..0000000000000 --- a/homeassistant/components/emulated_roku/.translations/it.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "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 deleted file mode 100644 index ddee892039f4f..0000000000000 --- a/homeassistant/components/emulated_roku/.translations/ko.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "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 deleted file mode 100644 index 11d1aa3ff7a7b..0000000000000 --- a/homeassistant/components/emulated_roku/.translations/lb.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "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 deleted file mode 100644 index fe26cda31e264..0000000000000 --- a/homeassistant/components/emulated_roku/.translations/nl.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "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 deleted file mode 100644 index e83497599ca4c..0000000000000 --- a/homeassistant/components/emulated_roku/.translations/no.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "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 deleted file mode 100644 index 0ed3cc3d14af6..0000000000000 --- a/homeassistant/components/emulated_roku/.translations/pl.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "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 deleted file mode 100644 index 138e077d4a46d..0000000000000 --- a/homeassistant/components/emulated_roku/.translations/pt.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "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 deleted file mode 100644 index c7b85c195929d..0000000000000 --- a/homeassistant/components/emulated_roku/.translations/ru.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "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 deleted file mode 100644 index 768feb83747e8..0000000000000 --- a/homeassistant/components/emulated_roku/.translations/sl.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "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 deleted file mode 100644 index 4ae7a356c4c0b..0000000000000 --- a/homeassistant/components/emulated_roku/.translations/sv.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "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/zh-Hans.json b/homeassistant/components/emulated_roku/.translations/zh-Hans.json deleted file mode 100644 index 88d8a822696f5..0000000000000 --- a/homeassistant/components/emulated_roku/.translations/zh-Hans.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "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 deleted file mode 100644 index 40b4307ae02de..0000000000000 --- a/homeassistant/components/emulated_roku/.translations/zh-Hant.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "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 index 72d4dff72db1f..4e5779296440a 100644 --- a/homeassistant/components/emulated_roku/__init__.py +++ b/homeassistant/components/emulated_roku/__init__.py @@ -8,24 +8,38 @@ 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) + 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): @@ -39,11 +53,11 @@ async def async_setup(hass, config): 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 - )) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=entry + ) + ) return True @@ -62,8 +76,15 @@ async def async_setup_entry(hass, config_entry): 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) + server = EmulatedRoku( + hass, + name, + host_ip, + listen_port, + advertise_ip, + advertise_port, + upnp_bind_multicast, + ) hass.data[DOMAIN][name] = server diff --git a/homeassistant/components/emulated_roku/binding.py b/homeassistant/components/emulated_roku/binding.py index b6a6719a37bb2..1d233c9ed8130 100644 --- a/homeassistant/components/emulated_roku/binding.py +++ b/homeassistant/components/emulated_roku/binding.py @@ -1,30 +1,39 @@ """Bridge between emulated_roku and Home Assistant.""" import logging -from homeassistant.const import ( - EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) +from emulated_roku import EmulatedRokuCommandHandler, EmulatedRokuServer + +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' +EVENT_ROKU_COMMAND = "roku_command" -ATTR_COMMAND_TYPE = 'type' -ATTR_SOURCE_NAME = 'source_name' -ATTR_KEY = 'key' -ATTR_APP_ID = 'app_id' +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' +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): + def __init__( + self, + hass, + name, + host_ip, + listen_port, + advertise_ip, + advertise_port, + upnp_bind_multicast, + ): """Initialize the properties.""" self.hass = hass @@ -44,8 +53,6 @@ def __init__(self, hass, name, host_ip, listen_port, 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.""" @@ -55,47 +62,70 @@ def __init__(self, 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) + 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) + 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) + 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) + 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( + "Initializing 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, + 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 + bind_multicast=self.bind_multicast, ) async def emulated_roku_stop(event): @@ -111,22 +141,26 @@ async def emulated_roku_start(event): 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) + 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) + 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) + EVENT_HOMEASSISTANT_START, emulated_roku_start + ) return True diff --git a/homeassistant/components/emulated_roku/config_flow.py b/homeassistant/components/emulated_roku/config_flow.py index d08ad09f1c0b9..0f8a82fa89343 100644 --- a/homeassistant/components/emulated_roku/config_flow.py +++ b/homeassistant/components/emulated_roku/config_flow.py @@ -11,8 +11,9 @@ @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)) + return { + entry.data[CONF_NAME] for entry in hass.config_entries.async_entries(DOMAIN) + } @config_entries.HANDLERS.register(DOMAIN) @@ -30,31 +31,30 @@ async def async_step_user(self, user_input=None): name = user_input[CONF_NAME] if name in configured_servers(self.hass): - return self.async_abort(reason='name_exists') + return self.async_abort(reason="name_exists") - return self.async_create_entry( - title=name, - data=user_input - ) + 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_name = f"{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 + 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): diff --git a/homeassistant/components/emulated_roku/const.py b/homeassistant/components/emulated_roku/const.py index 25ea3adaa84e5..6c7803671882f 100644 --- a/homeassistant/components/emulated_roku/const.py +++ b/homeassistant/components/emulated_roku/const.py @@ -1,12 +1,12 @@ """Constants for the emulated_roku component.""" -DOMAIN = 'emulated_roku' +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' +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 index 3b8eba396ec5c..78dfa78802f62 100644 --- a/homeassistant/components/emulated_roku/manifest.json +++ b/homeassistant/components/emulated_roku/manifest.json @@ -1,10 +1,8 @@ { "domain": "emulated_roku", - "name": "Emulated roku", - "documentation": "https://www.home-assistant.io/components/emulated_roku", - "requirements": [ - "emulated_roku==0.1.8" - ], - "dependencies": [], + "name": "Emulated Roku", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/emulated_roku", + "requirements": ["emulated_roku==0.2.1"], "codeowners": [] } diff --git a/homeassistant/components/emulated_roku/strings.json b/homeassistant/components/emulated_roku/strings.json index 376252966a37b..a47c1c4799b71 100644 --- a/homeassistant/components/emulated_roku/strings.json +++ b/homeassistant/components/emulated_roku/strings.json @@ -1,21 +1,19 @@ { - "config": { - "abort": { - "name_exists": "Name already exists" + "title": "Emulated Roku", + "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)" }, - "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" + "title": "Define server configuration" + } } -} \ No newline at end of file + } +} diff --git a/homeassistant/components/emulated_roku/translations/bg.json b/homeassistant/components/emulated_roku/translations/bg.json new file mode 100644 index 0000000000000..6c1725a16b182 --- /dev/null +++ b/homeassistant/components/emulated_roku/translations/bg.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "name_exists": "\u0418\u043c\u0435\u0442\u043e \u0432\u0435\u0447\u0435 \u0441\u044a\u0449\u0435\u0441\u0442\u0432\u0443\u0432\u0430" + }, + "step": { + "user": { + "data": { + "advertise_ip": "\u0420\u0430\u0437\u043f\u0440\u043e\u0441\u0442\u0440\u0430\u043d\u044f\u0432\u0430\u0439 IP \u0430\u0434\u0440\u0435\u0441", + "advertise_port": "\u0420\u0430\u0437\u043f\u0440\u043e\u0441\u0442\u0440\u0430\u043d\u044f\u0432\u0430\u0439 \u043f\u043e\u0440\u0442", + "host_ip": "\u0410\u0434\u0440\u0435\u0441", + "listen_port": "\u0421\u043b\u0443\u0448\u0430\u043d\u0435 \u043d\u0430 \u043f\u043e\u0440\u0442", + "name": "\u0418\u043c\u0435", + "upnp_bind_multicast": "\u0421\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435 \u043d\u0430 \u043c\u0443\u043b\u0442\u0438\u043a\u0430\u0441\u0442 (True/False)" + }, + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0432\u0430\u043d\u0435 \u043d\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f\u0442\u0430 \u043d\u0430 \u0441\u044a\u0440\u0432\u044a\u0440\u0430" + } + } + }, + "title": "EmulatedRoku" +} \ 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..cccea2cb77b53 --- /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..fbaf0d676c189 --- /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": "Adviseringsport", + "host_ip": "V\u00e6rts-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..ce3d9e40d6cff --- /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": "Emulated Roku" +} \ 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..c061f08b178a7 --- /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": "Emulated Roku" +} \ 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..85d75c81ff36b --- /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..8cba52b959148 --- /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..b94548b44af47 --- /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..dd115897e4abd --- /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..ce182a3f00d35 --- /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..b0bb5366ea2cb --- /dev/null +++ b/homeassistant/components/emulated_roku/translations/it.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "name_exists": "Il nome \u00e8 gi\u00e0 esistente" + }, + "step": { + "user": { + "data": { + "advertise_ip": "Pubblicizza IP", + "advertise_port": "Pubblicizza porta", + "host_ip": "Indirizzo IP dell'host", + "listen_port": "Porta di ascolto", + "name": "Nome", + "upnp_bind_multicast": "Associa multicast (Vero / Falso)" + }, + "title": "Definisci la configurazione del server" + } + } + }, + "title": "Emulated Roku" +} \ 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..d2f8d425a3986 --- /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\ud558\uae30" + } + } + }, + "title": "\uc5d0\ubbac\ub808\uc774\ud2b8 \ub41c Roku" +} \ 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..94ff88f971599 --- /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": "Emul\u00e9ierte Roku" +} \ 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..16c6ad1e512a4 --- /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/nn.json b/homeassistant/components/emulated_roku/translations/nn.json new file mode 100644 index 0000000000000..ccc9f5ac21e06 --- /dev/null +++ b/homeassistant/components/emulated_roku/translations/nn.json @@ -0,0 +1,3 @@ +{ + "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..2d4f72c50fb46 --- /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 (Sant/Usant)" + }, + "title": "Definer serverkonfigurasjon" + } + } + }, + "title": "Emulerte Roku" +} \ 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..6ce1c17e5d8b3 --- /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": "Adres IP", + "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-BR.json b/homeassistant/components/emulated_roku/translations/pt-BR.json new file mode 100644 index 0000000000000..b4111a184e9d7 --- /dev/null +++ b/homeassistant/components/emulated_roku/translations/pt-BR.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "name_exists": "O nome j\u00e1 existe" + }, + "step": { + "user": { + "data": { + "advertise_ip": "Anunciar IP", + "advertise_port": "Anunciar porta", + "host_ip": "IP do host", + "listen_port": "Porta de escuta", + "name": "Nome", + "upnp_bind_multicast": "Vincular 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/pt.json b/homeassistant/components/emulated_roku/translations/pt.json new file mode 100644 index 0000000000000..80a08aa09b85e --- /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..878575788d8bd --- /dev/null +++ b/homeassistant/components/emulated_roku/translations/ru.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "name_exists": "\u042d\u0442\u043e \u043d\u0430\u0437\u0432\u0430\u043d\u0438\u0435 \u0443\u0436\u0435 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u0442\u0441\u044f." + }, + "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": "Emulated Roku" +} \ 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..467d1139ea3f6 --- /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..ec12f514293f9 --- /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 similarity index 100% rename from homeassistant/components/emulated_roku/.translations/th.json rename to homeassistant/components/emulated_roku/translations/th.json 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..cf413a3ef1fd5 --- /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..b38f471ddff5a --- /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": "Emulated Roku" +} \ No newline at end of file diff --git a/homeassistant/components/enigma2/manifest.json b/homeassistant/components/enigma2/manifest.json index d523bd72b720d..86b0614897703 100644 --- a/homeassistant/components/enigma2/manifest.json +++ b/homeassistant/components/enigma2/manifest.json @@ -1,10 +1,7 @@ { "domain": "enigma2", - "name": "Enigma2", - "documentation": "https://www.home-assistant.io/components/enigma2", - "requirements": [ - "openwebifpy==3.1.1" - ], - "dependencies": [], + "name": "Enigma2 (OpenWebif)", + "documentation": "https://www.home-assistant.io/integrations/enigma2", + "requirements": ["openwebifpy==3.1.1"], "codeowners": ["@fbradyirl"] } diff --git a/homeassistant/components/enigma2/media_player.py b/homeassistant/components/enigma2/media_player.py index 4662c7076376d..563b2c5195d76 100644 --- a/homeassistant/components/enigma2/media_player.py +++ b/homeassistant/components/enigma2/media_player.py @@ -1,60 +1,88 @@ """Support for Enigma2 media players.""" import logging +from openwebif.api import CreateDevice import voluptuous as vol -from homeassistant.components.media_player import MediaPlayerDevice -from homeassistant.helpers.config_validation import (PLATFORM_SCHEMA) +from homeassistant.components.media_player import MediaPlayerEntity 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) + MEDIA_TYPE_TVSHOW, + SUPPORT_NEXT_TRACK, + SUPPORT_PAUSE, + SUPPORT_PREVIOUS_TRACK, + SUPPORT_SELECT_SOURCE, + SUPPORT_STOP, + SUPPORT_TURN_OFF, + SUPPORT_TURN_ON, + SUPPORT_VOLUME_MUTE, + SUPPORT_VOLUME_SET, + SUPPORT_VOLUME_STEP, +) from homeassistant.const import ( - CONF_HOST, CONF_NAME, CONF_USERNAME, CONF_PASSWORD, CONF_SSL, - STATE_OFF, STATE_ON, STATE_PLAYING, CONF_PORT) + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_SSL, + CONF_USERNAME, + STATE_OFF, + STATE_ON, + STATE_PLAYING, +) import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.config_validation import PLATFORM_SCHEMA _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' +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_NAME = "Enigma2 Media Player" DEFAULT_PORT = 80 DEFAULT_SSL = False DEFAULT_USE_CHANNEL_ICON = False -DEFAULT_USERNAME = 'root' -DEFAULT_PASSWORD = 'dreambox' +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, -}) +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): @@ -64,8 +92,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): # 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_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 @@ -74,22 +102,22 @@ def setup_platform(hass, config, add_devices, discovery_info=None): 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)) + device = CreateDevice( + host=config[CONF_HOST], + port=config.get(CONF_PORT), + username=config.get(CONF_USERNAME), + password=config.get(CONF_PASSWORD), + is_https=config[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): +class Enigma2Device(MediaPlayerEntity): """Representation of an Enigma2 box.""" def __init__(self, name, device): @@ -227,13 +255,15 @@ def device_state_attributes(self): """ 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'] + 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/__init__.py b/homeassistant/components/enocean/__init__.py index 9d51821082a25..90ab408775414 100644 --- a/homeassistant/components/enocean/__init__.py +++ b/homeassistant/components/enocean/__init__.py @@ -1,25 +1,26 @@ """Support for EnOcean devices.""" import logging +from enocean.communicators.serialcommunicator import SerialCommunicator +from enocean.protocol.packet import Packet, RadioPacket +from enocean.utils import combine_hex import voluptuous as vol from homeassistant.const import CONF_DEVICE -from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) -DOMAIN = 'enocean' -DATA_ENOCEAN = 'enocean' +DOMAIN = "enocean" +DATA_ENOCEAN = "enocean" -CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ - vol.Required(CONF_DEVICE): cv.string, - }), -}, extra=vol.ALLOW_EXTRA) +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' +SIGNAL_RECEIVE_MESSAGE = "enocean.receive_message" +SIGNAL_SEND_MESSAGE = "enocean.send_message" def setup(hass, config): @@ -36,13 +37,13 @@ class EnOceanDongle: 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 = 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) + SIGNAL_SEND_MESSAGE, self._send_message_callback + ) def _send_message_callback(self, command): """Send a command through the EnOcean dongle.""" @@ -54,11 +55,10 @@ def callback(self, packet): 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) + self.hass.helpers.dispatcher.dispatcher_send(SIGNAL_RECEIVE_MESSAGE, packet) class EnOceanDevice(Entity): @@ -71,22 +71,23 @@ def __init__(self, dev_id, dev_name="EnOcean device"): async def async_added_to_hass(self): """Register callbacks.""" - self.hass.helpers.dispatcher.async_dispatcher_connect( - SIGNAL_RECEIVE_MESSAGE, self._message_received_callback) + self.async_on_remove( + 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) + 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 index 5e0a3b31817c6..7fb8ea5e3f2fa 100644 --- a/homeassistant/components/enocean/binary_sensor.py +++ b/homeassistant/components/enocean/binary_sensor.py @@ -5,21 +5,26 @@ from homeassistant.components import enocean from homeassistant.components.binary_sensor import ( - DEVICE_CLASSES_SCHEMA, PLATFORM_SCHEMA, BinarySensorDevice) + DEVICE_CLASSES_SCHEMA, + PLATFORM_SCHEMA, + BinarySensorEntity, +) 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' +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, -}) +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): @@ -31,7 +36,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): add_entities([EnOceanBinarySensor(dev_id, dev_name, device_class)]) -class EnOceanBinarySensor(enocean.EnOceanDevice, BinarySensorDevice): +class EnOceanBinarySensor(enocean.EnOceanDevice, BinarySensorEntity): """Representation of EnOcean binary sensors such as wall switches. Supported EEPs (EnOcean Equipment Profiles): @@ -97,8 +102,12 @@ def value_changed(self, packet): 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}) + 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 index d40b2c01df655..0df0c94775a6c 100644 --- a/homeassistant/components/enocean/light.py +++ b/homeassistant/components/enocean/light.py @@ -6,23 +6,28 @@ from homeassistant.components import enocean from homeassistant.components.light import ( - ATTR_BRIGHTNESS, PLATFORM_SCHEMA, SUPPORT_BRIGHTNESS, Light) + ATTR_BRIGHTNESS, + PLATFORM_SCHEMA, + SUPPORT_BRIGHTNESS, + LightEntity, +) from homeassistant.const import CONF_ID, CONF_NAME import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -CONF_SENDER_ID = 'sender_id' +CONF_SENDER_ID = "sender_id" -DEFAULT_NAME = 'EnOcean Light' +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, -}) +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): @@ -34,7 +39,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): add_entities([EnOceanLight(sender_id, dev_id, dev_name)]) -class EnOceanLight(enocean.EnOceanDevice, Light): +class EnOceanLight(enocean.EnOceanDevice, LightEntity): """Representation of an EnOcean light source.""" def __init__(self, sender_id, dev_id, dev_name): @@ -77,7 +82,7 @@ def turn_on(self, **kwargs): bval = math.floor(self._brightness / 256.0 * 100.0) if bval == 0: bval = 1 - command = [0xa5, 0x02, bval, 0x01, 0x09] + command = [0xA5, 0x02, bval, 0x01, 0x09] command.extend(self._sender_id) command.extend([0x00]) self.send_command(command, [], 0x01) @@ -85,7 +90,7 @@ def turn_on(self, **kwargs): def turn_off(self, **kwargs): """Turn the light source off.""" - command = [0xa5, 0x02, 0x00, 0x01, 0x09] + command = [0xA5, 0x02, 0x00, 0x01, 0x09] command.extend(self._sender_id) command.extend([0x00]) self.send_command(command, [], 0x01) @@ -97,7 +102,7 @@ def value_changed(self, packet): 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: + 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) diff --git a/homeassistant/components/enocean/manifest.json b/homeassistant/components/enocean/manifest.json index e6f1c5d78262c..a02661f8883eb 100644 --- a/homeassistant/components/enocean/manifest.json +++ b/homeassistant/components/enocean/manifest.json @@ -1,10 +1,7 @@ { "domain": "enocean", - "name": "Enocean", - "documentation": "https://www.home-assistant.io/components/enocean", - "requirements": [ - "enocean==0.50" - ], - "dependencies": [], + "name": "EnOcean", + "documentation": "https://www.home-assistant.io/integrations/enocean", + "requirements": ["enocean==0.50"], "codeowners": ["@bdurrer"] } diff --git a/homeassistant/components/enocean/sensor.py b/homeassistant/components/enocean/sensor.py index 62d0277946fe7..45a20197a4a8a 100644 --- a/homeassistant/components/enocean/sensor.py +++ b/homeassistant/components/enocean/sensor.py @@ -6,73 +6,103 @@ 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) + CONF_DEVICE_CLASS, + CONF_ID, + CONF_NAME, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_POWER, + DEVICE_CLASS_TEMPERATURE, + POWER_WATT, + STATE_CLOSED, + STATE_OPEN, + TEMP_CELSIUS, + UNIT_PERCENTAGE, +) 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' +CONF_MAX_TEMP = "max_temp" +CONF_MIN_TEMP = "min_temp" +CONF_RANGE_FROM = "range_from" +CONF_RANGE_TO = "range_to" -DEFAULT_NAME = 'EnOcean sensor' +DEFAULT_NAME = "EnOcean sensor" -DEVICE_CLASS_POWER = 'powersensor' +SENSOR_TYPE_HUMIDITY = "humidity" +SENSOR_TYPE_POWER = "powersensor" +SENSOR_TYPE_TEMPERATURE = "temperature" +SENSOR_TYPE_WINDOWHANDLE = "windowhandle" SENSOR_TYPES = { - DEVICE_CLASS_HUMIDITY: { - 'name': 'Humidity', - 'unit': '%', - 'icon': 'mdi:water-percent', - 'class': DEVICE_CLASS_HUMIDITY, + SENSOR_TYPE_HUMIDITY: { + "name": "Humidity", + "unit": UNIT_PERCENTAGE, + "icon": "mdi:water-percent", + "class": DEVICE_CLASS_HUMIDITY, }, - DEVICE_CLASS_POWER: { - 'name': 'Power', - 'unit': POWER_WATT, - 'icon': 'mdi:power-plug', - 'class': DEVICE_CLASS_POWER, + SENSOR_TYPE_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, + SENSOR_TYPE_TEMPERATURE: { + "name": "Temperature", + "unit": TEMP_CELSIUS, + "icon": "mdi:thermometer", + "class": DEVICE_CLASS_TEMPERATURE, + }, + SENSOR_TYPE_WINDOWHANDLE: { + "name": "WindowHandle", + "unit": None, + "icon": "mdi:window", + "class": None, }, } -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, -}) + +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=SENSOR_TYPE_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) + sensor_type = config.get(CONF_DEVICE_CLASS) - if dev_class == DEVICE_CLASS_TEMPERATURE: + if sensor_type == SENSOR_TYPE_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( + [ + EnOceanTemperatureSensor( + dev_id, dev_name, temp_min, temp_max, range_from, range_to + ) + ] + ) + + elif sensor_type == SENSOR_TYPE_HUMIDITY: add_entities([EnOceanHumiditySensor(dev_id, dev_name)]) - elif dev_class == DEVICE_CLASS_POWER: + elif sensor_type == SENSOR_TYPE_POWER: add_entities([EnOceanPowerSensor(dev_id, dev_name)]) + elif sensor_type == SENSOR_TYPE_WINDOWHANDLE: + add_entities([EnOceanWindowHandle(dev_id, dev_name)]) + class EnOceanSensor(enocean.EnOceanDevice): """Representation of an EnOcean sensor device such as a power meter.""" @@ -81,11 +111,10 @@ 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._device_class = SENSOR_TYPES[self._sensor_type]["class"] + self._dev_name = f"{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 @@ -126,17 +155,17 @@ class EnOceanPowerSensor(EnOceanSensor): def __init__(self, dev_id, dev_name): """Initialize the EnOcean power sensor device.""" - super().__init__(dev_id, dev_name, DEVICE_CLASS_POWER) + super().__init__(dev_id, dev_name, SENSOR_TYPE_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: + 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'] + raw_val = packet.parsed["MR"]["raw_value"] + divisor = packet.parsed["DIV"]["raw_value"] self._state = raw_val / (10 ** divisor) self.schedule_update_ha_state() @@ -159,10 +188,9 @@ class EnOceanTemperatureSensor(EnOceanSensor): - A5-10-10 to A5-10-14 """ - def __init__(self, dev_id, dev_name, scale_min, scale_max, - range_from, range_to): + 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) + super().__init__(dev_id, dev_name, SENSOR_TYPE_TEMPERATURE) self._scale_min = scale_min self._scale_max = scale_max self.range_from = range_from @@ -170,7 +198,7 @@ def __init__(self, dev_id, dev_name, scale_min, scale_max, def value_changed(self, packet): """Update the internal state of the sensor.""" - if packet.data[0] != 0xa5: + if packet.data[0] != 0xA5: return temp_scale = self._scale_max - self._scale_min temp_range = self.range_to - self.range_from @@ -192,7 +220,7 @@ class EnOceanHumiditySensor(EnOceanSensor): def __init__(self, dev_id, dev_name): """Initialize the EnOcean humidity sensor device.""" - super().__init__(dev_id, dev_name, DEVICE_CLASS_HUMIDITY) + super().__init__(dev_id, dev_name, SENSOR_TYPE_HUMIDITY) def value_changed(self, packet): """Update the internal state of the sensor.""" @@ -201,3 +229,29 @@ def value_changed(self, packet): humidity = packet.data[2] * 100 / 250 self._state = round(humidity, 1) self.schedule_update_ha_state() + + +class EnOceanWindowHandle(EnOceanSensor): + """Representation of an EnOcean window handle device. + + EEPs (EnOcean Equipment Profiles): + - F6-10-00 (Mechanical handle / Hoppe AG) + """ + + def __init__(self, dev_id, dev_name): + """Initialize the EnOcean window handle sensor device.""" + super().__init__(dev_id, dev_name, SENSOR_TYPE_WINDOWHANDLE) + + def value_changed(self, packet): + """Update the internal state of the sensor.""" + + action = (packet.data[1] & 0x70) >> 4 + + if action == 0x07: + self._state = STATE_CLOSED + if action in (0x04, 0x06): + self._state = STATE_OPEN + if action == 0x05: + self._state = "tilt" + + self.schedule_update_ha_state() diff --git a/homeassistant/components/enocean/switch.py b/homeassistant/components/enocean/switch.py index 48d53949a4772..92642e329d93d 100644 --- a/homeassistant/components/enocean/switch.py +++ b/homeassistant/components/enocean/switch.py @@ -11,14 +11,16 @@ _LOGGER = logging.getLogger(__name__) -CONF_CHANNEL = 'channel' -DEFAULT_NAME = 'EnOcean Switch' +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, -}) +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): @@ -53,42 +55,46 @@ def name(self): def turn_on(self, **kwargs): """Turn on the switch.""" - optional = [0x03, ] + 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) + 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 = [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) + 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: + 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'] + 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: + 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 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/manifest.json b/homeassistant/components/enphase_envoy/manifest.json index 6fee88b39fca8..bde6c16bdfebc 100644 --- a/homeassistant/components/enphase_envoy/manifest.json +++ b/homeassistant/components/enphase_envoy/manifest.json @@ -1,10 +1,7 @@ { "domain": "enphase_envoy", - "name": "Enphase envoy", - "documentation": "https://www.home-assistant.io/components/enphase_envoy", - "requirements": [ - "envoy_reader==0.3" - ], - "dependencies": [], + "name": "Enphase Envoy", + "documentation": "https://www.home-assistant.io/integrations/enphase_envoy", + "requirements": ["envoy_reader==0.11.0"], "codeowners": [] } diff --git a/homeassistant/components/enphase_envoy/sensor.py b/homeassistant/components/enphase_envoy/sensor.py index 7077e12d7500a..a2b50f20eb635 100644 --- a/homeassistant/components/enphase_envoy/sensor.py +++ b/homeassistant/components/enphase_envoy/sensor.py @@ -1,60 +1,117 @@ """Support for Enphase Envoy solar energy monitor.""" import logging +from envoy_reader.envoy_reader import EnvoyReader +import requests 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) - + CONF_IP_ADDRESS, + CONF_MONITORED_CONDITIONS, + CONF_NAME, + CONF_PASSWORD, + CONF_USERNAME, + ENERGY_WATT_HOUR, + POWER_WATT, +) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity _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' + "daily_production": ("Envoy Today's Energy Production", ENERGY_WATT_HOUR), + "seven_days_production": ( + "Envoy Last Seven Days Energy Production", + ENERGY_WATT_HOUR, + ), + "lifetime_production": ("Envoy Lifetime Energy Production", ENERGY_WATT_HOUR), + "consumption": ("Envoy Current Energy Consumption", POWER_WATT), + "daily_consumption": ("Envoy Today's Energy Consumption", ENERGY_WATT_HOUR), + "seven_days_consumption": ( + "Envoy Last Seven Days Energy Consumption", + ENERGY_WATT_HOUR, + ), + "lifetime_consumption": ("Envoy Lifetime Energy Consumption", ENERGY_WATT_HOUR), + "inverters": ("Envoy Inverter", POWER_WATT), +} + + +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))])}) +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_IP_ADDRESS, default=CONST_DEFAULT_HOST): cv.string, + vol.Optional(CONF_USERNAME, default="envoy"): cv.string, + vol.Optional(CONF_PASSWORD, default=""): cv.string, + vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SENSORS)): vol.All( + cv.ensure_list, [vol.In(list(SENSORS))] + ), + vol.Optional(CONF_NAME, default=""): cv.string, + } +) -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the Enphase Envoy sensor.""" ip_address = config[CONF_IP_ADDRESS] monitored_conditions = config[CONF_MONITORED_CONDITIONS] + name = config[CONF_NAME] + username = config[CONF_USERNAME] + password = config[CONF_PASSWORD] + + envoy_reader = EnvoyReader(ip_address, username, password) + entities = [] # Iterate through the list of sensors for condition in monitored_conditions: - add_entities([Envoy(ip_address, condition, SENSORS[condition][0], - SENSORS[condition][1])], True) + if condition == "inverters": + try: + inverters = await envoy_reader.inverters_production() + except requests.exceptions.HTTPError: + _LOGGER.warning( + "Authentication for Inverter data failed during setup: %s", + ip_address, + ) + continue + + if isinstance(inverters, dict): + for inverter in inverters: + entities.append( + Envoy( + envoy_reader, + condition, + f"{name}{SENSORS[condition][0]} {inverter}", + SENSORS[condition][1], + ) + ) + + else: + entities.append( + Envoy( + envoy_reader, + condition, + f"{name}{SENSORS[condition][0]}", + SENSORS[condition][1], + ) + ) + async_add_entities(entities) class Envoy(Entity): """Implementation of the Enphase Envoy sensors.""" - def __init__(self, ip_address, sensor_type, name, unit): + def __init__(self, envoy_reader, sensor_type, name, unit): """Initialize the sensor.""" - self._ip_address = ip_address + self._envoy_reader = envoy_reader + self._type = sensor_type self._name = name self._unit_of_measurement = unit - self._type = sensor_type self._state = None + self._last_reported = None @property def name(self): @@ -76,8 +133,36 @@ 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 + @property + def device_state_attributes(self): + """Return the state attributes.""" + if self._type == "inverters": + return {"last_reported": self._last_reported} - self._state = getattr(EnvoyReader(self._ip_address), self._type)() + return None + + async def async_update(self): + """Get the energy production data from the Enphase Envoy.""" + if self._type != "inverters": + _state = await getattr(self._envoy_reader, self._type)() + if isinstance(_state, int): + self._state = _state + else: + _LOGGER.error(_state) + self._state = None + + elif self._type == "inverters": + try: + inverters = await (self._envoy_reader.inverters_production()) + except requests.exceptions.HTTPError: + _LOGGER.warning( + "Authentication for Inverter data failed during update: %s", + self._envoy_reader.host, + ) + + if isinstance(inverters, dict): + serial_number = self._name.split(" ")[2] + self._state = inverters[serial_number][0] + self._last_reported = inverters[serial_number][1] + else: + self._state = None diff --git a/homeassistant/components/entur_public_transport/manifest.json b/homeassistant/components/entur_public_transport/manifest.json index b2b60cff95a48..db5c68d2a4c45 100644 --- a/homeassistant/components/entur_public_transport/manifest.json +++ b/homeassistant/components/entur_public_transport/manifest.json @@ -1,10 +1,7 @@ { "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": [] + "name": "Entur", + "documentation": "https://www.home-assistant.io/integrations/entur_public_transport", + "requirements": ["enturclient==0.2.1"], + "codeowners": ["@hfurubotten"] } diff --git a/homeassistant/components/entur_public_transport/sensor.py b/homeassistant/components/entur_public_transport/sensor.py index 61b183b9408da..19510337d6ad8 100644 --- a/homeassistant/components/entur_public_transport/sensor.py +++ b/homeassistant/components/entur_public_transport/sensor.py @@ -2,12 +2,18 @@ from datetime import datetime, timedelta import logging +from enturclient import EnturPublicTransportData 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) + ATTR_ATTRIBUTION, + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_NAME, + CONF_SHOW_ON_MAP, + TIME_MINUTES, +) from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -16,58 +22,61 @@ _LOGGER = logging.getLogger(__name__) -API_CLIENT_NAME = 'homeassistant-homeassistant' +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' +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' +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', + "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)) -}) +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_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_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_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' +ATTR_TRANSPORT_MODE = "transport_mode" def due_in_minutes(timestamp: datetime) -> int: @@ -78,10 +87,8 @@ def due_in_minutes(timestamp: datetime) -> int: return int(diff.total_seconds() / 60) -async def async_setup_platform( - hass, config, async_add_entities, discovery_info=None): +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) @@ -94,13 +101,15 @@ async def async_setup_platform( 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)) + 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() @@ -111,13 +120,13 @@ async def async_setup_platform( entities = [] for place in data.all_stop_places_quays(): try: - given_name = "{} {}".format( - name, data.get_stop_info(place).name) + given_name = f"{name} {data.get_stop_info(place).name}" except KeyError: - given_name = "{} {}".format(name, place) + given_name = f"{name} {place}" entities.append( - EnturPublicTransportSensor(proxy, given_name, place, show_on_map)) + EnturPublicTransportSensor(proxy, given_name, place, show_on_map) + ) async_add_entities(entities, True) @@ -145,8 +154,7 @@ def get_stop_info(self, stop_id: str) -> dict: class EnturPublicTransportSensor(Entity): """Implementation of a Entur public transport sensor.""" - def __init__( - self, api: EnturProxy, name: str, stop: str, show_on_map: bool): + def __init__(self, api: EnturProxy, name: str, stop: str, show_on_map: bool): """Initialize the sensor.""" self.api = api self._stop = stop @@ -176,7 +184,7 @@ def device_state_attributes(self) -> dict: @property def unit_of_measurement(self) -> str: """Return the unit this state is expressed in.""" - return 'min' + return TIME_MINUTES @property def icon(self) -> str: @@ -204,13 +212,13 @@ async def async_update(self) -> 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._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_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 @@ -220,10 +228,12 @@ async def async_update(self) -> None: 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_AT] = calls[1].expected_departure_time.strftime( + "%H:%M" + ) + self._attributes[ + ATTR_NEXT_UP_IN + ] = f"{due_in_minutes(calls[1].expected_departure_time)} min" self._attributes[ATTR_NEXT_UP_REALTIME] = calls[1].is_realtime self._attributes[ATTR_NEXT_UP_DELAY] = calls[1].delay_in_min @@ -231,8 +241,8 @@ async def async_update(self) -> None: 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) + key_name = f"departure_#{i + 3}" + self._attributes[key_name] = ( + f"{'' if bool(call.is_realtime) else 'ca. '}" + f"{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 100644 index 0000000000000..d51b69f571368 --- /dev/null +++ b/homeassistant/components/environment_canada/camera.py @@ -0,0 +1,102 @@ +"""Support for the Environment Canada radar imagery.""" +import datetime +import logging + +from env_canada import ECRadar +import voluptuous as vol + +from homeassistant.components.camera import PLATFORM_SCHEMA, Camera +from homeassistant.const import ( + ATTR_ATTRIBUTION, + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_NAME, +) +import homeassistant.helpers.config_validation as cv +from homeassistant.util import Throttle + +_LOGGER = logging.getLogger(__name__) + +ATTR_STATION = "station" +ATTR_LOCATION = "location" +ATTR_UPDATED = "updated" + +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.matches_regex(r"^C[A-Z]{4}$|^[A-Z]{3}$"), + 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.""" + + if config.get(CONF_STATION): + radar_object = ECRadar( + station_id=config[CONF_STATION], precip_type=config.get(CONF_PRECIP_TYPE) + ) + else: + lat = config.get(CONF_LATITUDE, hass.config.latitude) + lon = config.get(CONF_LONGITUDE, hass.config.longitude) + radar_object = ECRadar(coordinates=(lat, lon)) + + 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 + self.timestamp = 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, + ATTR_UPDATED: self.timestamp, + } + + 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() + self.timestamp = self.radar_object.timestamp.isoformat() diff --git a/homeassistant/components/environment_canada/manifest.json b/homeassistant/components/environment_canada/manifest.json new file mode 100644 index 0000000000000..bdc38e90c0c40 --- /dev/null +++ b/homeassistant/components/environment_canada/manifest.json @@ -0,0 +1,7 @@ +{ + "domain": "environment_canada", + "name": "Environment Canada", + "documentation": "https://www.home-assistant.io/integrations/environment_canada", + "requirements": ["env_canada==0.0.35"], + "codeowners": ["@michaeldavie"] +} diff --git a/homeassistant/components/environment_canada/sensor.py b/homeassistant/components/environment_canada/sensor.py new file mode 100644 index 0000000000000..601a7f2ba3667 --- /dev/null +++ b/homeassistant/components/environment_canada/sensor.py @@ -0,0 +1,155 @@ +"""Support for the Environment Canada weather service.""" +from datetime import datetime, timedelta +import logging +import re + +from env_canada import ECData +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + ATTR_ATTRIBUTION, + ATTR_LOCATION, + CONF_LATITUDE, + CONF_LONGITUDE, + TEMP_CELSIUS, +) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(minutes=10) + +ATTR_UPDATED = "updated" +ATTR_STATION = "station" +ATTR_TIME = "alert time" + +CONF_ATTRIBUTION = "Data provided by Environment Canada" +CONF_STATION = "station" +CONF_LANGUAGE = "language" + + +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_LANGUAGE, default="english"): vol.In(["english", "french"]), + 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_entities, discovery_info=None): + """Set up the Environment Canada sensor.""" + + if config.get(CONF_STATION): + ec_data = ECData( + station_id=config[CONF_STATION], language=config.get(CONF_LANGUAGE) + ) + else: + lat = config.get(CONF_LATITUDE, hass.config.latitude) + lon = config.get(CONF_LONGITUDE, hass.config.longitude) + ec_data = ECData(coordinates=(lat, lon), language=config.get(CONF_LANGUAGE)) + + sensor_list = list(ec_data.conditions.keys()) + list(ec_data.alerts.keys()) + add_entities([ECSensor(sensor_type, ec_data) for sensor_type in sensor_list], True) + + +class ECSensor(Entity): + """Implementation of an Environment Canada sensor.""" + + def __init__(self, sensor_type, ec_data): + """Initialize the sensor.""" + self.sensor_type = sensor_type + self.ec_data = ec_data + + self._unique_id = None + self._name = None + self._state = None + self._attr = None + self._unit = None + + @property + def unique_id(self) -> str: + """Return the unique ID of the sensor.""" + return self._unique_id + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def 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 self._unit + + def update(self): + """Update current conditions.""" + self.ec_data.update() + self.ec_data.conditions.update(self.ec_data.alerts) + + conditions = self.ec_data.conditions + metadata = self.ec_data.metadata + sensor_data = conditions.get(self.sensor_type) + + self._unique_id = f"{metadata['location']}-{self.sensor_type}" + self._attr = {} + self._name = sensor_data.get("label") + value = sensor_data.get("value") + + if isinstance(value, list): + self._state = " | ".join([str(s.get("title")) for s in value])[:255] + self._attr.update( + {ATTR_TIME: " | ".join([str(s.get("date")) for s in value])} + ) + elif self.sensor_type == "tendency": + self._state = str(value).capitalize() + elif value is not None and len(value) > 255: + self._state = value[:255] + _LOGGER.info("Value for %s truncated to 255 characters", self._unique_id) + else: + self._state = value + + if sensor_data.get("unit") == "C" or self.sensor_type in [ + "wind_chill", + "humidex", + ]: + self._unit = TEMP_CELSIUS + else: + self._unit = sensor_data.get("unit") + + timestamp = metadata.get("timestamp") + if timestamp: + updated_utc = datetime.strptime(timestamp, "%Y%m%d%H%M%S").isoformat() + else: + updated_utc = None + + self._attr.update( + { + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + ATTR_UPDATED: updated_utc, + ATTR_LOCATION: metadata.get("location"), + ATTR_STATION: metadata.get("station"), + } + ) diff --git a/homeassistant/components/environment_canada/weather.py b/homeassistant/components/environment_canada/weather.py new file mode 100644 index 0000000000000..10666b4a34e49 --- /dev/null +++ b/homeassistant/components/environment_canada/weather.py @@ -0,0 +1,229 @@ +"""Platform for retrieving meteorological data from Environment Canada.""" +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 +import homeassistant.helpers.config_validation as cv +import homeassistant.util.dt as dt + +_LOGGER = logging.getLogger(__name__) + +CONF_FORECAST = "forecast" +CONF_ATTRIBUTION = "Data provided by Environment Canada" +CONF_STATION = "station" + + +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]) + else: + lat = config.get(CONF_LATITUDE, hass.config.latitude) + lon = config.get(CONF_LONGITUDE, hass.config.longitude) + ec_data = ECData(coordinates=(lat, lon)) + + 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.metadata.get("location") + + @property + def temperature(self): + """Return the temperature.""" + if self.ec_data.conditions.get("temperature").get("value"): + return float(self.ec_data.conditions["temperature"]["value"]) + if self.ec_data.hourly_forecasts[0].get("temperature"): + return float(self.ec_data.hourly_forecasts[0]["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").get("value"): + return float(self.ec_data.conditions["humidity"]["value"]) + return None + + @property + def wind_speed(self): + """Return the wind speed.""" + if self.ec_data.conditions.get("wind_speed").get("value"): + return float(self.ec_data.conditions["wind_speed"]["value"]) + return None + + @property + def wind_bearing(self): + """Return the wind bearing.""" + if self.ec_data.conditions.get("wind_bearing").get("value"): + return float(self.ec_data.conditions["wind_bearing"]["value"]) + return None + + @property + def pressure(self): + """Return the pressure.""" + if self.ec_data.conditions.get("pressure").get("value"): + return 10 * float(self.ec_data.conditions["pressure"]["value"]) + return None + + @property + def visibility(self): + """Return the visibility.""" + if self.ec_data.conditions.get("visibility").get("value"): + return float(self.ec_data.conditions["visibility"]["value"]) + return None + + @property + def condition(self): + """Return the weather condition.""" + icon_code = None + + if self.ec_data.conditions.get("icon_code").get("value"): + icon_code = self.ec_data.conditions["icon_code"]["value"] + elif self.ec_data.hourly_forecasts[0].get("icon_code"): + icon_code = self.ec_data.hourly_forecasts[0]["icon_code"] + + if icon_code: + return icon_code_to_condition(int(icon_code)) + return "" + + @property + def forecast(self): + """Return the forecast array.""" + return get_forecast(self.ec_data, self.forecast_type) + + 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/manifest.json b/homeassistant/components/envirophat/manifest.json index c69a66d43f85e..911e7a2fc3582 100644 --- a/homeassistant/components/envirophat/manifest.json +++ b/homeassistant/components/envirophat/manifest.json @@ -1,11 +1,7 @@ { "domain": "envirophat", - "name": "Envirophat", - "documentation": "https://www.home-assistant.io/components/envirophat", - "requirements": [ - "envirophat==0.0.6", - "smbus-cffi==0.5.1" - ], - "dependencies": [], + "name": "Enviro pHAT", + "documentation": "https://www.home-assistant.io/integrations/envirophat", + "requirements": ["envirophat==0.0.6", "smbus-cffi==0.5.1"], "codeowners": [] } diff --git a/homeassistant/components/envirophat/sensor.py b/homeassistant/components/envirophat/sensor.py index 6d792df24217c..1aa07c83027c8 100644 --- a/homeassistant/components/envirophat/sensor.py +++ b/homeassistant/components/envirophat/sensor.py @@ -1,54 +1,57 @@ """Support for Enviro pHAT sensors.""" +from datetime import timedelta 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) +from homeassistant.const import CONF_DISPLAY_OPTIONS, CONF_NAME, TEMP_CELSIUS, VOLT 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' +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'], + "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", VOLT, "mdi:flash"], + "voltage_1": ["voltage_1", VOLT, "mdi:flash"], + "voltage_2": ["voltage_2", VOLT, "mdi:flash"], + "voltage_3": ["voltage_3", VOLT, "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 -}) +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') + envirophat = importlib.import_module("envirophat") except OSError: _LOGGER.error("No Enviro pHAT was found.") return False @@ -97,37 +100,37 @@ def update(self): """Get the latest data and updates the states.""" self.data.update() - if self.type == 'light': + if self.type == "light": self._state = self.data.light - if self.type == 'light_red': + if self.type == "light_red": self._state = self.data.light_red - if self.type == 'light_green': + if self.type == "light_green": self._state = self.data.light_green - if self.type == 'light_blue': + if self.type == "light_blue": self._state = self.data.light_blue - if self.type == 'accelerometer_x': + if self.type == "accelerometer_x": self._state = self.data.accelerometer_x - if self.type == 'accelerometer_y': + if self.type == "accelerometer_y": self._state = self.data.accelerometer_y - if self.type == 'accelerometer_z': + if self.type == "accelerometer_z": self._state = self.data.accelerometer_z - if self.type == 'magnetometer_x': + if self.type == "magnetometer_x": self._state = self.data.magnetometer_x - if self.type == 'magnetometer_y': + if self.type == "magnetometer_y": self._state = self.data.magnetometer_y - if self.type == 'magnetometer_z': + if self.type == "magnetometer_z": self._state = self.data.magnetometer_z - if self.type == 'temperature': + if self.type == "temperature": self._state = self.data.temperature - if self.type == 'pressure': + if self.type == "pressure": self._state = self.data.pressure - if self.type == 'voltage_0': + if self.type == "voltage_0": self._state = self.data.voltage_0 - if self.type == 'voltage_1': + if self.type == "voltage_1": self._state = self.data.voltage_1 - if self.type == 'voltage_2': + if self.type == "voltage_2": self._state = self.data.voltage_2 - if self.type == 'voltage_3': + if self.type == "voltage_3": self._state = self.data.voltage_3 @@ -164,18 +167,23 @@ def update(self): 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() + 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() + ( + 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() + ( + 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) @@ -185,5 +193,9 @@ def update(self): 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() + ( + self.voltage_0, + self.voltage_1, + self.voltage_2, + self.voltage_3, + ) = self.envirophat.analog.read_all() diff --git a/homeassistant/components/envisalink/__init__.py b/homeassistant/components/envisalink/__init__.py index d7a015e8e4571..14113537de6e9 100644 --- a/homeassistant/components/envisalink/__init__.py +++ b/homeassistant/components/envisalink/__init__.py @@ -2,94 +2,103 @@ import asyncio import logging +from pyenvisalink import EnvisalinkAlarmPanel import voluptuous as vol +from homeassistant.const import CONF_HOST, CONF_TIMEOUT, EVENT_HOMEASSISTANT_STOP 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 +from homeassistant.helpers.entity import Entity _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' +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_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, -}) +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) @@ -106,11 +115,20 @@ async def async_setup(hass, config): zones = conf.get(CONF_ZONES) partitions = conf.get(CONF_PARTITIONS) connection_timeout = conf.get(CONF_TIMEOUT) - sync_connect = asyncio.Future(loop=hass.loop) + sync_connect = asyncio.Future() controller = EnvisalinkAlarmPanel( - host, port, panel_type, version, user, password, zone_dump, - keep_alive, hass.loop, connection_timeout) + host, + port, + panel_type, + version, + user, + password, + zone_dump, + keep_alive, + hass.loop, + connection_timeout, + ) hass.data[DATA_EVL] = controller @callback @@ -123,17 +141,19 @@ def login_fail_callback(data): @callback def connection_fail_callback(data): """Network failure callback.""" - _LOGGER.error("Could not establish a connection with the Envisalink") + _LOGGER.error( + "Could not establish a connection with the Envisalink- retrying..." + ) if not sync_connect.done(): - sync_connect.set_result(False) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_envisalink) + sync_connect.set_result(True) @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) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_envisalink) sync_connect.set_result(True) @callback @@ -183,30 +203,34 @@ async def handle_custom_function(call): # 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 - )) + 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) + 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 diff --git a/homeassistant/components/envisalink/alarm_control_panel.py b/homeassistant/components/envisalink/alarm_control_panel.py index 91a59d8f842c5..670dc78392f4d 100644 --- a/homeassistant/components/envisalink/alarm_control_panel.py +++ b/homeassistant/components/envisalink/alarm_control_panel.py @@ -3,33 +3,57 @@ import voluptuous as vol -import homeassistant.components.alarm_control_panel as alarm +from homeassistant.components.alarm_control_panel import ( + FORMAT_NUMBER, + AlarmControlPanelEntity, +) +from homeassistant.components.alarm_control_panel.const import ( + SUPPORT_ALARM_ARM_AWAY, + SUPPORT_ALARM_ARM_HOME, + SUPPORT_ALARM_ARM_NIGHT, + SUPPORT_ALARM_TRIGGER, +) 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) + ATTR_ENTITY_ID, + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_HOME, + STATE_ALARM_ARMED_NIGHT, + 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) + CONF_CODE, + CONF_PANIC, + CONF_PARTITIONNAME, + DATA_EVL, + DOMAIN, + 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 -}) +SERVICE_ALARM_KEYPRESS = "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): +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'] + configured_partitions = discovery_info["partitions"] code = discovery_info[CONF_CODE] panic_type = discovery_info[CONF_PANIC] @@ -37,9 +61,14 @@ async def async_setup_platform( 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]) + 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) @@ -50,24 +79,29 @@ def alarm_keypress_handler(service): 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] + 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) + DOMAIN, + SERVICE_ALARM_KEYPRESS, + alarm_keypress_handler, + schema=ALARM_KEYPRESS_SCHEMA, + ) return True -class EnvisalinkAlarm(EnvisalinkDevice, alarm.AlarmControlPanel): +class EnvisalinkAlarm(EnvisalinkDevice, AlarmControlPanelEntity): """Representation of an Envisalink-based alarm panel.""" - def __init__(self, hass, partition_number, alarm_name, code, panic_type, - info, controller): + 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 @@ -78,77 +112,106 @@ def __init__(self, hass, partition_number, alarm_name, code, panic_type, 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) + self.async_on_remove( + async_dispatcher_connect( + self.hass, SIGNAL_KEYPAD_UPDATE, self._update_callback + ) + ) + self.async_on_remove( + 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() + self.async_write_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 + return FORMAT_NUMBER @property def state(self): """Return the state of the device.""" state = STATE_UNKNOWN - if self._info['status']['alarm']: + if self._info["status"]["alarm"]: state = STATE_ALARM_TRIGGERED - elif self._info['status']['armed_away']: + elif self._info["status"]["armed_zero_entry_delay"]: + state = STATE_ALARM_ARMED_NIGHT + elif self._info["status"]["armed_away"]: state = STATE_ALARM_ARMED_AWAY - elif self._info['status']['armed_stay']: + elif self._info["status"]["armed_stay"]: state = STATE_ALARM_ARMED_HOME - elif self._info['status']['exit_delay']: + elif self._info["status"]["exit_delay"]: state = STATE_ALARM_PENDING - elif self._info['status']['entry_delay']: + elif self._info["status"]["entry_delay"]: state = STATE_ALARM_PENDING - elif self._info['status']['alpha']: + elif self._info["status"]["alpha"]: state = STATE_ALARM_DISARMED return state + @property + def supported_features(self) -> int: + """Return the list of supported features.""" + return ( + SUPPORT_ALARM_ARM_HOME + | SUPPORT_ALARM_ARM_AWAY + | SUPPORT_ALARM_ARM_NIGHT + | SUPPORT_ALARM_TRIGGER + ) + 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) + 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) + 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) + str(code), self._partition_number + ) else: self.hass.data[DATA_EVL].arm_stay_partition( - str(self._code), self._partition_number) + 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) + str(code), self._partition_number + ) else: self.hass.data[DATA_EVL].arm_away_partition( - str(self._code), self._partition_number) + 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) + async def async_alarm_arm_night(self, code=None): + """Send arm night command.""" + self.hass.data[DATA_EVL].arm_night_partition( + str(code) if code else str(self._code), self._partition_number + ) + @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) + self._partition_number, keypress + ) diff --git a/homeassistant/components/envisalink/binary_sensor.py b/homeassistant/components/envisalink/binary_sensor.py index bf47749d22862..5444566048454 100644 --- a/homeassistant/components/envisalink/binary_sensor.py +++ b/homeassistant/components/envisalink/binary_sensor.py @@ -2,23 +2,27 @@ import datetime import logging -from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.components.binary_sensor import BinarySensorEntity 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) + 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): +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'] + configured_zones = discovery_info["zones"] devices = [] for zone_num in configured_zones: @@ -28,30 +32,28 @@ async def async_setup_platform(hass, config, async_add_entities, 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] + hass.data[DATA_EVL].alarm_state["zone"][zone_num], + hass.data[DATA_EVL], ) devices.append(device) async_add_entities(devices) -class EnvisalinkBinarySensor(EnvisalinkDevice, BinarySensorDevice): +class EnvisalinkBinarySensor(EnvisalinkDevice, BinarySensorEntity): """Representation of an Envisalink binary sensor.""" - def __init__(self, hass, zone_number, zone_name, zone_type, info, - controller): + 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) + _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) + async_dispatcher_connect(self.hass, SIGNAL_ZONE_UPDATE, self._update_callback) @property def device_state_attributes(self): @@ -67,7 +69,7 @@ def device_state_attributes(self): # 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'] + seconds_ago = self._info["last_fault"] if seconds_ago < 65536 * 5: now = dt_util.now().replace(microsecond=0) delta = datetime.timedelta(seconds=seconds_ago) @@ -81,7 +83,7 @@ def device_state_attributes(self): @property def is_on(self): """Return true if sensor is on.""" - return self._info['status']['open'] + return self._info["status"]["open"] @property def device_class(self): @@ -92,4 +94,4 @@ def device_class(self): 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() + self.async_write_ha_state() diff --git a/homeassistant/components/envisalink/manifest.json b/homeassistant/components/envisalink/manifest.json index b34aa08951ca8..e45f8140df62a 100644 --- a/homeassistant/components/envisalink/manifest.json +++ b/homeassistant/components/envisalink/manifest.json @@ -1,10 +1,7 @@ { "domain": "envisalink", "name": "Envisalink", - "documentation": "https://www.home-assistant.io/components/envisalink", - "requirements": [ - "pyenvisalink==3.8" - ], - "dependencies": [], + "documentation": "https://www.home-assistant.io/integrations/envisalink", + "requirements": ["pyenvisalink==4.0"], "codeowners": [] } diff --git a/homeassistant/components/envisalink/sensor.py b/homeassistant/components/envisalink/sensor.py index 2652a7e2137fb..3f3711b2e40a2 100644 --- a/homeassistant/components/envisalink/sensor.py +++ b/homeassistant/components/envisalink/sensor.py @@ -6,24 +6,31 @@ from homeassistant.helpers.entity import Entity from . import ( - CONF_PARTITIONNAME, DATA_EVL, PARTITION_SCHEMA, SIGNAL_KEYPAD_UPDATE, - SIGNAL_PARTITION_UPDATE, EnvisalinkDevice) + 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): +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'] + 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]) + hass, + device_config_data[CONF_PARTITIONNAME], + part_num, + hass.data[DATA_EVL].alarm_state["partition"][part_num], + hass.data[DATA_EVL], + ) devices.append(device) @@ -33,21 +40,20 @@ async def async_setup_platform( class EnvisalinkSensor(EnvisalinkDevice, Entity): """Representation of an Envisalink keypad.""" - def __init__(self, hass, partition_name, partition_number, info, - controller): + def __init__(self, hass, partition_name, partition_number, info, controller): """Initialize the sensor.""" - self._icon = 'mdi:alarm' + 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) + super().__init__(f"{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_KEYPAD_UPDATE, self._update_callback) - async_dispatcher_connect( - self.hass, SIGNAL_PARTITION_UPDATE, self._update_callback) + self.hass, SIGNAL_PARTITION_UPDATE, self._update_callback + ) @property def icon(self): @@ -57,15 +63,15 @@ def icon(self): @property def state(self): """Return the overall state.""" - return self._info['status']['alpha'] + return self._info["status"]["alpha"] @property def device_state_attributes(self): """Return the state attributes.""" - return self._info['status'] + 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() + self.async_write_ha_state() diff --git a/homeassistant/components/envisalink/services.yaml b/homeassistant/components/envisalink/services.yaml index e31aa804059df..e9229ad838da4 100644 --- a/homeassistant/components/envisalink/services.yaml +++ b/homeassistant/components/envisalink/services.yaml @@ -1,5 +1,15 @@ # Describes the format for available Envisalink services. +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" + invoke_custom_function: description: > Allows users with DSC panels to trigger a PGM output (1-4). diff --git a/homeassistant/components/ephember/climate.py b/homeassistant/components/ephember/climate.py index 4e741dacf9d75..787677a66054e 100644 --- a/homeassistant/components/ephember/climate.py +++ b/homeassistant/components/ephember/climate.py @@ -1,14 +1,36 @@ """Support for the EPH Controls Ember themostats.""" -import logging from datetime import timedelta +import logging + +from pyephember.pyephember import ( + EphEmber, + ZoneMode, + zone_current_temperature, + zone_is_active, + zone_is_boost_active, + zone_is_hot_water, + zone_mode, + zone_name, + zone_target_temperature, +) import voluptuous as vol -from homeassistant.components.climate import ClimateDevice, PLATFORM_SCHEMA +from homeassistant.components.climate import PLATFORM_SCHEMA, ClimateEntity from homeassistant.components.climate.const import ( - STATE_HEAT, STATE_AUTO, SUPPORT_AUX_HEAT, SUPPORT_OPERATION_MODE, - SUPPORT_TARGET_TEMPERATURE) + CURRENT_HVAC_HEAT, + CURRENT_HVAC_IDLE, + HVAC_MODE_HEAT, + HVAC_MODE_HEAT_COOL, + HVAC_MODE_OFF, + SUPPORT_AUX_HEAT, + SUPPORT_TARGET_TEMPERATURE, +) from homeassistant.const import ( - ATTR_TEMPERATURE, TEMP_CELSIUS, CONF_USERNAME, CONF_PASSWORD, STATE_OFF) + ATTR_TEMPERATURE, + CONF_PASSWORD, + CONF_USERNAME, + TEMP_CELSIUS, +) import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -16,17 +38,16 @@ # 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] +OPERATION_LIST = [HVAC_MODE_HEAT_COOL, HVAC_MODE_HEAT, HVAC_MODE_OFF] -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string -}) +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 + "AUTO": HVAC_MODE_HEAT_COOL, + "ON": HVAC_MODE_HEAT, + "OFF": HVAC_MODE_OFF, } HA_STATE_TO_EPH = {value: key for key, value in EPH_TO_HA_STATE.items()} @@ -34,8 +55,6 @@ 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) @@ -51,25 +70,23 @@ def setup_platform(hass, config, add_entities, discovery_info=None): return -class EphEmberThermostat(ClimateDevice): - """Representation of a HeatmiserV3 thermostat.""" +class EphEmberThermostat(ClimateEntity): + """Representation of a EphEmber thermostat.""" def __init__(self, ember, zone): """Initialize the thermostat.""" self._ember = ember - self._zone_name = zone['name'] + self._zone_name = zone_name(zone) self._zone = zone - self._hot_water = zone['isHotWater'] + self._hot_water = zone_is_hot_water(zone) @property def supported_features(self): """Return the list of supported features.""" if self._hot_water: - return SUPPORT_AUX_HEAT | SUPPORT_OPERATION_MODE + return SUPPORT_AUX_HEAT - return (SUPPORT_TARGET_TEMPERATURE | - SUPPORT_AUX_HEAT | - SUPPORT_OPERATION_MODE) + return SUPPORT_TARGET_TEMPERATURE | SUPPORT_AUX_HEAT @property def name(self): @@ -84,12 +101,12 @@ def temperature_unit(self): @property def current_temperature(self): """Return the current temperature.""" - return self._zone['currentTemperature'] + return zone_current_temperature(self._zone) @property def target_temperature(self): """Return the temperature we try to reach.""" - return self._zone['targetTemperature'] + return zone_target_temperature(self._zone) @property def target_temperature_step(self): @@ -97,53 +114,46 @@ def target_temperature_step(self): if self._hot_water: return None - return 1 + return 0.5 @property - def device_state_attributes(self): - """Show Device Attributes.""" - attributes = { - 'currently_active': self._zone['isCurrentlyActive'] - } - return attributes + def hvac_action(self): + """Return current HVAC action.""" + if zone_is_active(self._zone): + return CURRENT_HVAC_HEAT + + return CURRENT_HVAC_IDLE @property - def current_operation(self): + def hvac_mode(self): """Return current operation ie. heat, cool, idle.""" - from pyephember.pyephember import ZoneMode - mode = ZoneMode(self._zone['mode']) + mode = zone_mode(self._zone) return self.map_mode_eph_hass(mode) @property - def operation_list(self): + def hvac_modes(self): """Return the supported operations.""" return OPERATION_LIST - def set_operation_mode(self, operation_mode): + def set_hvac_mode(self, hvac_mode): """Set the operation mode.""" - mode = self.map_mode_hass_eph(operation_mode) + mode = self.map_mode_hass_eph(hvac_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) + _LOGGER.error("Invalid operation mode provided %s", hvac_mode) @property - def is_on(self): - """Return current state.""" - if self._zone['isCurrentlyActive']: - return True - - return None - - @property - def is_aux_heat_on(self): + def is_aux_heat(self): """Return true if aux heater.""" - return self._zone['isBoostActive'] + + return zone_is_boost_active(self._zone) def turn_aux_heat_on(self): """Turn auxiliary heater on.""" self._ember.activate_boost_by_name( - self._zone_name, self._zone['targetTemperature']) + self._zone_name, zone_target_temperature(self._zone) + ) def turn_aux_heat_off(self): """Turn auxiliary heater off.""" @@ -164,25 +174,24 @@ def set_temperature(self, **kwargs): if temperature > self.max_temp or temperature < self.min_temp: return - self._ember.set_target_temperture_by_name(self._zone_name, - int(temperature)) + self._ember.set_target_temperture_by_name(self._zone_name, 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 zone_target_temperature(self._zone) - return 5 + return 5.0 @property def max_temp(self): """Return the maximum temperature.""" if self._hot_water: - return self._zone['targetTemperature'] + return zone_target_temperature(self._zone) - return 35 + return 35.0 def update(self): """Get the latest data.""" @@ -190,11 +199,10 @@ def update(self): @staticmethod def map_mode_hass_eph(operation_mode): - """Map from home assistant mode to eph mode.""" - from pyephember.pyephember import ZoneMode + """Map from Home Assistant mode to eph mode.""" 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) + """Map from eph mode to Home Assistant mode.""" + return EPH_TO_HA_STATE.get(operation_mode.name, HVAC_MODE_HEAT_COOL) diff --git a/homeassistant/components/ephember/manifest.json b/homeassistant/components/ephember/manifest.json index 3fed307aed5f3..c03a45a580452 100644 --- a/homeassistant/components/ephember/manifest.json +++ b/homeassistant/components/ephember/manifest.json @@ -1,12 +1,7 @@ { "domain": "ephember", - "name": "Ephember", - "documentation": "https://www.home-assistant.io/components/ephember", - "requirements": [ - "pyephember==0.2.0" - ], - "dependencies": [], - "codeowners": [ - "@ttroy50" - ] + "name": "EPH Controls", + "documentation": "https://www.home-assistant.io/integrations/ephember", + "requirements": ["pyephember==0.3.1"], + "codeowners": ["@ttroy50"] } diff --git a/homeassistant/components/epson/const.py b/homeassistant/components/epson/const.py new file mode 100644 index 0000000000000..23f3b081d0135 --- /dev/null +++ b/homeassistant/components/epson/const.py @@ -0,0 +1,10 @@ +"""Constants for the Epson projector component.""" +DOMAIN = "epson" +SERVICE_SELECT_CMODE = "select_cmode" + +ATTR_CMODE = "cmode" + +DATA_EPSON = "epson" +DEFAULT_NAME = "EPSON Projector" + +SUPPORT_CMODE = 33001 diff --git a/homeassistant/components/epson/manifest.json b/homeassistant/components/epson/manifest.json index e6623b83013ad..909efd5893e47 100644 --- a/homeassistant/components/epson/manifest.json +++ b/homeassistant/components/epson/manifest.json @@ -1,10 +1,7 @@ { "domain": "epson", "name": "Epson", - "documentation": "https://www.home-assistant.io/components/epson", - "requirements": [ - "epson-projector==0.1.3" - ], - "dependencies": [], + "documentation": "https://www.home-assistant.io/integrations/epson", + "requirements": ["epson-projector==0.1.3"], "codeowners": [] } diff --git a/homeassistant/components/epson/media_player.py b/homeassistant/components/epson/media_player.py index 8273ca9a21a1e..df0dcc536b5b3 100644 --- a/homeassistant/components/epson/media_player.py +++ b/homeassistant/components/epson/media_player.py @@ -1,68 +1,113 @@ """Support for Epson projector.""" import logging +import epson_projector as epson +from epson_projector.const import ( + BACK, + BUSY, + CMODE, + CMODE_LIST, + CMODE_LIST_SET, + DEFAULT_SOURCES, + EPSON_CODES, + FAST, + INV_SOURCES, + MUTE, + PAUSE, + PLAY, + POWER, + SOURCE, + SOURCE_LIST, + TURN_OFF, + TURN_ON, + VOL_DOWN, + VOL_UP, + VOLUME, +) import voluptuous as vol -from homeassistant.components.media_player import ( - MediaPlayerDevice, MEDIA_PLAYER_SCHEMA, PLATFORM_SCHEMA) +from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerEntity 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) + 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) + 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, -}) +from .const import ( + ATTR_CMODE, + DATA_EPSON, + DEFAULT_NAME, + DOMAIN, + SERVICE_SELECT_CMODE, + SUPPORT_CMODE, +) +_LOGGER = logging.getLogger(__name__) -async def async_setup_platform( - hass, config, async_add_entities, discovery_info=None): +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 +) + +MEDIA_PLAYER_SCHEMA = vol.Schema({ATTR_ENTITY_ID: cv.comp_entity_ids}) + +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) + ssl = config[CONF_SSL] - epson = EpsonProjector(async_get_clientsession( - hass, verify_ssl=False), name, host, port, ssl) + epson_proj = 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) + hass.data[DATA_EPSON].append(epson_proj) + async_add_entities([epson_proj], 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] + 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: @@ -71,25 +116,21 @@ async def async_service_handler(service): 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)) - }) + 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) + DOMAIN, SERVICE_SELECT_CMODE, async_service_handler, schema=epson_schema + ) -class EpsonProjector(MediaPlayerDevice): +class EpsonProjector(MediaPlayerEntity): """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._projector = epson.Projector(host, websession=websession, port=port) self._cmode = None self._source_list = list(DEFAULT_SOURCES.values()) self._source = None @@ -98,9 +139,6 @@ def __init__(self, websession, name, host, port, encryption): 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]: @@ -134,13 +172,11 @@ def supported_features(self): 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) @@ -161,48 +197,39 @@ def volume_level(self): 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 diff --git a/homeassistant/components/epson/services.yaml b/homeassistant/components/epson/services.yaml index e69de29bb2d1d..a463cd355124e 100644 --- a/homeassistant/components/epson/services.yaml +++ b/homeassistant/components/epson/services.yaml @@ -0,0 +1,9 @@ +select_cmode: + description: Select Color mode of Epson projector + fields: + entity_id: + description: Name of projector + example: "media_player.epson_projector" + cmode: + description: Name of Cmode + example: "cinema" diff --git a/homeassistant/components/epsonworkforce/manifest.json b/homeassistant/components/epsonworkforce/manifest.json index 21f76c3a31f09..cd989b9c69023 100644 --- a/homeassistant/components/epsonworkforce/manifest.json +++ b/homeassistant/components/epsonworkforce/manifest.json @@ -1,9 +1,7 @@ { "domain": "epsonworkforce", "name": "Epson Workforce", - "documentation": "https://www.home-assistant.io/components/epsonworkforce", - "dependencies": [], + "documentation": "https://www.home-assistant.io/integrations/epsonworkforce", "codeowners": ["@ThaStealth"], "requirements": ["epsonprinter==0.0.9"] } - diff --git a/homeassistant/components/epsonworkforce/sensor.py b/homeassistant/components/epsonworkforce/sensor.py index 4f9ea4a1dd0dc..b216432554769 100644 --- a/homeassistant/components/epsonworkforce/sensor.py +++ b/homeassistant/components/epsonworkforce/sensor.py @@ -2,30 +2,32 @@ from datetime import timedelta import logging +from epsonprinter_pkg.epsonprinterapi import EpsonPrinterAPI import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import CONF_HOST, CONF_MONITORED_CONDITIONS +from homeassistant.const import CONF_HOST, CONF_MONITORED_CONDITIONS, UNIT_PERCENTAGE 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'], + "black": ["Ink level Black", UNIT_PERCENTAGE, "mdi:water"], + "photoblack": ["Ink level Photoblack", UNIT_PERCENTAGE, "mdi:water"], + "magenta": ["Ink level Magenta", UNIT_PERCENTAGE, "mdi:water"], + "cyan": ["Ink level Cyan", UNIT_PERCENTAGE, "mdi:water"], + "yellow": ["Ink level Yellow", UNIT_PERCENTAGE, "mdi:water"], + "clean": ["Cleaning level", UNIT_PERCENTAGE, "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)]), -}) +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) @@ -33,13 +35,14 @@ 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]] + sensors = [ + EpsonPrinterCartridge(api, condition) + for condition in config[CONF_MONITORED_CONDITIONS] + ] add_devices(sensors, True) diff --git a/homeassistant/components/eq3btsmart/climate.py b/homeassistant/components/eq3btsmart/climate.py index fc12438fcf37d..402dfc684b38e 100644 --- a/homeassistant/components/eq3btsmart/climate.py +++ b/homeassistant/components/eq3btsmart/climate.py @@ -1,39 +1,68 @@ """Support for eQ-3 Bluetooth Smart thermostats.""" import logging +# pylint: disable=import-error +from bluepy.btle import BTLEException +import eq3bt as eq3 # pylint: disable=import-error import voluptuous as vol -from homeassistant.components.climate import ClimateDevice, PLATFORM_SCHEMA +from homeassistant.components.climate import PLATFORM_SCHEMA, ClimateEntity from homeassistant.components.climate.const import ( - STATE_HEAT, STATE_MANUAL, STATE_ECO, - SUPPORT_TARGET_TEMPERATURE, SUPPORT_OPERATION_MODE, SUPPORT_AWAY_MODE, - SUPPORT_ON_OFF) + HVAC_MODE_AUTO, + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + PRESET_AWAY, + PRESET_BOOST, + PRESET_NONE, + SUPPORT_PRESET_MODE, + SUPPORT_TARGET_TEMPERATURE, +) from homeassistant.const import ( - ATTR_TEMPERATURE, CONF_MAC, CONF_DEVICES, STATE_ON, STATE_OFF, - TEMP_CELSIUS, PRECISION_HALVES) + ATTR_TEMPERATURE, + CONF_DEVICES, + CONF_MAC, + PRECISION_HALVES, + TEMP_CELSIUS, +) import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -STATE_BOOST = 'boost' +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' +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, -}) +EQ_TO_HA_HVAC = { + eq3.Mode.Open: HVAC_MODE_HEAT, + eq3.Mode.Closed: HVAC_MODE_OFF, + eq3.Mode.Auto: HVAC_MODE_AUTO, + eq3.Mode.Manual: HVAC_MODE_HEAT, + eq3.Mode.Boost: HVAC_MODE_AUTO, + eq3.Mode.Away: HVAC_MODE_HEAT, +} -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_DEVICES): - vol.Schema({cv.string: DEVICE_SCHEMA}), -}) +HA_TO_EQ_HVAC = { + HVAC_MODE_HEAT: eq3.Mode.Manual, + HVAC_MODE_OFF: eq3.Mode.Closed, + HVAC_MODE_AUTO: eq3.Mode.Auto, +} -SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE | - SUPPORT_AWAY_MODE | SUPPORT_ON_OFF) +EQ_TO_HA_PRESET = {eq3.Mode.Boost: PRESET_BOOST, eq3.Mode.Away: PRESET_AWAY} + +HA_TO_EQ_PRESET = {PRESET_BOOST: eq3.Mode.Boost, PRESET_AWAY: eq3.Mode.Away} + + +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_PRESET_MODE def setup_platform(hass, config, add_entities, discovery_info=None): @@ -44,32 +73,17 @@ def setup_platform(hass, config, add_entities, discovery_info=None): mac = device_cfg[CONF_MAC] devices.append(EQ3BTSmartThermostat(mac, name)) - add_entities(devices) + add_entities(devices, True) -class EQ3BTSmartThermostat(ClimateDevice): +class EQ3BTSmartThermostat(ClimateEntity): """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): @@ -79,7 +93,7 @@ def supported_features(self): @property def available(self) -> bool: """Return if thermostat is available.""" - return self.current_operation is not None + return self._thermostat.mode >= 0 @property def name(self): @@ -111,46 +125,25 @@ def set_temperature(self, **kwargs): temperature = kwargs.get(ATTR_TEMPERATURE) if temperature is None: return - self._target_temperature = temperature self._thermostat.target_temperature = temperature @property - def current_operation(self): + def hvac_mode(self): """Return the current operation mode.""" if self._thermostat.mode < 0: - return None - return self.modes[self._thermostat.mode] + return HVAC_MODE_OFF + return EQ_TO_HA_HVAC[self._thermostat.mode] @property - def operation_list(self): + def hvac_modes(self): """Return the list of available operation modes.""" - return [x for x in self.modes.values()] + return list(HA_TO_EQ_HVAC.keys()) - def set_operation_mode(self, operation_mode): + def set_hvac_mode(self, hvac_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) + if self.preset_mode: + return + self._thermostat.mode = HA_TO_EQ_HVAC[hvac_mode] @property def min_temp(self): @@ -175,23 +168,32 @@ def device_state_attributes(self): return dev_specific + @property + def preset_mode(self): + """Return the current preset mode, e.g., home, away, temp. + + Requires SUPPORT_PRESET_MODE. + """ + return EQ_TO_HA_PRESET.get(self._thermostat.mode) + + @property + def preset_modes(self): + """Return a list of available preset modes. + + Requires SUPPORT_PRESET_MODE. + """ + return list(HA_TO_EQ_PRESET.keys()) + + def set_preset_mode(self, preset_mode): + """Set new preset mode.""" + if preset_mode == PRESET_NONE: + self.set_hvac_mode(HVAC_MODE_HEAT) + self._thermostat.mode = HA_TO_EQ_PRESET[preset_mode] + 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 index 6d13c79bcec09..e15fd8d384bce 100644 --- a/homeassistant/components/eq3btsmart/manifest.json +++ b/homeassistant/components/eq3btsmart/manifest.json @@ -1,13 +1,7 @@ { "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" - ] + "name": "EQ3 Bluetooth Smart Thermostats", + "documentation": "https://www.home-assistant.io/integrations/eq3btsmart", + "requirements": ["construct==2.9.45", "python-eq3bt==0.1.11"], + "codeowners": ["@rytilahti"] } diff --git a/homeassistant/components/esphome/.translations/bg.json b/homeassistant/components/esphome/.translations/bg.json deleted file mode 100644 index 3574965cae61c..0000000000000 --- a/homeassistant/components/esphome/.translations/bg.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "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 deleted file mode 100644 index f9c60979c8d86..0000000000000 --- a/homeassistant/components/esphome/.translations/ca.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "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" - }, - "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 deleted file mode 100644 index 081275d3defc9..0000000000000 --- a/homeassistant/components/esphome/.translations/cs.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "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 deleted file mode 100644 index 76389c451493a..0000000000000 --- a/homeassistant/components/esphome/.translations/da.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "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 deleted file mode 100644 index 30cbf09525f88..0000000000000 --- a/homeassistant/components/esphome/.translations/de.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "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" - }, - "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 deleted file mode 100644 index 3a73e54c34558..0000000000000 --- a/homeassistant/components/esphome/.translations/en.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "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" - }, - "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 deleted file mode 100644 index 58dbba34fa838..0000000000000 --- a/homeassistant/components/esphome/.translations/es-419.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "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 deleted file mode 100644 index 88730a18554e9..0000000000000 --- a/homeassistant/components/esphome/.translations/es.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "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 deleted file mode 100644 index b230a73c354ed..0000000000000 --- a/homeassistant/components/esphome/.translations/fr.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "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" - }, - "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 deleted file mode 100644 index c665637ba0524..0000000000000 --- a/homeassistant/components/esphome/.translations/hu.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "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" - }, - "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 deleted file mode 100644 index 837d18d27ad06..0000000000000 --- a/homeassistant/components/esphome/.translations/id.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "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 deleted file mode 100644 index 47047a9556059..0000000000000 --- a/homeassistant/components/esphome/.translations/it.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "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 deleted file mode 100644 index f58d43f9df9ae..0000000000000 --- a/homeassistant/components/esphome/.translations/ko.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "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" - }, - "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 deleted file mode 100644 index a240debfaf5af..0000000000000 --- a/homeassistant/components/esphome/.translations/lb.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "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 deleted file mode 100644 index aba738f4e0f6f..0000000000000 --- a/homeassistant/components/esphome/.translations/nl.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "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 deleted file mode 100644 index 830391f58f6e3..0000000000000 --- a/homeassistant/components/esphome/.translations/nn.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "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 deleted file mode 100644 index c71424b6f00e5..0000000000000 --- a/homeassistant/components/esphome/.translations/no.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "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)" - }, - "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 deleted file mode 100644 index 5693efde9a8d5..0000000000000 --- a/homeassistant/components/esphome/.translations/pl.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "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" - }, - "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 deleted file mode 100644 index 87adc69021c69..0000000000000 --- a/homeassistant/components/esphome/.translations/pt-BR.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "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" - }, - "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 deleted file mode 100644 index 7e4a85f351486..0000000000000 --- a/homeassistant/components/esphome/.translations/pt.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "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 deleted file mode 100644 index 9777a920a944e..0000000000000 --- a/homeassistant/components/esphome/.translations/ru.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "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" - }, - "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 deleted file mode 100644 index 93ca607aabec0..0000000000000 --- a/homeassistant/components/esphome/.translations/sl.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "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" - }, - "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 deleted file mode 100644 index da977af601ab3..0000000000000 --- a/homeassistant/components/esphome/.translations/sv.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "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" - }, - "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 deleted file mode 100644 index ceab9b6e11b78..0000000000000 --- a/homeassistant/components/esphome/.translations/th.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "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 deleted file mode 100644 index 79c9e70bcc84d..0000000000000 --- a/homeassistant/components/esphome/.translations/uk.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "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 deleted file mode 100644 index 46790868aba61..0000000000000 --- a/homeassistant/components/esphome/.translations/zh-Hans.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "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 deleted file mode 100644 index 9a5821f0b8fe4..0000000000000 --- a/homeassistant/components/esphome/.translations/zh-Hant.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "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" - }, - "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 index e5feedd84215a..3895e172024ef 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -2,157 +2,54 @@ import asyncio import logging import math -from typing import Any, Dict, List, Optional, TYPE_CHECKING, Callable, Tuple - -import attr +from typing import Any, Callable, Dict, List, Optional + +from aioesphomeapi import ( + APIClient, + APIConnectionError, + DeviceInfo, + EntityInfo, + EntityState, + HomeassistantServiceCall, + 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 callback, Event, State -import homeassistant.helpers.device_registry as dr +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 -import homeassistant.helpers.config_validation as cv from homeassistant.helpers import template -from homeassistant.helpers.dispatcher import async_dispatcher_connect, \ - async_dispatcher_send +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.template import Template from homeassistant.helpers.json import JSONEncoder from homeassistant.helpers.storage import Store -from homeassistant.helpers.typing import HomeAssistantType, ConfigType +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 - -if TYPE_CHECKING: - from aioesphomeapi import APIClient, EntityInfo, EntityState, DeviceInfo, \ - ServiceCall, UserService +from .config_flow import EsphomeFlowHandler # noqa: F401 +from .entry_data import DATA_KEY, RuntimeEntryData -DOMAIN = 'esphome' +DOMAIN = "esphome" _LOGGER = logging.getLogger(__name__) -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' - -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) -@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.""" - # pylint: disable= redefined-outer-name - from aioesphomeapi import COMPONENT_TYPE_TO_INFO, DeviceInfo, \ - UserService - - 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}) - - async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: """Stub to allow setting up this component. @@ -161,65 +58,82 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistantType, - entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool: """Set up the esphome component.""" - # pylint: disable=redefined-outer-name - from aioesphomeapi import APIClient, APIConnectionError - - hass.data.setdefault(DOMAIN, {}) + 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__)) + cli = APIClient( + hass.loop, + host, + port, + password, + client_info=f"Home Assistant {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[DOMAIN][entry.entry_id] = RuntimeEntryData( - client=cli, - entry_id=entry.entry_id, - store=store, + store = Store( + hass, STORAGE_VERSION, f"esphome.{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) + # Use async_listen instead of async_listen_once so that we don't deregister + # the callback twice when shutting down Home Assistant. + # "Unable to remove unknown listener .onetime_listener>" entry_data.cleanup_callbacks.append( - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, on_stop) + hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, on_stop) ) @callback - def async_on_state(state: 'EntityState') -> None: + 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: + def async_on_service_call(service: HomeassistantServiceCall) -> None: """Call service when user automation in ESPHome config is triggered.""" - domain, service_name = service.service.split('.', 1) + 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()} + 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)) + service_data.update( + template.render_complex(data_template, service.variables) + ) except TemplateError as ex: - _LOGGER.error('Error rendering data template: %s', 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: + if service.is_event: + # ESPHome uses servicecall packet for both events and service calls + # Ensure the user can only send events of form 'esphome.xyz' + if domain != "esphome": + _LOGGER.error("Can only generate events under esphome domain!") + return + hass.bus.async_fire(service.service, service_data) + else: + 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 @@ -228,29 +142,27 @@ async def send_home_assistant_state(entity_id: str, _, @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) + 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))) + 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) + 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 entry_data.async_update_static_infos(hass, entry, 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) + await cli.subscribe_home_assistant_states(async_on_state_subscription) hass.async_create_task(entry_data.async_save_to_store()) except APIConnectionError as err: @@ -258,55 +170,26 @@ async def on_login() -> None: # Re-connection logic will trigger after this await cli.disconnect() - try_connect = await _setup_auto_reconnect_logic(hass, cli, entry, host, - on_login) - - # This is a bit of a hack: We schedule complete_setup into the - # event loop and return immediately (return True) - # - # Usually, we should avoid that so that HA can track which components - # have been started successfully and which failed to be set up. - # That doesn't work here for two reasons: - # - We have our own re-connect logic - # - Before we do the first try_connect() call, we need to make sure - # all dispatcher event listeners have been connected, so - # async_forward_entry_setup needs to be awaited. However, if we - # would await async_forward_entry_setup() in async_setup_entry(), - # we would end up with a deadlock. - # - # Solution is: complete the setup outside of the async_setup_entry() - # function. HA will wait until the first connection attempt is made - # before starting up (as it should), but if the first connection attempt - # fails we will schedule all next re-connect attempts outside of the - # tracked tasks (hass.loop.create_task). This way HA won't stall startup - # forever until a connection is successful. + 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 entry_data.async_update_static_infos(hass, entry, infos) await _setup_services(hass, entry_data, services) - # If first connect fails, the next re-connect will be scheduled - # outside of _pending_task, in order not to delay HA startup - # indefinitely - await try_connect(is_disconnect=False) + # 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): +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.""" - from aioesphomeapi import APIConnectionError async def try_connect(tries: int = 0, is_disconnect: bool = True) -> None: """Try connecting to the API client. Will retry if not successful.""" @@ -314,7 +197,7 @@ async def try_connect(tries: int = 0, is_disconnect: bool = True) -> None: # When removing/disconnecting manually return - data = hass.data[DOMAIN][entry.entry_id] # type: RuntimeEntryData + data: RuntimeEntryData = hass.data[DOMAIN][entry.entry_id] for disconnect_cb in data.disconnect_callbacks: disconnect_cb() data.disconnect_callbacks = [] @@ -339,19 +222,19 @@ async def try_connect(tries: int = 0, is_disconnect: bool = True) -> None: # 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))) + 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) + _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)) + try_connect(tries + 1, is_disconnect=False) + ) else: _LOGGER.info("Successfully connected to %s", host) hass.async_create_task(on_login()) @@ -359,31 +242,28 @@ async def try_connect(tries: int = 0, is_disconnect: bool = True) -> None: return try_connect -async def _async_setup_device_registry(hass: HomeAssistantType, - entry: ConfigEntry, - device_info: 'DeviceInfo'): +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 + sw_version = device_info.esphome_version if device_info.compilation_time: - sw_version += ' ({})'.format(device_info.compilation_time) + sw_version += f" ({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) - }, + connections={(dr.CONNECTION_NETWORK_MAC, device_info.mac_address)}, name=device_info.name, - manufacturer='espressif', + manufacturer="espressif", model=device_info.model, sw_version=sw_version, ) -async def _register_service(hass: HomeAssistantType, - entry_data: RuntimeEntryData, - service: 'UserService'): - from aioesphomeapi import UserServiceArgType - service_name = '{}_{}'.format(entry_data.device_info.name, service.name) +async def _register_service( + hass: HomeAssistantType, entry_data: RuntimeEntryData, service: UserService +): + service_name = f"{entry_data.device_info.name}_{service.name}" schema = {} for arg in service.args: schema[vol.Required(arg.name)] = { @@ -391,18 +271,23 @@ async def _register_service(hass: HomeAssistantType, UserServiceArgType.INT: vol.Coerce(int), UserServiceArgType.FLOAT: vol.Coerce(float), UserServiceArgType.STRING: cv.string, + UserServiceArgType.BOOL_ARRAY: [cv.boolean], + UserServiceArgType.INT_ARRAY: [vol.Coerce(int)], + UserServiceArgType.FLOAT_ARRAY: [vol.Coerce(float)], + UserServiceArgType.STRING_ARRAY: [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)) + hass.services.async_register( + DOMAIN, service_name, execute_service, vol.Schema(schema) + ) -async def _setup_services(hass: HomeAssistantType, - entry_data: RuntimeEntryData, - services: List['UserService']): +async def _setup_services( + hass: HomeAssistantType, entry_data: RuntimeEntryData, services: List[UserService] +): old_services = entry_data.services.copy() to_unregister = [] to_register = [] @@ -424,18 +309,18 @@ async def _setup_services(hass: HomeAssistantType, 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) + service_name = f"{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: +async def _cleanup_instance( + hass: HomeAssistantType, entry: ConfigEntry +) -> RuntimeEntryData: """Cleanup the esphome client if it exists.""" - data = hass.data[DOMAIN].pop(entry.entry_id) # type: RuntimeEntryData + data: RuntimeEntryData = hass.data[DATA_KEY].pop(entry.entry_id) if data.reconnect_task is not None: data.reconnect_task.cancel() for disconnect_cb in data.disconnect_callbacks: @@ -443,42 +328,42 @@ async def _cleanup_instance(hass: HomeAssistantType, for cleanup_callback in data.cleanup_callbacks: cleanup_callback() await data.client.disconnect() + return data -async def async_unload_entry(hass: HomeAssistantType, - entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool: """Unload an esphome config entry.""" - await _cleanup_instance(hass, entry) - + entry_data = 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) - + for platform in entry_data.loaded_platforms: + tasks.append(hass.config_entries.async_forward_entry_unload(entry, platform)) + if tasks: + 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: +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: RuntimeEntryData = hass.data[DOMAIN][entry.entry_id] entry_data.info[component_key] = {} + entry_data.old_info[component_key] = {} entry_data.state[component_key] = {} @callback - def async_list_entities(infos: List['EntityInfo']): + 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 = {} @@ -500,23 +385,29 @@ def async_list_entities(infos: List['EntityInfo']): # Remove old entities for info in old_infos.values(): entry_data.async_remove_entity(hass, component_key, info.key) + + # First copy the now-old info into the backup object + entry_data.old_info[component_key] = entry_data.info[component_key] + # Then update the actual info entry_data.info[component_key] = new_infos + + # Add entities to Home Assistant async_add_entities(add_entities) - signal = DISPATCHER_ON_LIST.format(entry_id=entry.entry_id) + signal = f"esphome_{entry.entry_id}_on_list" entry_data.cleanup_callbacks.append( async_dispatcher_connect(hass, signal, async_list_entities) ) @callback - def async_entity_state(state: 'EntityState'): + 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) + signal = f"esphome_{entry.entry_id}_on_state" entry_data.cleanup_callbacks.append( async_dispatcher_connect(hass, signal, async_entity_state) ) @@ -528,8 +419,10 @@ def esphome_state_property(func): 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) @@ -538,6 +431,7 @@ def _wrapper(self): # (not JSON serializable) return None return val + return _wrapper @@ -575,36 +469,57 @@ def __init__(self, entry_id: str, component_key: str, key: int): self._entry_id = entry_id self._component_key = component_key self._key = key - self._remove_callbacks = [] # type: List[Callable[[], None]] + self._remove_callbacks: 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, + "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) + async_dispatcher_connect( + self.hass, + ( + f"esphome_{kwargs.get('entry_id')}" + f"_update_{kwargs.get('component_key')}_{kwargs.get('key')}" + ), + self._on_state_update, + ) ) self._remove_callbacks.append( - async_dispatcher_connect(self.hass, - DISPATCHER_REMOVE_ENTITY.format(**kwargs), - self.async_remove) + async_dispatcher_connect( + self.hass, + ( + f"esphome_{kwargs.get('entry_id')}_remove_" + f"{kwargs.get('component_key')}_{kwargs.get('key')}" + ), + self.async_remove, + ) ) self._remove_callbacks.append( async_dispatcher_connect( - self.hass, DISPATCHER_ON_DEVICE_UPDATE.format(**kwargs), - self.async_schedule_update_ha_state) + self.hass, + f"esphome_{kwargs.get('entry_id')}_on_device_update", + self._on_device_update, + ) ) - async def _on_update(self) -> None: + async def _on_state_update(self) -> None: """Update the entity state when state or static info changed.""" - self.async_schedule_update_ha_state() + self.async_write_ha_state() + + async def _on_device_update(self) -> None: + """Update the entity state when device info has changed.""" + if self._entry_data.available: + # Don't update the HA state yet when the device comes online. + # Only update the HA state when the full state arrives + # through the next entity state packet. + return + self.async_write_ha_state() async def async_will_remove_from_hass(self) -> None: """Unregister callbacks.""" @@ -614,22 +529,28 @@ async def async_will_remove_from_hass(self) -> None: @property def _entry_data(self) -> RuntimeEntryData: - return self.hass.data[DOMAIN][self._entry_id] + 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] + def _static_info(self) -> EntityInfo: + # Check if value is in info database. Use a single lookup. + info = self._entry_data.info[self._component_key].get(self._key) + if info is not None: + return info + # This entity is in the removal project and has been removed from .info + # already, look in old_info + return self._entry_data.old_info[self._component_key].get(self._key) @property - def _device_info(self) -> 'DeviceInfo': + def _device_info(self) -> DeviceInfo: return self._entry_data.device_info @property - def _client(self) -> 'APIClient': + def _client(self) -> APIClient: return self._entry_data.client @property - def _state(self) -> 'Optional[EntityState]': + def _state(self) -> Optional[EntityState]: try: return self._entry_data.state[self._component_key][self._key] except KeyError: @@ -658,8 +579,7 @@ def unique_id(self) -> Optional[str]: 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)} + "connections": {(dr.CONNECTION_NETWORK_MAC, self._device_info.mac_address)} } @property diff --git a/homeassistant/components/esphome/binary_sensor.py b/homeassistant/components/esphome/binary_sensor.py index 6a6f9bfac1c96..d605a48410b89 100644 --- a/homeassistant/components/esphome/binary_sensor.py +++ b/homeassistant/components/esphome/binary_sensor.py @@ -1,40 +1,35 @@ """Support for ESPHome binary sensors.""" -import logging -from typing import TYPE_CHECKING, Optional +from typing import Optional -from homeassistant.components.binary_sensor import BinarySensorDevice +from aioesphomeapi import BinarySensorInfo, BinarySensorState -from . import EsphomeEntity, platform_async_setup_entry - -if TYPE_CHECKING: - # pylint: disable=unused-import - from aioesphomeapi import BinarySensorInfo, BinarySensorState # noqa +from homeassistant.components.binary_sensor import BinarySensorEntity -_LOGGER = logging.getLogger(__name__) +from . import EsphomeEntity, platform_async_setup_entry async def async_setup_entry(hass, entry, async_add_entities): """Set up ESPHome binary sensors based on a config entry.""" - # pylint: disable=redefined-outer-name - from aioesphomeapi import BinarySensorInfo, BinarySensorState # noqa - await platform_async_setup_entry( - hass, entry, async_add_entities, - component_key='binary_sensor', - info_type=BinarySensorInfo, entity_type=EsphomeBinarySensor, - state_type=BinarySensorState + hass, + entry, + async_add_entities, + component_key="binary_sensor", + info_type=BinarySensorInfo, + entity_type=EsphomeBinarySensor, + state_type=BinarySensorState, ) -class EsphomeBinarySensor(EsphomeEntity, BinarySensorDevice): +class EsphomeBinarySensor(EsphomeEntity, BinarySensorEntity): """A binary sensor implementation for ESPHome.""" @property - def _static_info(self) -> 'BinarySensorInfo': + def _static_info(self) -> BinarySensorInfo: return super()._static_info @property - def _state(self) -> Optional['BinarySensorState']: + def _state(self) -> Optional[BinarySensorState]: return super()._state @property @@ -46,6 +41,8 @@ def is_on(self) -> Optional[bool]: return self._entry_data.available if self._state is None: return None + if self._state.missing_state: + return None return self._state.state @property diff --git a/homeassistant/components/esphome/camera.py b/homeassistant/components/esphome/camera.py index 64e73dc8784f9..c3615c4726d40 100644 --- a/homeassistant/components/esphome/camera.py +++ b/homeassistant/components/esphome/camera.py @@ -1,32 +1,32 @@ """Support for ESPHome cameras.""" import asyncio import logging -from typing import Optional, TYPE_CHECKING +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 -if TYPE_CHECKING: - # pylint: disable=unused-import - from aioesphomeapi import CameraInfo, CameraState # noqa +from . import EsphomeEntity, platform_async_setup_entry _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistantType, - entry: ConfigEntry, async_add_entities) -> None: +async def async_setup_entry( + hass: HomeAssistantType, entry: ConfigEntry, async_add_entities +) -> None: """Set up esphome cameras based on a config entry.""" - # pylint: disable=redefined-outer-name - from aioesphomeapi import CameraInfo, CameraState # noqa - await platform_async_setup_entry( - hass, entry, async_add_entities, - component_key='camera', - info_type=CameraInfo, entity_type=EsphomeCamera, - state_type=CameraState + hass, + entry, + async_add_entities, + component_key="camera", + info_type=CameraInfo, + entity_type=EsphomeCamera, + state_type=CameraState, ) @@ -40,16 +40,16 @@ def __init__(self, entry_id: str, component_key: str, key: int): self._image_cond = asyncio.Condition() @property - def _static_info(self) -> 'CameraInfo': + def _static_info(self) -> CameraInfo: return super()._static_info @property - def _state(self) -> Optional['CameraState']: + def _state(self) -> Optional[CameraState]: return super()._state - async def _on_update(self) -> None: + async def _on_state_update(self) -> None: """Notify listeners of new image when update arrives.""" - await super()._on_update() + await super()._on_state_update() async with self._image_cond: self._image_cond.notify_all() @@ -78,5 +78,5 @@ async def _async_camera_stream_image(self) -> Optional[bytes]: 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) + 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 index 184eb4b6270de..46ed214afba4c 100644 --- a/homeassistant/components/esphome/climate.py +++ b/homeassistant/components/esphome/climate.py @@ -1,61 +1,143 @@ """Support for ESPHome climate devices.""" import logging -from typing import TYPE_CHECKING, List, Optional +from typing import List, Optional -from homeassistant.components.climate import ClimateDevice +from aioesphomeapi import ( + ClimateAction, + ClimateFanMode, + ClimateInfo, + ClimateMode, + ClimateState, + ClimateSwingMode, +) + +from homeassistant.components.climate import ClimateEntity 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) + ATTR_HVAC_MODE, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + CURRENT_HVAC_COOL, + CURRENT_HVAC_DRY, + CURRENT_HVAC_FAN, + CURRENT_HVAC_HEAT, + CURRENT_HVAC_IDLE, + CURRENT_HVAC_OFF, + FAN_AUTO, + FAN_DIFFUSE, + FAN_FOCUS, + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, + FAN_MIDDLE, + FAN_OFF, + FAN_ON, + HVAC_MODE_COOL, + HVAC_MODE_DRY, + HVAC_MODE_FAN_ONLY, + HVAC_MODE_HEAT, + HVAC_MODE_HEAT_COOL, + HVAC_MODE_OFF, + PRESET_AWAY, + PRESET_HOME, + SUPPORT_FAN_MODE, + SUPPORT_PRESET_MODE, + SUPPORT_SWING_MODE, + SUPPORT_TARGET_TEMPERATURE, + SUPPORT_TARGET_TEMPERATURE_RANGE, + SWING_BOTH, + SWING_HORIZONTAL, + SWING_OFF, + SWING_VERTICAL, +) from homeassistant.const import ( - ATTR_TEMPERATURE, PRECISION_HALVES, PRECISION_TENTHS, PRECISION_WHOLE, - STATE_OFF, TEMP_CELSIUS) - -from . import EsphomeEntity, platform_async_setup_entry, \ - esphome_state_property, esphome_map_enum + ATTR_TEMPERATURE, + PRECISION_HALVES, + PRECISION_TENTHS, + PRECISION_WHOLE, + TEMP_CELSIUS, +) -if TYPE_CHECKING: - # pylint: disable=unused-import - from aioesphomeapi import ClimateInfo, ClimateState, ClimateMode # noqa +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.""" - # pylint: disable=redefined-outer-name - from aioesphomeapi import ClimateInfo, ClimateState # noqa - await platform_async_setup_entry( - hass, entry, async_add_entities, - component_key='climate', - info_type=ClimateInfo, entity_type=EsphomeClimateDevice, - state_type=ClimateState + hass, + entry, + async_add_entities, + component_key="climate", + info_type=ClimateInfo, + entity_type=EsphomeClimateEntity, + state_type=ClimateState, ) @esphome_map_enum def _climate_modes(): - # pylint: disable=redefined-outer-name - from aioesphomeapi import ClimateMode # noqa return { - ClimateMode.OFF: STATE_OFF, - ClimateMode.AUTO: STATE_AUTO, - ClimateMode.COOL: STATE_COOL, - ClimateMode.HEAT: STATE_HEAT, + ClimateMode.OFF: HVAC_MODE_OFF, + ClimateMode.AUTO: HVAC_MODE_HEAT_COOL, + ClimateMode.COOL: HVAC_MODE_COOL, + ClimateMode.HEAT: HVAC_MODE_HEAT, + ClimateMode.FAN_ONLY: HVAC_MODE_FAN_ONLY, + ClimateMode.DRY: HVAC_MODE_DRY, + } + + +@esphome_map_enum +def _climate_actions(): + return { + ClimateAction.OFF: CURRENT_HVAC_OFF, + ClimateAction.COOLING: CURRENT_HVAC_COOL, + ClimateAction.HEATING: CURRENT_HVAC_HEAT, + ClimateAction.IDLE: CURRENT_HVAC_IDLE, + ClimateAction.DRYING: CURRENT_HVAC_DRY, + ClimateAction.FAN: CURRENT_HVAC_FAN, + } + + +@esphome_map_enum +def _fan_modes(): + return { + ClimateFanMode.ON: FAN_ON, + ClimateFanMode.OFF: FAN_OFF, + ClimateFanMode.AUTO: FAN_AUTO, + ClimateFanMode.LOW: FAN_LOW, + ClimateFanMode.MEDIUM: FAN_MEDIUM, + ClimateFanMode.HIGH: FAN_HIGH, + ClimateFanMode.MIDDLE: FAN_MIDDLE, + ClimateFanMode.FOCUS: FAN_FOCUS, + ClimateFanMode.DIFFUSE: FAN_DIFFUSE, } -class EsphomeClimateDevice(EsphomeEntity, ClimateDevice): +@esphome_map_enum +def _swing_modes(): + return { + ClimateSwingMode.OFF: SWING_OFF, + ClimateSwingMode.BOTH: SWING_BOTH, + ClimateSwingMode.VERTICAL: SWING_VERTICAL, + ClimateSwingMode.HORIZONTAL: SWING_HORIZONTAL, + } + + +class EsphomeClimateEntity(EsphomeEntity, ClimateEntity): """A climate implementation for ESPHome.""" @property - def _static_info(self) -> 'ClimateInfo': + def _static_info(self) -> ClimateInfo: return super()._static_info @property - def _state(self) -> Optional['ClimateState']: + def _state(self) -> Optional[ClimateState]: return super()._state @property @@ -74,13 +156,34 @@ def temperature_unit(self) -> str: return TEMP_CELSIUS @property - def operation_list(self) -> List[str]: + def hvac_modes(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 fan_modes(self): + """Return the list of available fan modes.""" + return [ + _fan_modes.from_esphome(mode) + for mode in self._static_info.supported_fan_modes + ] + + @property + def preset_modes(self): + """Return preset modes.""" + return [PRESET_AWAY, PRESET_HOME] if self._static_info.supports_away else [] + + @property + def swing_modes(self): + """Return the list of available swing modes.""" + return [ + _swing_modes.from_esphome(mode) + for mode in self._static_info.supported_swing_modes + ] + @property def target_temperature_step(self) -> float: """Return the supported step of target temperature.""" @@ -100,21 +203,50 @@ def max_temp(self) -> float: @property def supported_features(self) -> int: """Return the list of supported features.""" - features = SUPPORT_OPERATION_MODE + features = 0 if self._static_info.supports_two_point_target_temperature: - features |= (SUPPORT_TARGET_TEMPERATURE_LOW | - SUPPORT_TARGET_TEMPERATURE_HIGH) + features |= SUPPORT_TARGET_TEMPERATURE_RANGE else: features |= SUPPORT_TARGET_TEMPERATURE if self._static_info.supports_away: - features |= SUPPORT_AWAY_MODE + features |= SUPPORT_PRESET_MODE + if self._static_info.supported_fan_modes: + features |= SUPPORT_FAN_MODE + if self._static_info.supported_swing_modes: + features |= SUPPORT_SWING_MODE return features + # https://github.com/PyCQA/pylint/issues/3150 for all @esphome_state_property + # pylint: disable=invalid-overridden-method + @esphome_state_property - def current_operation(self) -> Optional[str]: + def hvac_mode(self) -> Optional[str]: """Return current operation ie. heat, cool, idle.""" return _climate_modes.from_esphome(self._state.mode) + @esphome_state_property + def hvac_action(self) -> Optional[str]: + """Return current action.""" + # HA has no support feature field for hvac_action + if not self._static_info.supports_action: + return None + return _climate_actions.from_esphome(self._state.action) + + @esphome_state_property + def fan_mode(self): + """Return current fan setting.""" + return _fan_modes.from_esphome(self._state.fan_mode) + + @esphome_state_property + def preset_mode(self): + """Return current preset mode.""" + return PRESET_AWAY if self._state.away else PRESET_HOME + + @esphome_state_property + def swing_mode(self): + """Return current swing mode.""" + return _swing_modes.from_esphome(self._state.swing_mode) + @esphome_state_property def current_temperature(self) -> Optional[float]: """Return the current temperature.""" @@ -135,38 +267,38 @@ 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]) + data = {"key": self._static_info.key} + if ATTR_HVAC_MODE in kwargs: + data["mode"] = _climate_modes.from_hass(kwargs[ATTR_HVAC_MODE]) if ATTR_TEMPERATURE in kwargs: - data['target_temperature'] = kwargs[ATTR_TEMPERATURE] + data["target_temperature"] = kwargs[ATTR_TEMPERATURE] if ATTR_TARGET_TEMP_LOW in kwargs: - data['target_temperature_low'] = kwargs[ATTR_TARGET_TEMP_LOW] + 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] + 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: + async def async_set_hvac_mode(self, hvac_mode: str) -> None: """Set new target operation mode.""" await self._client.climate_command( - key=self._static_info.key, - mode=_climate_modes.from_hass(operation_mode), + key=self._static_info.key, mode=_climate_modes.from_hass(hvac_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_set_preset_mode(self, preset_mode): + """Set preset mode.""" + away = preset_mode == PRESET_AWAY + await self._client.climate_command(key=self._static_info.key, away=away) - 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) + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set new fan mode.""" + await self._client.climate_command( + key=self._static_info.key, fan_mode=_fan_modes.from_hass(fan_mode) + ) + + async def async_set_swing_mode(self, swing_mode: str) -> None: + """Set new swing mode.""" + await self._client.climate_command( + key=self._static_info.key, swing_mode=_swing_modes.from_hass(swing_mode) + ) diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py index f6b8bb9abd7f1..cb9b7958efa26 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -2,103 +2,151 @@ from collections import OrderedDict from typing import Optional +from aioesphomeapi import APIClient, APIConnectionError import voluptuous as vol -from homeassistant import config_entries -from homeassistant.helpers import ConfigType +from homeassistant.config_entries import CONN_CLASS_LOCAL_PUSH, ConfigFlow +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT +from homeassistant.core import callback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from .entry_data import DATA_KEY, RuntimeEntryData -@config_entries.HANDLERS.register('esphome') -class EsphomeFlowHandler(config_entries.ConfigFlow): +DOMAIN = "esphome" + + +class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a esphome config flow.""" VERSION = 1 - CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH + CONNECTION_CLASS = 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] + self._host: Optional[str] = None + self._port: Optional[int] = None + self._password: Optional[str] = None - async def async_step_user(self, user_input: Optional[ConfigType] = None, - error: Optional[str] = None): + 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 + fields[vol.Required(CONF_HOST, default=self._host or vol.UNDEFINED)] = str + fields[vol.Optional(CONF_PORT, default=self._port or 6053)] = int errors = {} if error is not None: - errors['base'] = error + errors["base"] = error return self.async_show_form( - step_id='user', - data_schema=vol.Schema(fields), - errors=errors + 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'] + @property + def _name(self): + # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 + return self.context.get(CONF_NAME) + + @_name.setter + def _name(self, value): + # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 + self.context[CONF_NAME] = value + self.context["title_placeholders"] = {"name": self._name} + + def _set_user_input(self, user_input): + if user_input is None: + return + self._host = user_input[CONF_HOST] + self._port = user_input[CONF_PORT] + + async def _async_authenticate_or_add(self, user_input): + self._set_user_input(user_input) 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 + # 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 await self._async_authenticate_or_add(None) return self.async_show_form( - step_id='discovery_confirm', - description_placeholders={'name': self._name}, + step_id="discovery_confirm", description_placeholders={"name": self._name} ) - async def async_step_discovery(self, user_input: ConfigType): - """Handle discovery.""" - address = user_input['properties'].get( - 'address', user_input['hostname'][:-1]) - for entry in self._async_current_entries(): - if entry.data['host'] == address: - 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) + async def async_step_zeroconf(self, discovery_info: DiscoveryInfoType): + """Handle zeroconf discovery.""" + # Hostname is format: livingroom.local. + local_name = discovery_info["hostname"][:-1] + node_name = local_name[: -len(".local")] + address = discovery_info["properties"].get("address", local_name) + + # Check if already configured + await self.async_set_unique_id(node_name) + self._abort_if_unique_id_configured( + updates={CONF_HOST: discovery_info[CONF_HOST]} + ) + for entry in self._async_current_entries(): + already_configured = False + + if ( + entry.data[CONF_HOST] == address + or entry.data[CONF_HOST] == discovery_info[CONF_HOST] + ): + # Is this address or IP 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: RuntimeEntryData = self.hass.data[DATA_KEY][entry.entry_id] + + # 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: + # Backwards compat, we update old entries + if not entry.unique_id: + self.hass.config_entries.async_update_entry( + entry, + data={**entry.data, CONF_HOST: discovery_info[CONF_HOST]}, + unique_id=node_name, + ) + + return self.async_abort(reason="already_configured") + + self._host = discovery_info[CONF_HOST] + self._port = discovery_info[CONF_PORT] + self._name = node_name + + return await self.async_step_discovery_confirm() + + @callback def _async_get_entry(self): return self.async_create_entry( title=self._name, data={ - 'host': self._host, - 'port': self._port, + CONF_HOST: self._host, + CONF_PORT: self._port, # The API uses protobuf, so empty string denotes absence - 'password': self._password or '', - } + CONF_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'] + self._password = user_input[CONF_PASSWORD] error = await self.try_login() if error: return await self.async_step_authenticate(error=error) @@ -106,30 +154,26 @@ async def async_step_authenticate(self, user_input=None, error=None): errors = {} if error is not None: - errors['base'] = error + 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 + 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, '') + 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 + if "resolving" in str(err): + return "resolve_error", None + return "connection_error", None finally: await cli.disconnect(force=True) @@ -137,14 +181,12 @@ async def fetch_device_info(self): 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 "invalid_password" return None diff --git a/homeassistant/components/esphome/cover.py b/homeassistant/components/esphome/cover.py index a3ef15fa4c72f..fcf7c22a2a264 100644 --- a/homeassistant/components/esphome/cover.py +++ b/homeassistant/components/esphome/cover.py @@ -1,42 +1,49 @@ """Support for ESPHome covers.""" import logging -from typing import TYPE_CHECKING, Optional +from typing import Optional + +from aioesphomeapi import CoverInfo, CoverOperation, 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) + ATTR_POSITION, + ATTR_TILT_POSITION, + SUPPORT_CLOSE, + SUPPORT_CLOSE_TILT, + SUPPORT_OPEN, + SUPPORT_OPEN_TILT, + SUPPORT_SET_POSITION, + SUPPORT_SET_TILT_POSITION, + SUPPORT_STOP, + CoverEntity, +) from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.typing import HomeAssistantType -from . import EsphomeEntity, platform_async_setup_entry, esphome_state_property - -if TYPE_CHECKING: - # pylint: disable=unused-import - from aioesphomeapi import CoverInfo, CoverState # noqa +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: +async def async_setup_entry( + hass: HomeAssistantType, entry: ConfigEntry, async_add_entities +) -> None: """Set up ESPHome covers based on a config entry.""" - # pylint: disable=redefined-outer-name - from aioesphomeapi import CoverInfo, CoverState # noqa - await platform_async_setup_entry( - hass, entry, async_add_entities, - component_key='cover', - info_type=CoverInfo, entity_type=EsphomeCover, - state_type=CoverState + hass, + entry, + async_add_entities, + component_key="cover", + info_type=CoverInfo, + entity_type=EsphomeCover, + state_type=CoverState, ) -class EsphomeCover(EsphomeEntity, CoverDevice): +class EsphomeCover(EsphomeEntity, CoverEntity): """A cover implementation for ESPHome.""" @property - def _static_info(self) -> 'CoverInfo': + def _static_info(self) -> CoverInfo: return super()._static_info @property @@ -46,8 +53,7 @@ def supported_features(self) -> int: 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) + flags |= SUPPORT_OPEN_TILT | SUPPORT_CLOSE_TILT | SUPPORT_SET_TILT_POSITION return flags @property @@ -61,9 +67,12 @@ def assumed_state(self) -> bool: return self._static_info.assumed_state @property - def _state(self) -> Optional['CoverState']: + def _state(self) -> Optional[CoverState]: return super()._state + # https://github.com/PyCQA/pylint/issues/3150 for all @esphome_state_property + # pylint: disable=invalid-overridden-method + @esphome_state_property def is_closed(self) -> Optional[bool]: """Return if the cover is closed or not.""" @@ -73,21 +82,19 @@ def is_closed(self) -> Optional[bool]: @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]: + def current_cover_position(self) -> Optional[int]: """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 + return round(self._state.position * 100.0) @esphome_state_property def current_cover_tilt_position(self) -> Optional[float]: @@ -98,13 +105,11 @@ def current_cover_tilt_position(self) -> Optional[float]: async def async_open_cover(self, **kwargs) -> None: """Open the cover.""" - await self._client.cover_command(key=self._static_info.key, - position=1.0) + 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) + await self._client.cover_command(key=self._static_info.key, position=0.0) async def async_stop_cover(self, **kwargs) -> None: """Stop the cover.""" @@ -112,8 +117,9 @@ async def async_stop_cover(self, **kwargs) -> None: 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) + 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.""" @@ -125,5 +131,6 @@ async def async_close_cover_tilt(self, **kwargs) -> None: 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) + 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..d8453c974f61b --- /dev/null +++ b/homeassistant/components/esphome/entry_data.py @@ -0,0 +1,163 @@ +"""Runtime entry data for ESPHome stored in hass.data.""" +import asyncio +from typing import Any, Callable, Dict, List, Optional, Set, Tuple + +from aioesphomeapi import ( + COMPONENT_TYPE_TO_INFO, + BinarySensorInfo, + CameraInfo, + ClimateInfo, + CoverInfo, + DeviceInfo, + EntityInfo, + EntityState, + FanInfo, + LightInfo, + SensorInfo, + SwitchInfo, + TextSensorInfo, + UserService, +) +import attr + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.storage import Store +from homeassistant.helpers.typing import HomeAssistantType + +DATA_KEY = "esphome" + +# Mapping from ESPHome info type to HA platform +INFO_TYPE_TO_PLATFORM = { + BinarySensorInfo: "binary_sensor", + CameraInfo: "camera", + ClimateInfo: "climate", + CoverInfo: "cover", + FanInfo: "fan", + LightInfo: "light", + SensorInfo: "sensor", + SwitchInfo: "switch", + TextSensorInfo: "sensor", +} + + +@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) + + # A second list of EntityInfo objects + # This is necessary for when an entity is being removed. HA requires + # some static info to be accessible during removal (unique_id, maybe others) + # If an entity can't find anything in the info array, it will look for info here. + old_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) + loaded_platforms = attr.ib(type=Set[str], factory=set) + platform_load_lock = attr.ib(type=asyncio.Lock, factory=asyncio.Lock) + + @callback + def async_update_entity( + self, hass: HomeAssistantType, component_key: str, key: int + ) -> None: + """Schedule the update of an entity.""" + signal = f"esphome_{self.entry_id}_update_{component_key}_{key}" + async_dispatcher_send(hass, signal) + + @callback + def async_remove_entity( + self, hass: HomeAssistantType, component_key: str, key: int + ) -> None: + """Schedule the removal of an entity.""" + signal = f"esphome_{self.entry_id}_remove_{component_key}_{key}" + async_dispatcher_send(hass, signal) + + async def _ensure_platforms_loaded( + self, hass: HomeAssistantType, entry: ConfigEntry, platforms: Set[str] + ): + async with self.platform_load_lock: + needed = platforms - self.loaded_platforms + tasks = [] + for platform in needed: + tasks.append( + hass.config_entries.async_forward_entry_setup(entry, platform) + ) + if tasks: + await asyncio.wait(tasks) + self.loaded_platforms |= needed + + async def async_update_static_infos( + self, hass: HomeAssistantType, entry: ConfigEntry, infos: List[EntityInfo] + ) -> None: + """Distribute an update of static infos to all platforms.""" + # First, load all platforms + needed_platforms = set() + for info in infos: + for info_type, platform in INFO_TYPE_TO_PLATFORM.items(): + if isinstance(info, info_type): + needed_platforms.add(platform) + break + await self._ensure_platforms_loaded(hass, entry, needed_platforms) + + # Then send dispatcher event + signal = f"esphome_{self.entry_id}_on_list" + async_dispatcher_send(hass, signal, infos) + + @callback + def async_update_state(self, hass: HomeAssistantType, state: EntityState) -> None: + """Distribute an update of state information to all platforms.""" + signal = f"esphome_{self.entry_id}_on_state" + async_dispatcher_send(hass, signal, state) + + @callback + def async_update_device_state(self, hass: HomeAssistantType) -> None: + """Distribute an update of a core device state like availability.""" + signal = f"esphome_{self.entry_id}_on_device_update" + 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 index 50cf04203f357..8b9b4b4922cf8 100644 --- a/homeassistant/components/esphome/fan.py +++ b/homeassistant/components/esphome/fan.py @@ -1,41 +1,48 @@ """Support for ESPHome fans.""" import logging -from typing import TYPE_CHECKING, List, Optional +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) + 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, platform_async_setup_entry, \ - esphome_state_property, esphome_map_enum - -if TYPE_CHECKING: - # pylint: disable=unused-import - from aioesphomeapi import FanInfo, FanState, FanSpeed # noqa +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: +async def async_setup_entry( + hass: HomeAssistantType, entry: ConfigEntry, async_add_entities +) -> None: """Set up ESPHome fans based on a config entry.""" - # pylint: disable=redefined-outer-name - from aioesphomeapi import FanInfo, FanState # noqa - await platform_async_setup_entry( - hass, entry, async_add_entities, - component_key='fan', - info_type=FanInfo, entity_type=EsphomeFan, - state_type=FanState + hass, + entry, + async_add_entities, + component_key="fan", + info_type=FanInfo, + entity_type=EsphomeFan, + state_type=FanState, ) @esphome_map_enum def _fan_speeds(): - # pylint: disable=redefined-outer-name - from aioesphomeapi import FanSpeed # noqa return { FanSpeed.LOW: SPEED_LOW, FanSpeed.MEDIUM: SPEED_MEDIUM, @@ -47,11 +54,11 @@ class EsphomeFan(EsphomeEntity, FanEntity): """A fan implementation for ESPHome.""" @property - def _static_info(self) -> 'FanInfo': + def _static_info(self) -> FanInfo: return super()._static_info @property - def _state(self) -> Optional['FanState']: + def _state(self) -> Optional[FanState]: return super()._state async def async_set_speed(self, speed: str) -> None: @@ -61,28 +68,31 @@ async def async_set_speed(self, speed: str) -> None: return await self._client.fan_command( - self._static_info.key, speed=_fan_speeds.from_hass(speed)) + self._static_info.key, speed=_fan_speeds.from_hass(speed) + ) - async def async_turn_on(self, speed: Optional[str] = None, - **kwargs) -> None: + 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} + data = {"key": self._static_info.key, "state": True} if speed is not None: - data['speed'] = _fan_speeds.from_hass(speed) + 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) + await self._client.fan_command( + key=self._static_info.key, oscillating=oscillating + ) + + # https://github.com/PyCQA/pylint/issues/3150 for all @esphome_state_property + # pylint: disable=invalid-overridden-method @esphome_state_property def is_on(self) -> Optional[bool]: diff --git a/homeassistant/components/esphome/light.py b/homeassistant/components/esphome/light.py index 6b4abafe62b95..36c22f280164c 100644 --- a/homeassistant/components/esphome/light.py +++ b/homeassistant/components/esphome/light.py @@ -1,56 +1,69 @@ """Support for ESPHome lights.""" import logging -from typing import TYPE_CHECKING, List, Optional, Tuple +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) + 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, + LightEntity, +) from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.typing import HomeAssistantType import homeassistant.util.color as color_util -from . import EsphomeEntity, platform_async_setup_entry, esphome_state_property - -if TYPE_CHECKING: - # pylint: disable=unused-import - from aioesphomeapi import LightInfo, LightState # noqa +from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry _LOGGER = logging.getLogger(__name__) -FLASH_LENGTHS = { - FLASH_SHORT: 2, - FLASH_LONG: 10, -} +FLASH_LENGTHS = {FLASH_SHORT: 2, FLASH_LONG: 10} -async def async_setup_entry(hass: HomeAssistantType, - entry: ConfigEntry, async_add_entities) -> None: +async def async_setup_entry( + hass: HomeAssistantType, entry: ConfigEntry, async_add_entities +) -> None: """Set up ESPHome lights based on a config entry.""" - # pylint: disable=redefined-outer-name - from aioesphomeapi import LightInfo, LightState # noqa - await platform_async_setup_entry( - hass, entry, async_add_entities, - component_key='light', - info_type=LightInfo, entity_type=EsphomeLight, - state_type=LightState + hass, + entry, + async_add_entities, + component_key="light", + info_type=LightInfo, + entity_type=EsphomeLight, + state_type=LightState, ) -class EsphomeLight(EsphomeEntity, Light): +class EsphomeLight(EsphomeEntity, LightEntity): """A switch implementation for ESPHome.""" @property - def _static_info(self) -> 'LightInfo': + def _static_info(self) -> LightInfo: return super()._static_info @property - def _state(self) -> Optional['LightState']: + def _state(self) -> Optional[LightState]: return super()._state + # https://github.com/PyCQA/pylint/issues/3150 for all @esphome_state_property + # pylint: disable=invalid-overridden-method + @esphome_state_property def is_on(self) -> Optional[bool]: """Return true if the switch is on.""" @@ -58,32 +71,32 @@ def is_on(self) -> Optional[bool]: async def async_turn_on(self, **kwargs) -> None: """Turn the entity on.""" - data = {'key': self._static_info.key, 'state': True} + 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) + data["rgb"] = (red / 255, green / 255, blue / 255) if ATTR_FLASH in kwargs: - data['flash'] = FLASH_LENGTHS[kwargs[ATTR_FLASH]] + data["flash_length"] = FLASH_LENGTHS[kwargs[ATTR_FLASH]] if ATTR_TRANSITION in kwargs: - data['transition_length'] = kwargs[ATTR_TRANSITION] + data["transition_length"] = kwargs[ATTR_TRANSITION] if ATTR_BRIGHTNESS in kwargs: - data['brightness'] = kwargs[ATTR_BRIGHTNESS] / 255 + data["brightness"] = kwargs[ATTR_BRIGHTNESS] / 255 if ATTR_COLOR_TEMP in kwargs: - data['color_temperature'] = kwargs[ATTR_COLOR_TEMP] + data["color_temperature"] = kwargs[ATTR_COLOR_TEMP] if ATTR_EFFECT in kwargs: - data['effect'] = kwargs[ATTR_EFFECT] + data["effect"] = kwargs[ATTR_EFFECT] if ATTR_WHITE_VALUE in kwargs: - data['white'] = kwargs[ATTR_WHITE_VALUE] / 255 + 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} + data = {"key": self._static_info.key, "state": False} if ATTR_FLASH in kwargs: - data['flash'] = FLASH_LENGTHS[kwargs[ATTR_FLASH]] + data["flash_length"] = FLASH_LENGTHS[kwargs[ATTR_FLASH]] if ATTR_TRANSITION in kwargs: - data['transition_length'] = kwargs[ATTR_TRANSITION] + data["transition_length"] = kwargs[ATTR_TRANSITION] await self._client.light_command(**data) @esphome_state_property @@ -95,9 +108,8 @@ def brightness(self) -> Optional[int]: 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) + self._state.red * 255, self._state.green * 255, self._state.blue * 255 + ) @esphome_state_property def color_temp(self) -> Optional[float]: diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 9d25ec6d034da..19d00fbbff90d 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -1,12 +1,9 @@ { "domain": "esphome", "name": "ESPHome", - "documentation": "https://www.home-assistant.io/components/esphome", - "requirements": [ - "aioesphomeapi==2.0.1" - ], - "dependencies": [], - "codeowners": [ - "@OttoWinter" - ] + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/esphome", + "requirements": ["aioesphomeapi==2.6.1"], + "zeroconf": ["_esphomelib._tcp.local."], + "codeowners": ["@OttoWinter"] } diff --git a/homeassistant/components/esphome/sensor.py b/homeassistant/components/esphome/sensor.py index 8d8fb938c6867..0856f27071035 100644 --- a/homeassistant/components/esphome/sensor.py +++ b/homeassistant/components/esphome/sensor.py @@ -1,51 +1,55 @@ """Support for esphome sensors.""" import logging import math -from typing import TYPE_CHECKING, Optional +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, platform_async_setup_entry, esphome_state_property - -if TYPE_CHECKING: - # pylint: disable=unused-import - from aioesphomeapi import ( # noqa - SensorInfo, SensorState, TextSensorInfo, TextSensorState) +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: +async def async_setup_entry( + hass: HomeAssistantType, entry: ConfigEntry, async_add_entities +) -> None: """Set up esphome sensors based on a config entry.""" - # pylint: disable=redefined-outer-name - from aioesphomeapi import ( # noqa - SensorInfo, SensorState, TextSensorInfo, TextSensorState) - await platform_async_setup_entry( - hass, entry, async_add_entities, - component_key='sensor', - info_type=SensorInfo, entity_type=EsphomeSensor, - state_type=SensorState + 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 + hass, + entry, + async_add_entities, + component_key="text_sensor", + info_type=TextSensorInfo, + entity_type=EsphomeTextSensor, + state_type=TextSensorState, ) +# https://github.com/PyCQA/pylint/issues/3150 for all @esphome_state_property +# pylint: disable=invalid-overridden-method + + class EsphomeSensor(EsphomeEntity): """A sensor implementation for esphome.""" @property - def _static_info(self) -> 'SensorInfo': + def _static_info(self) -> SensorInfo: return super()._static_info @property - def _state(self) -> Optional['SensorState']: + def _state(self) -> Optional[SensorState]: return super()._state @property @@ -53,13 +57,19 @@ def icon(self) -> str: """Return the icon.""" return self._static_info.icon + @property + def force_update(self) -> bool: + """Return if this sensor should force a state update.""" + return self._static_info.force_update + @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) + if self._state.missing_state: + return None + return f"{self._state.state:.{self._static_info.accuracy_decimals}f}" @property def unit_of_measurement(self) -> str: @@ -71,11 +81,11 @@ class EsphomeTextSensor(EsphomeEntity): """A text sensor implementation for ESPHome.""" @property - def _static_info(self) -> 'TextSensorInfo': + def _static_info(self) -> "TextSensorInfo": return super()._static_info @property - def _state(self) -> Optional['TextSensorState']: + def _state(self) -> Optional["TextSensorState"]: return super()._state @property @@ -86,4 +96,6 @@ def icon(self) -> str: @esphome_state_property def state(self) -> Optional[str]: """Return the state of the entity.""" + if self._state.missing_state: + return None return self._state.state diff --git a/homeassistant/components/esphome/strings.json b/homeassistant/components/esphome/strings.json index 8f691d9cb00bf..e5eeb150d89fd 100644 --- a/homeassistant/components/esphome/strings.json +++ b/homeassistant/components/esphome/strings.json @@ -1,34 +1,28 @@ { - "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" - } + "config": { + "abort": { + "already_configured": "ESP is already configured", + "already_in_progress": "ESP configuration is already in progress" + }, + "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." + }, + "authenticate": { + "data": { "password": "Password" }, + "description": "Please enter the password you set in your configuration for {name}." + }, + "discovery_confirm": { + "description": "Do you want to add the ESPHome node `{name}` to Home Assistant?", + "title": "Discovered ESPHome node" + } + }, + "flow_title": "ESPHome: {name}" + } } diff --git a/homeassistant/components/esphome/switch.py b/homeassistant/components/esphome/switch.py index 77994d0be58f0..a3c7eeab9468c 100644 --- a/homeassistant/components/esphome/switch.py +++ b/homeassistant/components/esphome/switch.py @@ -1,43 +1,42 @@ """Support for ESPHome switches.""" import logging -from typing import TYPE_CHECKING, Optional +from typing import Optional -from homeassistant.components.switch import SwitchDevice +from aioesphomeapi import SwitchInfo, SwitchState + +from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.typing import HomeAssistantType -from . import EsphomeEntity, platform_async_setup_entry, esphome_state_property - -if TYPE_CHECKING: - # pylint: disable=unused-import - from aioesphomeapi import SwitchInfo, SwitchState # noqa +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: +async def async_setup_entry( + hass: HomeAssistantType, entry: ConfigEntry, async_add_entities +) -> None: """Set up ESPHome switches based on a config entry.""" - # pylint: disable=redefined-outer-name - from aioesphomeapi import SwitchInfo, SwitchState # noqa - await platform_async_setup_entry( - hass, entry, async_add_entities, - component_key='switch', - info_type=SwitchInfo, entity_type=EsphomeSwitch, - state_type=SwitchState + hass, + entry, + async_add_entities, + component_key="switch", + info_type=SwitchInfo, + entity_type=EsphomeSwitch, + state_type=SwitchState, ) -class EsphomeSwitch(EsphomeEntity, SwitchDevice): +class EsphomeSwitch(EsphomeEntity, SwitchEntity): """A switch implementation for ESPHome.""" @property - def _static_info(self) -> 'SwitchInfo': + def _static_info(self) -> SwitchInfo: return super()._static_info @property - def _state(self) -> Optional['SwitchState']: + def _state(self) -> Optional[SwitchState]: return super()._state @property @@ -50,6 +49,8 @@ def assumed_state(self) -> bool: """Return true if we do optimistic updates.""" return self._static_info.assumed_state + # https://github.com/PyCQA/pylint/issues/3150 for @esphome_state_property + # pylint: disable=invalid-overridden-method @esphome_state_property def is_on(self) -> Optional[bool]: """Return true if the switch is on.""" diff --git a/homeassistant/components/esphome/translations/af.json b/homeassistant/components/esphome/translations/af.json new file mode 100644 index 0000000000000..9d6d417a053b2 --- /dev/null +++ b/homeassistant/components/esphome/translations/af.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "user": { + "title": "[%key:component::esphome::title%]" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/translations/ar.json b/homeassistant/components/esphome/translations/ar.json new file mode 100644 index 0000000000000..9d6d417a053b2 --- /dev/null +++ b/homeassistant/components/esphome/translations/ar.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "user": { + "title": "[%key:component::esphome::title%]" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/translations/bg.json b/homeassistant/components/esphome/translations/bg.json new file mode 100644 index 0000000000000..f77d3957ea140 --- /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" + }, + "flow_title": "ESPHome: {name}", + "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": "[%key:component::esphome::title%]" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/translations/bs.json b/homeassistant/components/esphome/translations/bs.json new file mode 100644 index 0000000000000..9d6d417a053b2 --- /dev/null +++ b/homeassistant/components/esphome/translations/bs.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "user": { + "title": "[%key:component::esphome::title%]" + } + } + } +} \ 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..9f9378081dca9 --- /dev/null +++ b/homeassistant/components/esphome/translations/ca.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "already_configured": "ESP ja est\u00e0 configurat", + "already_in_progress": "La configuraci\u00f3 de l'ESP ja est\u00e0 en curs" + }, + "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": "[%key:component::esphome::title%]" + } + } + } +} \ 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..36a600befaa2d --- /dev/null +++ b/homeassistant/components/esphome/translations/cs.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "already_configured": "Tento ESP uzel je ji\u017e nakonfigurov\u00e1n", + "already_in_progress": "Konfigurace uzlu ESP ji\u017e prob\u00edh\u00e1" + }, + "error": { + "connection_error": "Nelze se p\u0159ipojit k ESP. Zkontrolujte, zda va\u0161e YAML konfigurace obsahuje \u0159\u00e1dek 'api:'.", + "invalid_password": "Neplatn\u00e9 heslo", + "resolve_error": "Nelze naj\u00edt IP adresu uzlu ESP. Pokud tato chyba p\u0159etrv\u00e1v\u00e1, nastavte statickou adresu IP: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" + }, + "flow_title": "ESPHome: {name}", + "step": { + "authenticate": { + "data": { + "password": "Heslo" + }, + "description": "Zadejte heslo, kter\u00e9 jste nastavili ve va\u0161\u00ed konfiguraci pro {name} .", + "title": "Zadejte heslo" + }, + "discovery_confirm": { + "description": "Chcete do domovsk\u00e9ho asistenta p\u0159idat uzel ESPHome `{name}`?", + "title": "Nalezen uzel ESPHome" + }, + "user": { + "data": { + "host": "Adresa uzlu", + "port": "Port" + }, + "description": "Zadejte pros\u00edm nastaven\u00ed p\u0159ipojen\u00ed va\u0161eho [ESPHome](https://esphomelib.com/) uzlu.", + "title": "[%key:component::esphome::title%]" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/translations/cy.json b/homeassistant/components/esphome/translations/cy.json new file mode 100644 index 0000000000000..9d6d417a053b2 --- /dev/null +++ b/homeassistant/components/esphome/translations/cy.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "user": { + "title": "[%key:component::esphome::title%]" + } + } + } +} \ 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..c05e2d34f0107 --- /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" + }, + "flow_title": "ESPHome: {name}", + "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-knudepunkt `{name}` til Home Assistant?", + "title": "Fandt ESPHome-knudepunkt" + }, + "user": { + "data": { + "host": "V\u00e6rt", + "port": "Port" + }, + "description": "Angiv forbindelsesindstillinger for din [ESPHome](https://esphomelib.com/) node.", + "title": "[%key:component::esphome::title%]" + } + } + } +} \ 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..64262aa654a57 --- /dev/null +++ b/homeassistant/components/esphome/translations/de.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "ESP ist bereits konfiguriert" + }, + "error": { + "connection_error": "Keine Verbindung zum ESP m\u00f6glich. Achte darauf, dass deine 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, lege 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 gebe 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 gib die Verbindungseinstellungen deines [ESPHome](https://esphomelib.com/)-Knotens ein.", + "title": "ESPHome" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/translations/el.json b/homeassistant/components/esphome/translations/el.json new file mode 100644 index 0000000000000..9d6d417a053b2 --- /dev/null +++ b/homeassistant/components/esphome/translations/el.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "user": { + "title": "[%key:component::esphome::title%]" + } + } + } +} \ 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..3cc24dea78e7d --- /dev/null +++ b/homeassistant/components/esphome/translations/en.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "already_configured": "ESP is already configured", + "already_in_progress": "ESP configuration is already in progress" + }, + "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" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/translations/eo.json b/homeassistant/components/esphome/translations/eo.json new file mode 100644 index 0000000000000..9d6d417a053b2 --- /dev/null +++ b/homeassistant/components/esphome/translations/eo.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "user": { + "title": "[%key:component::esphome::title%]" + } + } + } +} \ 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..7bbe61aceb2de --- /dev/null +++ b/homeassistant/components/esphome/translations/es-419.json @@ -0,0 +1,34 @@ +{ + "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" + }, + "flow_title": "ESPHome: {name}", + "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": { + "description": "\u00bfDesea agregar el nodo ESPHome `{name}` a Home Assistant?", + "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": "[%key:component::esphome::title%]" + } + } + } +} \ 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..ff2f1f093c9e2 --- /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" + }, + "flow_title": "ESPHome: {name}", + "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": "[%key:component::esphome::title%]" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/translations/et.json b/homeassistant/components/esphome/translations/et.json new file mode 100644 index 0000000000000..9d6d417a053b2 --- /dev/null +++ b/homeassistant/components/esphome/translations/et.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "user": { + "title": "[%key:component::esphome::title%]" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/translations/eu.json b/homeassistant/components/esphome/translations/eu.json new file mode 100644 index 0000000000000..9d6d417a053b2 --- /dev/null +++ b/homeassistant/components/esphome/translations/eu.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "user": { + "title": "[%key:component::esphome::title%]" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/translations/fa.json b/homeassistant/components/esphome/translations/fa.json new file mode 100644 index 0000000000000..9d6d417a053b2 --- /dev/null +++ b/homeassistant/components/esphome/translations/fa.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "user": { + "title": "[%key:component::esphome::title%]" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/translations/fi.json b/homeassistant/components/esphome/translations/fi.json new file mode 100644 index 0000000000000..9d6d417a053b2 --- /dev/null +++ b/homeassistant/components/esphome/translations/fi.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "user": { + "title": "[%key:component::esphome::title%]" + } + } + } +} \ 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..a6620218f0ecb --- /dev/null +++ b/homeassistant/components/esphome/translations/fr.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "already_configured": "ESP est d\u00e9j\u00e0 configur\u00e9", + "already_in_progress": "La configuration ESP est d\u00e9j\u00e0 en cours" + }, + "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": "[%key:component::esphome::title%]" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/translations/gsw.json b/homeassistant/components/esphome/translations/gsw.json new file mode 100644 index 0000000000000..9d6d417a053b2 --- /dev/null +++ b/homeassistant/components/esphome/translations/gsw.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "user": { + "title": "[%key:component::esphome::title%]" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/translations/he.json b/homeassistant/components/esphome/translations/he.json new file mode 100644 index 0000000000000..9d6d417a053b2 --- /dev/null +++ b/homeassistant/components/esphome/translations/he.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "user": { + "title": "[%key:component::esphome::title%]" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/translations/hi.json b/homeassistant/components/esphome/translations/hi.json new file mode 100644 index 0000000000000..9d6d417a053b2 --- /dev/null +++ b/homeassistant/components/esphome/translations/hi.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "user": { + "title": "[%key:component::esphome::title%]" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/translations/hr.json b/homeassistant/components/esphome/translations/hr.json new file mode 100644 index 0000000000000..9d6d417a053b2 --- /dev/null +++ b/homeassistant/components/esphome/translations/hr.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "user": { + "title": "[%key:component::esphome::title%]" + } + } + } +} \ 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..a178860d420fb --- /dev/null +++ b/homeassistant/components/esphome/translations/hu.json @@ -0,0 +1,34 @@ +{ + "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" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/translations/iba.json b/homeassistant/components/esphome/translations/iba.json new file mode 100644 index 0000000000000..9d6d417a053b2 --- /dev/null +++ b/homeassistant/components/esphome/translations/iba.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "user": { + "title": "[%key:component::esphome::title%]" + } + } + } +} \ 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..9c646def82c19 --- /dev/null +++ b/homeassistant/components/esphome/translations/id.json @@ -0,0 +1,19 @@ +{ + "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" + }, + "user": { + "title": "[%key:component::esphome::title%]" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/translations/is.json b/homeassistant/components/esphome/translations/is.json new file mode 100644 index 0000000000000..9d6d417a053b2 --- /dev/null +++ b/homeassistant/components/esphome/translations/is.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "user": { + "title": "[%key:component::esphome::title%]" + } + } + } +} \ 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..050c122249553 --- /dev/null +++ b/homeassistant/components/esphome/translations/it.json @@ -0,0 +1,34 @@ +{ + "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" + }, + "flow_title": "ESPHome: {name}", + "step": { + "authenticate": { + "data": { + "password": "Password" + }, + "description": "Inserisci la password per {name} che hai impostato nella tua configurazione.", + "title": "Inserisci la password" + }, + "discovery_confirm": { + "description": "Vuoi aggiungere il nodo ESPHome `{name}` a Home Assistant?", + "title": "Trovato nodo ESPHome" + }, + "user": { + "data": { + "host": "Host", + "port": "Porta" + }, + "description": "Inserisci le impostazioni di connessione del tuo nodo [ESPHome] (https://esphomelib.com/).", + "title": "ESPHome" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/translations/ja.json b/homeassistant/components/esphome/translations/ja.json new file mode 100644 index 0000000000000..9d6d417a053b2 --- /dev/null +++ b/homeassistant/components/esphome/translations/ja.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "user": { + "title": "[%key:component::esphome::title%]" + } + } + } +} \ 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..d8546154f4772 --- /dev/null +++ b/homeassistant/components/esphome/translations/ko.json @@ -0,0 +1,34 @@ +{ + "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\ud558\uae30" + }, + "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" + } + } + } +} \ 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..9bfc78af28c53 --- /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" + }, + "flow_title": "ESPHome: {name}", + "step": { + "authenticate": { + "data": { + "password": "Passwuert" + }, + "description": "Gitt d'Passwuert vun \u00e4rer Konfiguratioun an fir {name}.", + "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" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/translations/lt.json b/homeassistant/components/esphome/translations/lt.json new file mode 100644 index 0000000000000..9d6d417a053b2 --- /dev/null +++ b/homeassistant/components/esphome/translations/lt.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "user": { + "title": "[%key:component::esphome::title%]" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/translations/lv.json b/homeassistant/components/esphome/translations/lv.json new file mode 100644 index 0000000000000..9d6d417a053b2 --- /dev/null +++ b/homeassistant/components/esphome/translations/lv.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "user": { + "title": "[%key:component::esphome::title%]" + } + } + } +} \ 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..4edc46a372a2b --- /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" + }, + "flow_title": "ESPHome: {name}", + "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": "[%key:component::esphome::title%]" + } + } + } +} \ 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..628fb01a2cc41 --- /dev/null +++ b/homeassistant/components/esphome/translations/nn.json @@ -0,0 +1,13 @@ +{ + "config": { + "flow_title": "ESPHome: {name}", + "step": { + "discovery_confirm": { + "title": "Fann ESPhome node" + }, + "user": { + "title": "[%key:component::esphome::title%]" + } + } + } +} \ 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..37e3b881b8b4e --- /dev/null +++ b/homeassistant/components/esphome/translations/no.json @@ -0,0 +1,34 @@ +{ + "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": "", + "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": "" + }, + "description": "Vennligst skriv inn tilkoblingsinnstillinger for din [ESPHome](https://esphomelib.com/) node.", + "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..276a0b404ddf2 --- /dev/null +++ b/homeassistant/components/esphome/translations/pl.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "already_configured": "ESP jest ju\u017c skonfigurowane.", + "already_in_progress": "Konfiguracja ESP jest ju\u017c w toku." + }, + "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 {name}.", + "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": "Nazwa hosta lub adres IP", + "port": "Port" + }, + "description": "Wprowad\u017a ustawienia po\u0142\u0105czenia [ESPHome](https://esphomelib.com/) w\u0119z\u0142a.", + "title": "[%key:component::esphome::title%]" + } + } + } +} \ 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..bbc5642189919 --- /dev/null +++ b/homeassistant/components/esphome/translations/pt-BR.json @@ -0,0 +1,34 @@ +{ + "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" + }, + "discovery_confirm": { + "description": "Voc\u00ea quer adicionar o n\u00f3 ESPHome ` {name} ` ao Home Assistant?", + "title": "N\u00f3 ESPHome descoberto" + }, + "user": { + "data": { + "host": "Host", + "port": "Porta" + }, + "description": "Por favor insira as configura\u00e7\u00f5es de conex\u00e3o de seu n\u00f3 de [ESPHome] (https://esphomelib.com/).", + "title": "[%key:component::esphome::title%]" + } + } + } +} \ 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..7a5e8621d4cba --- /dev/null +++ b/homeassistant/components/esphome/translations/pt.json @@ -0,0 +1,33 @@ +{ + "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" + }, + "discovery_confirm": { + "description": "Deseja adicionar um n\u00f3 ESPHome `{name}` ao Home Assistant?", + "title": "N\u00f3 ESPHome descoberto" + }, + "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": "[%key:component::esphome::title%]" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/translations/ro.json b/homeassistant/components/esphome/translations/ro.json new file mode 100644 index 0000000000000..9d6d417a053b2 --- /dev/null +++ b/homeassistant/components/esphome/translations/ro.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "user": { + "title": "[%key:component::esphome::title%]" + } + } + } +} \ 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..d9407b1c20e0a --- /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.", + "already_in_progress": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u044d\u0442\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u044f\u0435\u0442\u0441\u044f." + }, + "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" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/translations/sk.json b/homeassistant/components/esphome/translations/sk.json new file mode 100644 index 0000000000000..9d6d417a053b2 --- /dev/null +++ b/homeassistant/components/esphome/translations/sk.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "user": { + "title": "[%key:component::esphome::title%]" + } + } + } +} \ 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..64aa9716f2411 --- /dev/null +++ b/homeassistant/components/esphome/translations/sl.json @@ -0,0 +1,34 @@ +{ + "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" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/translations/sr-Latn.json b/homeassistant/components/esphome/translations/sr-Latn.json new file mode 100644 index 0000000000000..9d6d417a053b2 --- /dev/null +++ b/homeassistant/components/esphome/translations/sr-Latn.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "user": { + "title": "[%key:component::esphome::title%]" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/translations/sr.json b/homeassistant/components/esphome/translations/sr.json new file mode 100644 index 0000000000000..9d6d417a053b2 --- /dev/null +++ b/homeassistant/components/esphome/translations/sr.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "user": { + "title": "[%key:component::esphome::title%]" + } + } + } +} \ 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..3c3a4a53f64f6 --- /dev/null +++ b/homeassistant/components/esphome/translations/sv.json @@ -0,0 +1,34 @@ +{ + "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": "[%key:component::esphome::title%]" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/translations/ta.json b/homeassistant/components/esphome/translations/ta.json new file mode 100644 index 0000000000000..9d6d417a053b2 --- /dev/null +++ b/homeassistant/components/esphome/translations/ta.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "user": { + "title": "[%key:component::esphome::title%]" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/translations/te.json b/homeassistant/components/esphome/translations/te.json new file mode 100644 index 0000000000000..9d6d417a053b2 --- /dev/null +++ b/homeassistant/components/esphome/translations/te.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "user": { + "title": "[%key:component::esphome::title%]" + } + } + } +} \ 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..10520139bd22c --- /dev/null +++ b/homeassistant/components/esphome/translations/th.json @@ -0,0 +1,18 @@ +{ + "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" + }, + "user": { + "title": "[%key:component::esphome::title%]" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/translations/tr.json b/homeassistant/components/esphome/translations/tr.json new file mode 100644 index 0000000000000..9d6d417a053b2 --- /dev/null +++ b/homeassistant/components/esphome/translations/tr.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "user": { + "title": "[%key:component::esphome::title%]" + } + } + } +} \ 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..ec22664e46bd0 --- /dev/null +++ b/homeassistant/components/esphome/translations/uk.json @@ -0,0 +1,33 @@ +{ + "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/).", + "title": "[%key:component::esphome::title%]" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/translations/ur.json b/homeassistant/components/esphome/translations/ur.json new file mode 100644 index 0000000000000..9d6d417a053b2 --- /dev/null +++ b/homeassistant/components/esphome/translations/ur.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "user": { + "title": "[%key:component::esphome::title%]" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/translations/vi.json b/homeassistant/components/esphome/translations/vi.json new file mode 100644 index 0000000000000..9d6d417a053b2 --- /dev/null +++ b/homeassistant/components/esphome/translations/vi.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "user": { + "title": "[%key:component::esphome::title%]" + } + } + } +} \ 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..8c756a6968638 --- /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" + }, + "flow_title": "ESPHome: {name}", + "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": "[%key:component::esphome::title%]" + } + } + } +} \ 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..3657af88ce964 --- /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", + "already_in_progress": "ESP \u8a2d\u5b9a\u5df2\u7d93\u9032\u884c\u4e2d\u3002" + }, + "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 {name} \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 `{name}` \u65b0\u589e\u81f3 Home Assistant\uff1f", + "title": "\u81ea\u52d5\u63a2\u7d22\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": "[%key:component::esphome::title%]" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/essent/manifest.json b/homeassistant/components/essent/manifest.json index 49189f6bacb15..a46d37ccdc88c 100644 --- a/homeassistant/components/essent/manifest.json +++ b/homeassistant/components/essent/manifest.json @@ -1,8 +1,7 @@ { "domain": "essent", "name": "Essent", - "documentation": "https://www.home-assistant.io/components/essent", - "requirements": ["PyEssent==0.10"], - "dependencies": [], + "documentation": "https://www.home-assistant.io/integrations/essent", + "requirements": ["PyEssent==0.13"], "codeowners": ["@TheLastProject"] } diff --git a/homeassistant/components/essent/sensor.py b/homeassistant/components/essent/sensor.py index 545ed3d5baf5f..e3ce1ccaafa5e 100644 --- a/homeassistant/components/essent/sensor.py +++ b/homeassistant/components/essent/sensor.py @@ -1,22 +1,21 @@ """Support for Essent API.""" from datetime import timedelta +from typing import Optional 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) +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 -}) +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): @@ -28,32 +27,44 @@ def setup_platform(hass, config, add_devices, discovery_info=None): 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'])) + 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(): +class EssentBase: """Essent Base.""" def __init__(self, username, password): """Initialize the Essent API.""" self._username = username self._password = password - self._meters = [] self._meter_data = {} self.update() def retrieve_meters(self): """Retrieve the list of meters.""" - return self._meters + return self._meter_data.keys() def retrieve_meter_data(self, meter): """Retrieve the data for this meter.""" @@ -63,10 +74,11 @@ def retrieve_meter_data(self, meter): def update(self): """Retrieve the latest meter data from Essent.""" essent = PyEssent(self._username, self._password) - self._meters = essent.get_EANs() - for meter in self._meters: - self._meter_data[meter] = essent.read_meter( - meter, only_last_meter_reading=True) + eans = set(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): @@ -81,10 +93,15 @@ def __init__(self, essent_base, meter, meter_type, tariff, unit): self._tariff = tariff self._unit = unit + @property + def unique_id(self) -> Optional[str]: + """Return a unique ID.""" + return f"{self._meter}-{self._type}-{self._tariff}" + @property def name(self): """Return the name of the sensor.""" - return "Essent {} ({})".format(self._type, self._tariff) + return f"Essent {self._type} ({self._tariff})" @property def state(self): @@ -94,7 +111,7 @@ def state(self): @property def unit_of_measurement(self): """Return the unit of measurement.""" - if self._unit.lower() == 'kwh': + if self._unit.lower() == "kwh": return ENERGY_KILO_WATT_HOUR return self._unit @@ -109,4 +126,5 @@ def update(self): # Set our value self._state = next( - iter(data['values']['LVR'][self._tariff]['records'].values())) + iter(data["values"]["LVR"][self._tariff]["records"].values()) + ) diff --git a/homeassistant/components/etherscan/manifest.json b/homeassistant/components/etherscan/manifest.json index 452d1c4c47534..b21f7d0e3fb67 100644 --- a/homeassistant/components/etherscan/manifest.json +++ b/homeassistant/components/etherscan/manifest.json @@ -1,10 +1,7 @@ { "domain": "etherscan", "name": "Etherscan", - "documentation": "https://www.home-assistant.io/components/etherscan", - "requirements": [ - "python-etherscan-api==0.0.3" - ], - "dependencies": [], + "documentation": "https://www.home-assistant.io/integrations/etherscan", + "requirements": ["python-etherscan-api==0.0.3"], "codeowners": [] } diff --git a/homeassistant/components/etherscan/sensor.py b/homeassistant/components/etherscan/sensor.py index 83805ec4d2015..1c14ce578c16f 100644 --- a/homeassistant/components/etherscan/sensor.py +++ b/homeassistant/components/etherscan/sensor.py @@ -1,26 +1,28 @@ """Support for Etherscan sensors.""" from datetime import timedelta +from pyetherscan import get_balance import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import ( - ATTR_ATTRIBUTION, CONF_ADDRESS, CONF_NAME, CONF_TOKEN) +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' +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, -}) +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): @@ -74,7 +76,7 @@ def device_state_attributes(self): 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: diff --git a/homeassistant/components/eufy/__init__.py b/homeassistant/components/eufy/__init__.py index 8425780b76b95..eca637ec37183 100644 --- a/homeassistant/components/eufy/__init__.py +++ b/homeassistant/components/eufy/__init__.py @@ -1,69 +1,82 @@ """Support for Eufy devices.""" import logging +import lakeside import voluptuous as vol from homeassistant.const import ( - CONF_ACCESS_TOKEN, CONF_ADDRESS, CONF_DEVICES, CONF_NAME, CONF_PASSWORD, - CONF_TYPE, CONF_USERNAME) + 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' +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 -}) +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) +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' + "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]) + data = lakeside.get_devices( + config[DOMAIN][CONF_USERNAME], config[DOMAIN][CONF_PASSWORD] + ) for device in data: - kind = device['type'] + kind = device["type"] if kind not in EUFY_DISPATCH: continue - discovery.load_platform(hass, EUFY_DISPATCH[kind], DOMAIN, device, - config) + discovery.load_platform(hass, EUFY_DISPATCH[kind], DOMAIN, device, config) for device_info in config[DOMAIN][CONF_DEVICES]: - kind = device_info['type'] + 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) + 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 index 1d08e42fff72f..2c23eca483f47 100644 --- a/homeassistant/components/eufy/light.py +++ b/homeassistant/components/eufy/light.py @@ -1,15 +1,22 @@ """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 lakeside +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP, + ATTR_HS_COLOR, + SUPPORT_BRIGHTNESS, + SUPPORT_COLOR, + SUPPORT_COLOR_TEMP, + LightEntity, +) import homeassistant.util.color as color_util - from homeassistant.util.color import ( + color_temperature_kelvin_to_mired as kelvin_to_mired, color_temperature_mired_to_kelvin as mired_to_kelvin, - color_temperature_kelvin_to_mired as kelvin_to_mired) +) _LOGGER = logging.getLogger(__name__) @@ -24,21 +31,20 @@ def setup_platform(hass, config, add_entities, discovery_info=None): add_entities([EufyLight(discovery_info)], True) -class EufyLight(Light): +class EufyLight(LightEntity): """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._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": @@ -46,8 +52,7 @@ def __init__(self, device): 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._features = SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_COLOR self._bulb.connect() def update(self): @@ -96,9 +101,9 @@ def max_mireds(self): @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)) + temp_in_k = int( + EUFY_MIN_KELVIN + (self._temp * (EUFY_MAX_KELVIN - EUFY_MIN_KELVIN) / 100) + ) return kelvin_to_mired(temp_in_k) @property @@ -131,28 +136,29 @@ def turn_on(self, **kwargs): 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)) + 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) + 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) + 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) + 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) + self._bulb.set_state( + power=True, brightness=brightness, temperature=temp, colors=rgb + ) def turn_off(self, **kwargs): """Turn the specified light off.""" diff --git a/homeassistant/components/eufy/manifest.json b/homeassistant/components/eufy/manifest.json index ec7f1fe707223..49956b9f0b2c0 100644 --- a/homeassistant/components/eufy/manifest.json +++ b/homeassistant/components/eufy/manifest.json @@ -1,10 +1,7 @@ { "domain": "eufy", - "name": "Eufy", - "documentation": "https://www.home-assistant.io/components/eufy", - "requirements": [ - "lakeside==0.12" - ], - "dependencies": [], + "name": "eufy", + "documentation": "https://www.home-assistant.io/integrations/eufy", + "requirements": ["lakeside==0.12"], "codeowners": [] } diff --git a/homeassistant/components/eufy/switch.py b/homeassistant/components/eufy/switch.py index 3216bfed69ea7..586965aa42b31 100644 --- a/homeassistant/components/eufy/switch.py +++ b/homeassistant/components/eufy/switch.py @@ -1,7 +1,9 @@ """Support for Eufy switches.""" import logging -from homeassistant.components.switch import SwitchDevice +import lakeside + +from homeassistant.components.switch import SwitchEntity _LOGGER = logging.getLogger(__name__) @@ -13,18 +15,17 @@ def setup_platform(hass, config, add_entities, discovery_info=None): add_entities([EufySwitch(discovery_info)], True) -class EufySwitch(SwitchDevice): +class EufySwitch(SwitchEntity): """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._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() diff --git a/homeassistant/components/everlights/light.py b/homeassistant/components/everlights/light.py index c5fb025370dfe..95571a825b2ec 100644 --- a/homeassistant/components/everlights/light.py +++ b/homeassistant/components/everlights/light.py @@ -1,52 +1,54 @@ """Support for EverLights lights.""" -import logging from datetime import timedelta +import logging from typing import Tuple +import pyeverlights 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) + ATTR_BRIGHTNESS, + ATTR_EFFECT, + ATTR_HS_COLOR, + PLATFORM_SCHEMA, + SUPPORT_BRIGHTNESS, + SUPPORT_COLOR, + SUPPORT_EFFECT, + LightEntity, +) +from homeassistant.const import CONF_HOSTS +from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession 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) +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 {}" +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + {vol.Required(CONF_HOSTS): vol.All(cv.ensure_list, [cv.string])} +) 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 + 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) + return (value >> 16, (value >> 8) & 0xFF, value & 0xFF) -async def async_setup_platform(hass, config, async_add_entities, - discovery_info=None): +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)) + api = pyeverlights.EverLights(ipaddr, async_get_clientsession(hass)) try: status = await api.get_status() @@ -57,15 +59,13 @@ async def async_setup_platform(hass, config, async_add_entities, raise PlatformNotReady else: - lights.append(EverLightsLight(api, pyeverlights.ZONE_1, - status, effects)) - lights.append(EverLightsLight(api, pyeverlights.ZONE_2, - status, effects)) + 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): +class EverLightsLight(LightEntity): """Representation of a Flux light.""" def __init__(self, api, channel, status, effects): @@ -74,7 +74,7 @@ def __init__(self, api, channel, status, effects): self._channel = channel self._status = status self._effects = effects - self._mac = status['mac'] + self._mac = status["mac"] self._error_reported = False self._hs_color = [255, 255] self._brightness = 255 @@ -84,7 +84,7 @@ def __init__(self, api, channel, status, effects): @property def unique_id(self) -> str: """Return a unique ID.""" - return '{}-{}'.format(self._mac, self._channel) + return f"{self._mac}-{self._channel}" @property def available(self) -> bool: @@ -94,12 +94,12 @@ def available(self) -> bool: @property def name(self): """Return the name of the device.""" - return NAME_FORMAT.format(self._mac, self._channel) + return f"EverLights {self._mac} Zone {self._channel}" @property def is_on(self): """Return true if device is on.""" - return self._status['ch{}Active'.format(self._channel)] == 1 + return self._status[f"ch{self._channel}Active"] == 1 @property def brightness(self): @@ -141,7 +141,7 @@ async def async_turn_on(self, **kwargs): brightness = hsv[2] / 100 * 255 else: - rgb = color_util.color_hsv_to_RGB(*hs_color, brightness/255*100) + 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) @@ -156,8 +156,6 @@ async def async_turn_off(self, **kwargs): async def async_update(self): """Synchronize state with control box.""" - import pyeverlights - try: self._status = await self._api.get_status() except pyeverlights.ConnectionError: diff --git a/homeassistant/components/everlights/manifest.json b/homeassistant/components/everlights/manifest.json index 9c2e1b2ae4f66..83cb166296df4 100644 --- a/homeassistant/components/everlights/manifest.json +++ b/homeassistant/components/everlights/manifest.json @@ -1,10 +1,7 @@ { "domain": "everlights", - "name": "Everlights", - "documentation": "https://www.home-assistant.io/components/everlights", - "requirements": [ - "pyeverlights==0.1.0" - ], - "dependencies": [], + "name": "EverLights", + "documentation": "https://www.home-assistant.io/integrations/everlights", + "requirements": ["pyeverlights==0.1.0"], "codeowners": [] } diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py index 562a32b07c6b7..e4d2cf00e71bd 100644 --- a/homeassistant/components/evohome/__init__.py +++ b/homeassistant/components/evohome/__init__.py @@ -1,272 +1,703 @@ -"""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 +"""Support for (EMEA/EU-based) Honeywell TCC climate systems. + +Such systems include evohome, Round Thermostat, and others. +""" +from datetime import datetime as dt, timedelta import logging +import re +from typing import Any, Dict, Optional, Tuple -import requests.exceptions +import aiohttp.client_exceptions +import evohomeasync +import evohomeasync2 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) + ATTR_ENTITY_ID, + CONF_PASSWORD, + CONF_SCAN_INTERVAL, + CONF_USERNAME, + HTTP_SERVICE_UNAVAILABLE, + HTTP_TOO_MANY_REQUESTS, + TEMP_CELSIUS, +) from homeassistant.core import callback +from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.discovery import load_platform +from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, async_dispatcher_send) + async_dispatcher_connect, + async_dispatcher_send, +) from homeassistant.helpers.entity import Entity +from homeassistant.helpers.service import verify_domain_control +from homeassistant.helpers.typing import ConfigType, HomeAssistantType +import homeassistant.util.dt as dt_util -from .const import ( - DOMAIN, DATA_EVOHOME, DISPATCHER_EVOHOME, GWS, TCS) +from .const import DOMAIN, EVO_FOLLOW, GWS, STORAGE_KEY, STORAGE_VER, TCS, UTC_OFFSET _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) +ACCESS_TOKEN = "access_token" +ACCESS_TOKEN_EXPIRES = "access_token_expires" +REFRESH_TOKEN = "refresh_token" +USER_DATA = "user_data" -CONF_SECRETS = [ - CONF_USERNAME, CONF_PASSWORD, -] +CONF_LOCATION_IDX = "location_idx" -# bit masks for dispatcher packets -EVO_PARENT = 0x01 -EVO_CHILD = 0x02 - - -def setup(hass, hass_config): - """Create a (EMEA/EU-based) Honeywell evohome system. +SCAN_INTERVAL_DEFAULT = timedelta(seconds=300) +SCAN_INTERVAL_MINIMUM = timedelta(seconds=60) + +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, +) + +ATTR_SYSTEM_MODE = "mode" +ATTR_DURATION_DAYS = "period" +ATTR_DURATION_HOURS = "duration" + +ATTR_ZONE_TEMP = "setpoint" +ATTR_DURATION_UNTIL = "duration" + +SVC_REFRESH_SYSTEM = "refresh_system" +SVC_SET_SYSTEM_MODE = "set_system_mode" +SVC_RESET_SYSTEM = "reset_system" +SVC_SET_ZONE_OVERRIDE = "set_zone_override" +SVC_RESET_ZONE_OVERRIDE = "clear_zone_override" + + +RESET_ZONE_OVERRIDE_SCHEMA = vol.Schema({vol.Required(ATTR_ENTITY_ID): cv.entity_id}) +SET_ZONE_OVERRIDE_SCHEMA = vol.Schema( + { + vol.Required(ATTR_ENTITY_ID): cv.entity_id, + vol.Required(ATTR_ZONE_TEMP): vol.All( + vol.Coerce(float), vol.Range(min=4.0, max=35.0) + ), + vol.Optional(ATTR_DURATION_UNTIL): vol.All( + cv.time_period, vol.Range(min=timedelta(days=0), max=timedelta(days=1)) + ), + } +) +# system mode schemas are built dynamically, below + + +def _dt_local_to_aware(dt_naive: dt) -> dt: + dt_aware = dt_util.now() + (dt_naive - dt.now()) + if dt_aware.microsecond >= 500000: + dt_aware += timedelta(seconds=1) + return dt_aware.replace(microsecond=0) + + +def _dt_aware_to_naive(dt_aware: dt) -> dt: + dt_naive = dt.now() + (dt_aware - dt_util.now()) + if dt_naive.microsecond >= 500000: + dt_naive += timedelta(seconds=1) + return dt_naive.replace(microsecond=0) + + +def convert_until(status_dict: dict, until_key: str) -> str: + """Reformat a dt str from "%Y-%m-%dT%H:%M:%SZ" as local/aware/isoformat.""" + if until_key in status_dict: # only present for certain modes + dt_utc_naive = dt_util.parse_datetime(status_dict[until_key]) + status_dict[until_key] = dt_util.as_local(dt_utc_naive).isoformat() + + +def convert_dict(dictionary: Dict[str, Any]) -> Dict[str, Any]: + """Recursively convert a dict's keys to snake_case.""" + + def convert_key(key: str) -> str: + """Convert a string to snake_case.""" + string = re.sub(r"[\-\.\s]", "_", str(key)) + return (string[0]).lower() + re.sub( + r"[A-Z]", lambda matched: f"_{matched.group(0).lower()}", string[1:] + ) - Currently, only the Controller and the Zones are implemented here. - """ - evo_data = hass.data[DATA_EVOHOME] = {} - evo_data['timers'] = {} + return { + (convert_key(k) if isinstance(k, str) else k): ( + convert_dict(v) if isinstance(v, dict) else v + ) + for k, v in dictionary.items() + } - # 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) +def _handle_exception(err) -> bool: + """Return False if the exception can't be ignored.""" try: - client = evo_data['client'] = evohomeclient2.EvohomeClient( - evo_data['params'][CONF_USERNAME], - evo_data['params'][CONF_PASSWORD], - debug=False - ) + raise err - except evohomeclient2.AuthenticationError as err: + except evohomeasync2.AuthenticationError: _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 + "Failed to authenticate with the vendor's server. " + "Check your network and the vendor's service status page. " + "Also check that your username and password are correct. " + "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." + except aiohttp.ClientConnectionError: + # this appears to be a common occurrence with the vendor's servers + _LOGGER.warning( + "Unable to connect with the vendor's server. " + "Check your network and the vendor's service status page. " + "Message is: %s", + err, ) 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 + except aiohttp.ClientResponseError: + if err.status == HTTP_SERVICE_UNAVAILABLE: + _LOGGER.warning( + "The vendor says their server is currently unavailable. " + "Check the vendor's service status page." + ) + return False + + if err.status == HTTP_TOO_MANY_REQUESTS: + _LOGGER.warning( + "The vendor's API rate limit has been exceeded. " + "If this message persists, consider increasing the %s.", + CONF_SCAN_INTERVAL, + ) + return False + + raise # we don't expect/handle any other Exceptions + + +async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: + """Create a (EMEA/EU-based) Honeywell TCC system.""" + + async def load_auth_tokens(store) -> Tuple[Dict, Optional[Dict]]: + app_storage = await store.async_load() + tokens = dict(app_storage if app_storage else {}) + + if tokens.pop(CONF_USERNAME, None) != config[DOMAIN][CONF_USERNAME]: + # any tokens won't be valid, and store might be be corrupt + await store.async_save({}) + return ({}, None) + + # evohomeasync2 requires naive/local datetimes as strings + if tokens.get(ACCESS_TOKEN_EXPIRES) is not None: + tokens[ACCESS_TOKEN_EXPIRES] = _dt_aware_to_naive( + dt_util.parse_datetime(tokens[ACCESS_TOKEN_EXPIRES]) + ) + + user_data = tokens.pop(USER_DATA, None) + return (tokens, user_data) - evo_data['status'] = {} + store = hass.helpers.storage.Store(STORAGE_VER, STORAGE_KEY) + tokens, user_data = await load_auth_tokens(store) - # 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' + client_v2 = evohomeasync2.EvohomeClient( + config[DOMAIN][CONF_USERNAME], + config[DOMAIN][CONF_PASSWORD], + **tokens, + session=async_get_clientsession(hass), + ) - # Pull down the installation configuration - loc_idx = evo_data['params'][CONF_LOCATION_IDX] try: - evo_data['config'] = client.installation_info[loc_idx] + await client_v2.login() + except (aiohttp.ClientError, evohomeasync2.AuthenticationError) as err: + _handle_exception(err) + return False + finally: + config[DOMAIN][CONF_PASSWORD] = "REDACTED" + loc_idx = config[DOMAIN][CONF_LOCATION_IDX] + try: + loc_config = client_v2.installation_info[loc_idx] except IndexError: _LOGGER.error( - "setup(): config error, '%s' = %s, but its valid range is 0-%s. " + "Config error: '%s' = %s, but the 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 + CONF_LOCATION_IDX, + loc_idx, + len(client_v2.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." + _config = {"locationInfo": {"timeZone": None}, GWS: [{TCS: None}]} + _config["locationInfo"]["timeZone"] = loc_config["locationInfo"]["timeZone"] + _config[GWS][0][TCS] = loc_config[GWS][0][TCS] + _LOGGER.debug("Config = %s", _config) + + client_v1 = evohomeasync.EvohomeClient( + client_v2.username, + client_v2.password, + user_data=user_data, + session=async_get_clientsession(hass), + ) + + hass.data[DOMAIN] = {} + hass.data[DOMAIN]["broker"] = broker = EvoBroker( + hass, client_v2, client_v1, store, config[DOMAIN] + ) + + await broker.save_auth_tokens() + await broker.async_update() # get initial state + + hass.async_create_task(async_load_platform(hass, "climate", DOMAIN, {}, config)) + if broker.tcs.hotwater: + hass.async_create_task( + async_load_platform(hass, "water_heater", DOMAIN, {}, config) ) - @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.helpers.event.async_track_time_interval( + broker.async_update, config[DOMAIN][CONF_SCAN_INTERVAL] + ) - hass.bus.listen(EVENT_HOMEASSISTANT_START, _first_update) + setup_service_functions(hass, broker) return True -class EvoDevice(Entity): - """Base for any Honeywell evohome device. +@callback +def setup_service_functions(hass: HomeAssistantType, broker): + """Set up the service handlers for the system/zone operating modes. + + Not all Honeywell TCC-compatible systems support all operating modes. In addition, + each mode will require any of four distinct service schemas. This has to be + enumerated before registering the appropriate handlers. - Such devices include the Controller, (up to 12) Heating Zones and - (optionally) a DHW controller. + It appears that all TCC-compatible systems support the same three zones modes. """ - def __init__(self, evo_data, client, obj_ref): - """Initialize the evohome entity.""" - self._client = client - self._obj = obj_ref + @verify_domain_control(hass, DOMAIN) + async def force_refresh(call) -> None: + """Obtain the latest state data via the vendor's RESTful API.""" + await broker.async_update() + + @verify_domain_control(hass, DOMAIN) + async def set_system_mode(call) -> None: + """Set the system mode.""" + payload = { + "unique_id": broker.tcs.systemId, + "service": call.service, + "data": call.data, + } + async_dispatcher_send(hass, DOMAIN, payload) + + @verify_domain_control(hass, DOMAIN) + async def set_zone_override(call) -> None: + """Set the zone override (setpoint).""" + entity_id = call.data[ATTR_ENTITY_ID] + + registry = await hass.helpers.entity_registry.async_get_registry() + registry_entry = registry.async_get(entity_id) + + if registry_entry is None or registry_entry.platform != DOMAIN: + raise ValueError(f"'{entity_id}' is not a known {DOMAIN} entity") + + if registry_entry.domain != "climate": + raise ValueError(f"'{entity_id}' is not an {DOMAIN} controller/zone") + + payload = { + "unique_id": registry_entry.unique_id, + "service": call.service, + "data": call.data, + } + + async_dispatcher_send(hass, DOMAIN, payload) + + hass.services.async_register(DOMAIN, SVC_REFRESH_SYSTEM, force_refresh) + + # Enumerate which operating modes are supported by this system + modes = broker.config["allowedSystemModes"] + + # Not all systems support "AutoWithReset": register this handler only if required + if [m["systemMode"] for m in modes if m["systemMode"] == "AutoWithReset"]: + hass.services.async_register(DOMAIN, SVC_RESET_SYSTEM, set_system_mode) + + system_mode_schemas = [] + modes = [m for m in modes if m["systemMode"] != "AutoWithReset"] + + # Permanent-only modes will use this schema + perm_modes = [m["systemMode"] for m in modes if not m["canBeTemporary"]] + if perm_modes: # any of: "Auto", "HeatingOff": permanent only + schema = vol.Schema({vol.Required(ATTR_SYSTEM_MODE): vol.In(perm_modes)}) + system_mode_schemas.append(schema) + + modes = [m for m in modes if m["canBeTemporary"]] + + # These modes are set for a number of hours (or indefinitely): use this schema + temp_modes = [m["systemMode"] for m in modes if m["timingMode"] == "Duration"] + if temp_modes: # any of: "AutoWithEco", permanent or for 0-24 hours + schema = vol.Schema( + { + vol.Required(ATTR_SYSTEM_MODE): vol.In(temp_modes), + vol.Optional(ATTR_DURATION_HOURS): vol.All( + cv.time_period, + vol.Range(min=timedelta(hours=0), max=timedelta(hours=24)), + ), + } + ) + system_mode_schemas.append(schema) + + # These modes are set for a number of days (or indefinitely): use this schema + temp_modes = [m["systemMode"] for m in modes if m["timingMode"] == "Period"] + if temp_modes: # any of: "Away", "Custom", "DayOff", permanent or for 1-99 days + schema = vol.Schema( + { + vol.Required(ATTR_SYSTEM_MODE): vol.In(temp_modes), + vol.Optional(ATTR_DURATION_DAYS): vol.All( + cv.time_period, + vol.Range(min=timedelta(days=1), max=timedelta(days=99)), + ), + } + ) + system_mode_schemas.append(schema) + + if system_mode_schemas: + hass.services.async_register( + DOMAIN, + SVC_SET_SYSTEM_MODE, + set_system_mode, + schema=vol.Any(*system_mode_schemas), + ) - self._name = None - self._icon = None - self._type = None + # The zone modes are consistent across all systems and use the same schema + hass.services.async_register( + DOMAIN, + SVC_RESET_ZONE_OVERRIDE, + set_zone_override, + schema=RESET_ZONE_OVERRIDE_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SVC_SET_ZONE_OVERRIDE, + set_zone_override, + schema=SET_ZONE_OVERRIDE_SCHEMA, + ) + + +class EvoBroker: + """Container for evohome client and data.""" + + def __init__(self, hass, client, client_v1, store, params) -> None: + """Initialize the evohome client and its data structure.""" + self.hass = hass + self.client = client + self.client_v1 = client_v1 + self._store = store + self.params = params + + loc_idx = params[CONF_LOCATION_IDX] + self.config = client.installation_info[loc_idx][GWS][0][TCS][0] + self.tcs = client.locations[loc_idx]._gateways[0]._control_systems[0] + self.tcs_utc_offset = timedelta( + minutes=client.locations[loc_idx].timeZone[UTC_OFFSET] + ) + self.temps = {} + + async def save_auth_tokens(self) -> None: + """Save access tokens and session IDs to the store for later use.""" + # evohomeasync2 uses naive/local datetimes + access_token_expires = _dt_local_to_aware(self.client.access_token_expires) + + app_storage = {CONF_USERNAME: self.client.username} + app_storage[REFRESH_TOKEN] = self.client.refresh_token + app_storage[ACCESS_TOKEN] = self.client.access_token + app_storage[ACCESS_TOKEN_EXPIRES] = access_token_expires.isoformat() + + if self.client_v1 and self.client_v1.user_data: + app_storage[USER_DATA] = { + "userInfo": {"userID": self.client_v1.user_data["userInfo"]["userID"]}, + "sessionId": self.client_v1.user_data["sessionId"], + } + else: + app_storage[USER_DATA] = None + + await self._store.async_save(app_storage) + + async def call_client_api(self, api_function, refresh=True) -> Any: + """Call a client API.""" + try: + result = await api_function + except (aiohttp.ClientError, evohomeasync2.AuthenticationError) as err: + if not _handle_exception(err): + return - self._supported_features = None - self._operation_list = None + if refresh: + self.hass.helpers.event.async_call_later(1, self.async_update()) - self._params = evo_data['params'] - self._timers = evo_data['timers'] - self._status = {} + return result - self._available = False # should become True after first update() + async def _update_v1(self, *args, **kwargs) -> None: + """Get the latest high-precision temperatures of the default Location.""" - @callback - def _connect(self, packet): - if packet['to'] & self._type and packet['signal'] == 'refresh': - self.async_schedule_update_ha_state(force_refresh=True) + def get_session_id(client_v1) -> Optional[str]: + user_data = client_v1.user_data if client_v1 else None + return user_data.get("sessionId") if user_data else None - def _handle_exception(self, err): - try: - raise err + session_id = get_session_id(self.client_v1) - except evohomeclient2.AuthenticationError: - _LOGGER.error( - "Failed to (re)authenticate with the vendor's server. " - "This may be a temporary error. Message is: %s", - err - ) + try: + temps = list(await self.client_v1.temperatures(force_refresh=True)) - except requests.exceptions.ConnectionError: - # this appears to be common with Honeywell's servers + except aiohttp.ClientError as err: _LOGGER.warning( - "Unable to connect with the vendor's server. " - "Check your network and the vendor's status page." + "Unable to obtain the latest high-precision temperatures. " + "Check your network and the vendor's service status page. " + "Proceeding with low-precision temperatures. " + "Message is: %s", + err, ) + self.temps = None # these are now stale, will fall back to v2 temps - except requests.exceptions.HTTPError: - if err.response.status_code == HTTP_SERVICE_UNAVAILABLE: + else: + if ( + str(self.client_v1.location_id) + != self.client.locations[self.params[CONF_LOCATION_IDX]].locationId + ): _LOGGER.warning( - "Vendor says their server is currently unavailable. " - "This may be temporary; check the vendor's status page." + "The v2 API's configured location doesn't match " + "the v1 API's default location (there is more than one location), " + "so the high-precision feature will be disabled" ) + self.client_v1 = self.temps = None + else: + self.temps = {str(i["id"]): i["temp"] for i in temps} - 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 + _LOGGER.debug("Temperatures = %s", self.temps) - else: - raise # we don't expect/handle any other HTTPErrors + if session_id != get_session_id(self.client_v1): + await self.save_auth_tokens() - # 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) + async def _update_v2(self, *args, **kwargs) -> None: + """Get the latest modes, temperatures, setpoints of a Location.""" + access_token = self.client.access_token - @property - def should_poll(self) -> bool: - """Most evohome devices push their state to HA. + loc_idx = self.params[CONF_LOCATION_IDX] + try: + status = await self.client.locations[loc_idx].status() + except (aiohttp.ClientError, evohomeasync2.AuthenticationError) as err: + _handle_exception(err) + else: + async_dispatcher_send(self.hass, DOMAIN) - Only the Controller should be polled. + _LOGGER.debug("Status = %s", status) + + if access_token != self.client.access_token: + await self.save_auth_tokens() + + async def async_update(self, *args, **kwargs) -> None: + """Get the latest state data of an entire Honeywell TCC Location. + + This includes state data for a 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). """ + await self._update_v2() + + if self.client_v1: + await self._update_v1() + + # inform the evohome devices that state data has been updated + async_dispatcher_send(self.hass, DOMAIN) + + +class EvoDevice(Entity): + """Base for any evohome device. + + This includes the Controller, (up to 12) Heating Zones and (optionally) a + DHW controller. + """ + + def __init__(self, evo_broker, evo_device) -> None: + """Initialize the evohome entity.""" + self._evo_device = evo_device + self._evo_broker = evo_broker + self._evo_tcs = evo_broker.tcs + + self._unique_id = self._name = self._icon = self._precision = None + self._supported_features = None + self._device_state_attrs = {} + + async def async_refresh(self, payload: Optional[dict] = None) -> None: + """Process any signals.""" + if payload is None: + self.async_schedule_update_ha_state(force_refresh=True) + return + if payload["unique_id"] != self._unique_id: + return + if payload["service"] in [SVC_SET_ZONE_OVERRIDE, SVC_RESET_ZONE_OVERRIDE]: + await self.async_zone_svc_request(payload["service"], payload["data"]) + return + await self.async_tcs_svc_request(payload["service"], payload["data"]) + + async def async_tcs_svc_request(self, service: dict, data: dict) -> None: + """Process a service request (system mode) for a controller.""" + raise NotImplementedError + + async def async_zone_svc_request(self, service: dict, data: dict) -> None: + """Process a service request (setpoint override) for a zone.""" + raise NotImplementedError + + @property + def should_poll(self) -> bool: + """Evohome entities should not be polled.""" return False + @property + def unique_id(self) -> Optional[str]: + """Return a unique ID.""" + return self._unique_id + @property def name(self) -> str: - """Return the name to use in the frontend UI.""" + """Return the name of the evohome entity.""" 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} + def device_state_attributes(self) -> Dict[str, Any]: + """Return the evohome-specific state attributes.""" + status = self._device_state_attrs + if "systemModeStatus" in status: + convert_until(status["systemModeStatus"], "timeUntil") + if "setpointStatus" in status: + convert_until(status["setpointStatus"], "until") + if "stateStatus" in status: + convert_until(status["stateStatus"], "until") + + return {"status": convert_dict(status)} @property - def icon(self): + def icon(self) -> str: """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.""" + def supported_features(self) -> int: + """Get the flag of supported features of the device.""" return self._supported_features - # These properties are common to ClimateDevice, WaterHeaterDevice classes + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + async_dispatcher_connect(self.hass, DOMAIN, self.async_refresh) + @property - def precision(self): + def precision(self) -> float: """Return the temperature precision to use in the frontend UI.""" - return PRECISION_HALVES + return self._precision @property - def temperature_unit(self): + def temperature_unit(self) -> str: """Return the temperature unit to use in the frontend UI.""" return TEMP_CELSIUS + +class EvoChild(EvoDevice): + """Base for any evohome child. + + This includes (up to 12) Heating Zones and (optionally) a DHW controller. + """ + + def __init__(self, evo_broker, evo_device) -> None: + """Initialize a evohome Controller (hub).""" + super().__init__(evo_broker, evo_device) + self._schedule = {} + self._setpoints = {} + + @property + def current_temperature(self) -> Optional[float]: + """Return the current temperature of a Zone.""" + if not self._evo_device.temperatureStatus["isAvailable"]: + return None + + if self._evo_broker.temps: + return self._evo_broker.temps[self._evo_device.zoneId] + + return self._evo_device.temperatureStatus["temperature"] + @property - def operation_list(self): - """Return the list of available operations.""" - return self._operation_list + def setpoints(self) -> Dict[str, Any]: + """Return the current/next setpoints from the schedule. + + Only Zones & DHW controllers (but not the TCS) can have schedules. + """ + + def _dt_evo_to_aware(dt_naive: dt, utc_offset: timedelta) -> dt: + dt_aware = dt_naive.replace(tzinfo=dt_util.UTC) - utc_offset + return dt_util.as_local(dt_aware) + + if not self._schedule["DailySchedules"]: + return {} # no schedule {'DailySchedules': []}, so no scheduled setpoints + + day_time = dt_util.now() + day_of_week = int(day_time.strftime("%w")) # 0 is Sunday + time_of_day = day_time.strftime("%H:%M:%S") + + try: + # Iterate today's switchpoints until past the current time of day... + day = self._schedule["DailySchedules"][day_of_week] + sp_idx = -1 # last switchpoint of the day before + for i, tmp in enumerate(day["Switchpoints"]): + if time_of_day > tmp["TimeOfDay"]: + sp_idx = i # current setpoint + else: + break + + # Did the current SP start yesterday? Does the next start SP tomorrow? + this_sp_day = -1 if sp_idx == -1 else 0 + next_sp_day = 1 if sp_idx + 1 == len(day["Switchpoints"]) else 0 + + for key, offset, idx in [ + ("this", this_sp_day, sp_idx), + ("next", next_sp_day, (sp_idx + 1) * (1 - next_sp_day)), + ]: + sp_date = (day_time + timedelta(days=offset)).strftime("%Y-%m-%d") + day = self._schedule["DailySchedules"][(day_of_week + offset) % 7] + switchpoint = day["Switchpoints"][idx] + + dt_aware = _dt_evo_to_aware( + dt_util.parse_datetime(f"{sp_date}T{switchpoint['TimeOfDay']}"), + self._evo_broker.tcs_utc_offset, + ) + + self._setpoints[f"{key}_sp_from"] = dt_aware.isoformat() + try: + self._setpoints[f"{key}_sp_temp"] = switchpoint["heatSetpoint"] + except KeyError: + self._setpoints[f"{key}_sp_state"] = switchpoint["DhwState"] + + except IndexError: + self._setpoints = {} + _LOGGER.warning( + "Failed to get setpoints, report as an issue if this error persists", + exc_info=True, + ) + + return self._setpoints + + async def _update_schedule(self) -> None: + """Get the latest schedule, if any.""" + if "DailySchedules" in self._schedule and not self._schedule["DailySchedules"]: + if not self._evo_device.setpointStatus["setpointMode"] == EVO_FOLLOW: + return # avoid unnecessary I/O - there's nothing to update + + self._schedule = await self._evo_broker.call_client_api( + self._evo_device.schedule(), refresh=False + ) + + _LOGGER.debug("Schedule['%s'] = %s", self.name, self._schedule) + + async def async_update(self) -> None: + """Get the latest state data.""" + next_sp_from = self._setpoints.get("next_sp_from", "2000-01-01T00:00:00+00:00") + if dt_util.now() >= dt_util.parse_datetime(next_sp_from): + await self._update_schedule() # no schedule, or it's out-of-date + + self._device_state_attrs = {"setpoints": self.setpoints} diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py index 3e8aefe39c4d2..c6edb4aa1dc88 100644 --- a/homeassistant/components/evohome/climate.py +++ b/homeassistant/components/evohome/climate.py @@ -1,457 +1,428 @@ -"""Support for Climate devices of (EMEA/EU-based) Honeywell evohome systems.""" -from datetime import datetime, timedelta +"""Support for Climate devices of (EMEA/EU-based) Honeywell TCC systems.""" +from datetime import datetime as dt import logging +from typing import List, Optional -import requests.exceptions - -import evohomeclient2 - -from homeassistant.components.climate import ClimateDevice +from homeassistant.components.climate import ClimateEntity 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 + CURRENT_HVAC_HEAT, + CURRENT_HVAC_IDLE, + CURRENT_HVAC_OFF, + HVAC_MODE_AUTO, + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + PRESET_AWAY, + PRESET_ECO, + PRESET_HOME, + PRESET_NONE, + SUPPORT_PRESET_MODE, + SUPPORT_TARGET_TEMPERATURE, +) +from homeassistant.const import PRECISION_TENTHS +from homeassistant.helpers.typing import ConfigType, HomeAssistantType +import homeassistant.util.dt as dt_util from . import ( + ATTR_DURATION_DAYS, + ATTR_DURATION_HOURS, + ATTR_DURATION_UNTIL, + ATTR_SYSTEM_MODE, + ATTR_ZONE_TEMP, + CONF_LOCATION_IDX, + SVC_RESET_ZONE_OVERRIDE, + SVC_SET_SYSTEM_MODE, + EvoChild, EvoDevice, - CONF_LOCATION_IDX, EVO_CHILD, EVO_PARENT) +) from .const import ( - DATA_EVOHOME, DISPATCHER_EVOHOME, GWS, TCS) + DOMAIN, + EVO_AUTO, + EVO_AUTOECO, + EVO_AWAY, + EVO_CUSTOM, + EVO_DAYOFF, + EVO_FOLLOW, + EVO_HEATOFF, + EVO_PERMOVER, + EVO_RESET, + EVO_TEMPOVER, +) _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 +PRESET_RESET = "Reset" # reset all child zones to EVO_FOLLOW +PRESET_CUSTOM = "Custom" + +HA_HVAC_TO_TCS = {HVAC_MODE_OFF: EVO_HEATOFF, HVAC_MODE_HEAT: EVO_AUTO} + +TCS_PRESET_TO_HA = { + EVO_AWAY: PRESET_AWAY, + EVO_CUSTOM: PRESET_CUSTOM, + EVO_AUTOECO: PRESET_ECO, + EVO_DAYOFF: PRESET_HOME, + EVO_RESET: PRESET_RESET, +} # EVO_AUTO: None, + +HA_PRESET_TO_TCS = {v: k for k, v in TCS_PRESET_TO_HA.items()} + +EVO_PRESET_TO_HA = { + EVO_FOLLOW: PRESET_NONE, + EVO_TEMPOVER: "temporary", + EVO_PERMOVER: "permanent", } -ZONE_OP_LIST = list(HA_STATE_TO_ZONE) +HA_PRESET_TO_EVO = {v: k for k, v in EVO_PRESET_TO_HA.items()} +STATE_ATTRS_TCS = ["systemId", "activeFaults", "systemModeStatus"] +STATE_ATTRS_ZONES = ["zoneId", "activeFaults", "setpointStatus", "temperatureStatus"] -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] +async def async_setup_platform( + hass: HomeAssistantType, config: ConfigType, async_add_entities, discovery_info=None +) -> None: + """Create the evohome Controller, and its Zones, if any.""" + if discovery_info is None: + return - # 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 + broker = hass.data[DOMAIN]["broker"] _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) + "Found the Location/Controller (%s), id=%s, name=%s (location_idx=%s)", + broker.tcs.modelType, + broker.tcs.systemId, + broker.tcs.location.name, + broker.params[CONF_LOCATION_IDX], + ) - controller = EvoController(evo_data, client, tcs_obj_ref) - zones = [] + controller = EvoController(broker, broker.tcs) - 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)) + zones = [] + for zone in broker.tcs.zones.values(): + if zone.modelType == "HeatingZone" or zone.zoneType == "Thermostat": + _LOGGER.debug( + "Adding: %s (%s), id=%s, name=%s", + zone.zoneType, + zone.modelType, + zone.zoneId, + zone.name, + ) - entities = [controller] + zones + new_entity = EvoZone(broker, zone) + zones.append(new_entity) - async_add_entities(entities, update_before_add=False) + else: + _LOGGER.warning( + "Ignoring: %s (%s), id=%s, name=%s: unknown/invalid zone type, " + "report as an issue if you feel this zone type should be supported", + zone.zoneType, + zone.modelType, + zone.zoneId, + zone.name, + ) + async_add_entities([controller] + zones, update_before_add=True) -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) +class EvoClimateEntity(EvoDevice, ClimateEntity): + """Base for an evohome Climate device.""" - self._id = obj_ref.zoneId - self._name = obj_ref.name - self._icon = "mdi:radiator" - self._type = EVO_CHILD + def __init__(self, evo_broker, evo_device) -> None: + """Initialize a Climate device.""" + super().__init__(evo_broker, evo_device) - for _zone in evo_data['config'][GWS][0][TCS][0]['zones']: - if _zone['zoneId'] == self._id: - self._config = _zone - break - self._status = {} + self._preset_modes = None - self._operation_list = ZONE_OP_LIST - self._supported_features = \ - SUPPORT_OPERATION_MODE | \ - SUPPORT_TARGET_TEMPERATURE | \ - SUPPORT_ON_OFF + @property + def hvac_modes(self) -> List[str]: + """Return a list of available hvac operation modes.""" + return list(HA_HVAC_TO_TCS) @property - def current_operation(self): - """Return the current operating mode of the evohome Zone. + def preset_modes(self) -> Optional[List[str]]: + """Return a list of available preset modes.""" + return self._preset_modes - 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'] +class EvoZone(EvoChild, EvoClimateEntity): + """Base for a Honeywell TCC Zone.""" - 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) + def __init__(self, evo_broker, evo_device) -> None: + """Initialize a Honeywell TCC Zone.""" + super().__init__(evo_broker, evo_device) + + if evo_device.modelType.startswith("VisionProWifi"): + # this system does not have a distinct ID for the zone + self._unique_id = f"{evo_device.zoneId}z" + else: + self._unique_id = evo_device.zoneId + + self._name = evo_device.name + self._icon = "mdi:radiator" + + if evo_broker.client_v1: + self._precision = PRECISION_TENTHS + else: + self._precision = self._evo_device.setpointCapabilities["valueResolution"] + + self._preset_modes = list(HA_PRESET_TO_EVO) + self._supported_features = SUPPORT_PRESET_MODE | SUPPORT_TARGET_TEMPERATURE + + async def async_zone_svc_request(self, service: dict, data: dict) -> None: + """Process a service request (setpoint override) for a zone.""" + if service == SVC_RESET_ZONE_OVERRIDE: + await self._evo_broker.call_client_api( + self._evo_device.cancel_temp_override() + ) + return + + # otherwise it is SVC_SET_ZONE_OVERRIDE + temperature = max(min(data[ATTR_ZONE_TEMP], self.max_temp), self.min_temp) + + if ATTR_DURATION_UNTIL in data: + duration = data[ATTR_DURATION_UNTIL] + if duration.total_seconds() == 0: + await self._update_schedule() + until = dt_util.parse_datetime(self.setpoints.get("next_sp_from", "")) else: - current_operation = TCS_STATE_TO_HA.get(system_mode) + until = dt_util.now() + data[ATTR_DURATION_UNTIL] else: - current_operation = ZONE_STATE_TO_HA.get(setpoint_mode) + until = None # indefinitely - return current_operation + until = dt_util.as_utc(until) if until else None + await self._evo_broker.call_client_api( + self._evo_device.set_temperature(temperature, until=until) + ) @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) + def hvac_mode(self) -> str: + """Return the current operating mode of a Zone.""" + if self._evo_tcs.systemModeStatus["mode"] in [EVO_AWAY, EVO_HEATOFF]: + return HVAC_MODE_AUTO + is_off = self.target_temperature <= self.min_temp + return HVAC_MODE_OFF if is_off else HVAC_MODE_HEAT @property - def target_temperature(self): - """Return the target temperature of the evohome Zone.""" - return self._status['setpointStatus']['targetHeatTemperature'] + def hvac_action(self) -> Optional[str]: + """Return the current running hvac operation if supported.""" + if self._evo_tcs.systemModeStatus["mode"] == EVO_HEATOFF: + return CURRENT_HVAC_OFF + if self.target_temperature <= self.min_temp: + return CURRENT_HVAC_OFF + if not self._evo_device.temperatureStatus["isAvailable"]: + return None + if self.target_temperature <= self.current_temperature: + return CURRENT_HVAC_IDLE + return CURRENT_HVAC_HEAT @property - def is_on(self) -> bool: - """Return True if the evohome Zone is off. + def target_temperature(self) -> float: + """Return the target temperature of a Zone.""" + return self._evo_device.setpointStatus["targetHeatTemperature"] - 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 preset_mode(self) -> Optional[str]: + """Return the current preset mode, e.g., home, away, temp.""" + if self._evo_tcs.systemModeStatus["mode"] in [EVO_AWAY, EVO_HEATOFF]: + return TCS_PRESET_TO_HA.get(self._evo_tcs.systemModeStatus["mode"]) + return EVO_PRESET_TO_HA.get(self._evo_device.setpointStatus["setpointMode"]) @property - def min_temp(self): - """Return the minimum target temperature of a evohome Zone. + def min_temp(self) -> float: + """Return the minimum target temperature of a Zone. - The default is 5 (in Celsius), but it is configurable within 5-35. + The default is 5, but is user-configurable within 5-35 (in Celsius). """ - return self._config['setpointCapabilities']['minHeatSetpoint'] + return self._evo_device.setpointCapabilities["minHeatSetpoint"] @property - def max_temp(self): - """Return the maximum target temperature of a evohome Zone. + def max_temp(self) -> float: + """Return the maximum target temperature of a Zone. - The default is 35 (in Celsius), but it is configurable within 5-35. + The default is 35, but is user-configurable within 5-35 (in Celsius). """ - return self._config['setpointCapabilities']['maxHeatSetpoint'] + return self._evo_device.setpointCapabilities["maxHeatSetpoint"] - def _set_temperature(self, temperature, until=None): - """Set the new target temperature of a Zone. + async def async_set_temperature(self, **kwargs) -> None: + """Set a new target temperature.""" + temperature = kwargs["temperature"] + until = kwargs.get("until") - 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) + if until is None: + if self._evo_device.setpointStatus["setpointMode"] == EVO_FOLLOW: + await self._update_schedule() + until = dt_util.parse_datetime(self.setpoints.get("next_sp_from", "")) + elif self._evo_device.setpointStatus["setpointMode"] == EVO_TEMPOVER: + until = dt_util.parse_datetime(self._evo_device.setpointStatus["until"]) - def set_temperature(self, **kwargs): - """Set new target temperature, indefinitely.""" - self._set_temperature(kwargs['temperature'], until=None) + until = dt_util.as_utc(until) if until else None + await self._evo_broker.call_client_api( + self._evo_device.set_temperature(temperature, until=until) + ) - def turn_on(self): - """Turn the evohome Zone on. + async def async_set_hvac_mode(self, hvac_mode: str) -> None: + """Set a Zone to one of its native EVO_* operating modes. - This is achieved by setting the Zone to its 'FollowSchedule' mode. - """ - self._set_operation_mode(EVO_FOLLOW) + Zones inherit their _effective_ operating mode from their Controller. + + Usually, Zones are in 'FollowSchedule' mode, where their setpoints are a + function of their own schedule and the Controller's operating mode, e.g. + 'AutoWithEco' mode means their setpoint is (by default) 3C less than scheduled. - def turn_off(self): - """Turn the evohome Zone off. + However, Zones can _override_ these setpoints, either indefinitely, + 'PermanentOverride' mode, or for a set period of time, 'TemporaryOverride' mode + (after which they will revert back to 'FollowSchedule' mode). - This is achieved by setting the Zone to its minimum temperature, - indefinitely (i.e. 'PermanentOverride' mode). + Finally, some of the Controller's operating modes are _forced_ upon the Zones, + regardless of any override mode, e.g. 'HeatingOff', Zones to (by default) 5C, + and 'Away', Zones to (by default) 12C. """ - 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 + if hvac_mode == HVAC_MODE_OFF: + await self._evo_broker.call_client_api( + self._evo_device.set_temperature(self.min_temp, until=None) ) - - 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 + else: # HVAC_MODE_HEAT + await self._evo_broker.call_client_api( + self._evo_device.cancel_temp_override() ) - 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. + async def async_set_preset_mode(self, preset_mode: Optional[str]) -> None: + """Set the preset mode; if None, then revert to following the schedule.""" + evo_preset_mode = HA_PRESET_TO_EVO.get(preset_mode, EVO_FOLLOW) - NB: evohome Zones do not have an operating mode as understood by HA. - Instead they usually 'inherit' an operating mode from their controller. + if evo_preset_mode == EVO_FOLLOW: + await self._evo_broker.call_client_api( + self._evo_device.cancel_temp_override() + ) + return - 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. + temperature = self._evo_device.setpointStatus["targetHeatTemperature"] - 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. + if evo_preset_mode == EVO_TEMPOVER: + await self._update_schedule() + until = dt_util.parse_datetime(self.setpoints.get("next_sp_from", "")) + else: # EVO_PERMOVER + until = None - 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)) + until = dt_util.as_utc(until) if until else None + await self._evo_broker.call_client_api( + self._evo_device.set_temperature(temperature, until=until) + ) - def update(self): - """Process the evohome Zone's state data.""" - evo_data = self.hass.data[DATA_EVOHOME] + async def async_update(self) -> None: + """Get the latest state data for a Zone.""" + await super().async_update() - for _zone in evo_data['status']['zones']: - if _zone['zoneId'] == self._id: - self._status = _zone - break + for attr in STATE_ATTRS_ZONES: + self._device_state_attrs[attr] = getattr(self._evo_device, attr) - self._available = True +class EvoController(EvoClimateEntity): + """Base for a Honeywell TCC Controller/Location. -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 implemented as a Climate entity to expose the controller's + operating modes to HA. - The Controller (aka TCS, temperature control system) is the parent of all - the child (CH/DHW) devices. It is also a Climate device. + It is assumed there is only one TCS per location, and they are thus synonymous. """ - def __init__(self, evo_data, client, obj_ref): - """Initialize the evohome Controller (hub).""" - super().__init__(evo_data, client, obj_ref) + def __init__(self, evo_broker, evo_device) -> None: + """Initialize a Honeywell TCC Controller/Location.""" + super().__init__(evo_broker, evo_device) - self._id = obj_ref.systemId - self._name = '_{}'.format(obj_ref.location.name) + self._unique_id = evo_device.systemId + self._name = evo_device.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._precision = PRECISION_TENTHS - self._operation_list = TCS_OP_LIST - self._supported_features = \ - SUPPORT_OPERATION_MODE | \ - SUPPORT_AWAY_MODE + modes = [m["systemMode"] for m in evo_broker.config["allowedSystemModes"]] + self._preset_modes = [ + TCS_PRESET_TO_HA[m] for m in modes if m in list(TCS_PRESET_TO_HA) + ] + self._supported_features = SUPPORT_PRESET_MODE if self._preset_modes else 0 - @property - def device_state_attributes(self): - """Return the device state attributes of the evohome Controller. + async def async_tcs_svc_request(self, service: dict, data: dict) -> None: + """Process a service request (system mode) for a controller. - This is state data that is not available otherwise, due to the - restrictions placed upon ClimateDevice properties, etc. by HA. + Data validation is not required, it will have been done upstream. """ - status = dict(self._status) - - if 'zones' in status: - del status['zones'] - if 'dhw' in status: - del status['dhw'] + if service == SVC_SET_SYSTEM_MODE: + mode = data[ATTR_SYSTEM_MODE] + else: # otherwise it is SVC_RESET_SYSTEM + mode = EVO_RESET - return {'status': status} + if ATTR_DURATION_DAYS in data: + until = dt_util.start_of_local_day() + until += data[ATTR_DURATION_DAYS] - @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. + elif ATTR_DURATION_HOURS in data: + until = dt_util.now() + data[ATTR_DURATION_HOURS] - 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] + else: + until = None - avg_temp = round(sum(temps) / len(temps), 1) if temps else None - return avg_temp + await self._set_tcs_mode(mode, until=until) - @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 + async def _set_tcs_mode(self, mode: str, until: Optional[dt] = None) -> None: + """Set a Controller to any of its native EVO_* operating modes.""" + until = dt_util.as_utc(until) if until else None + await self._evo_broker.call_client_api( + self._evo_tcs.set_status(mode, until=until) + ) @property - def is_away_mode_on(self) -> bool: - """Return True if away mode is on.""" - return self._status['systemModeStatus']['mode'] == EVO_AWAY + def hvac_mode(self) -> str: + """Return the current operating mode of a Controller.""" + tcs_mode = self._evo_tcs.systemModeStatus["mode"] + return HVAC_MODE_OFF if tcs_mode == EVO_HEATOFF else HVAC_MODE_HEAT @property - def is_on(self) -> bool: - """Return True as evohome Controllers are always on. + def current_temperature(self) -> Optional[float]: + """Return the average current temperature of the heating Zones. - For example, evohome Controllers have a 'HeatingOff' mode, but even - then the DHW would remain on. + Controllers do not have a current temp, but one is expected by HA. """ - return True + temps = [ + z.temperatureStatus["temperature"] + for z in self._evo_tcs.zones.values() + if z.temperatureStatus["isAvailable"] + ] + return round(sum(temps) / len(temps), 1) if temps else None @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 + def preset_mode(self) -> Optional[str]: + """Return the current preset mode, e.g., home, away, temp.""" + return TCS_PRESET_TO_HA.get(self._evo_tcs.systemModeStatus["mode"]) @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 + def min_temp(self) -> float: + """Return None as Controllers don't have a target temperature.""" + return None @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) + def max_temp(self) -> float: + """Return None as Controllers don't have a target temperature.""" + return None + + async def async_set_temperature(self, **kwargs) -> None: + """Raise exception as Controllers don't have a target temperature.""" + raise NotImplementedError("Evohome Controllers don't have target temperatures.") + + async def async_set_hvac_mode(self, hvac_mode: str) -> None: + """Set an operating mode for a Controller.""" + await self._set_tcs_mode(HA_HVAC_TO_TCS.get(hvac_mode)) + + async def async_set_preset_mode(self, preset_mode: Optional[str]) -> None: + """Set the preset mode; if None, then revert to 'Auto' mode.""" + await self._set_tcs_mode(HA_PRESET_TO_TCS.get(preset_mode, EVO_AUTO)) + + async def async_update(self) -> None: + """Get the latest state data for a Controller.""" + self._device_state_attrs = {} + + attrs = self._device_state_attrs + for attr in STATE_ATTRS_TCS: + if attr == "activeFaults": + attrs["activeSystemFaults"] = getattr(self._evo_tcs, attr) + else: + attrs[attr] = getattr(self._evo_tcs, attr) diff --git a/homeassistant/components/evohome/const.py b/homeassistant/components/evohome/const.py index 9fe1c49064fc7..6bd3a59c22512 100644 --- a/homeassistant/components/evohome/const.py +++ b/homeassistant/components/evohome/const.py @@ -1,9 +1,25 @@ -"""Provides the constants needed for evohome.""" +"""Support for (EMEA/EU-based) Honeywell TCC climate systems.""" +DOMAIN = "evohome" -DOMAIN = 'evohome' -DATA_EVOHOME = 'data_' + DOMAIN -DISPATCHER_EVOHOME = 'dispatcher_' + DOMAIN +STORAGE_VER = 1 +STORAGE_KEY = DOMAIN -# These are used only to help prevent E501 (line too long) violations. -GWS = 'gateways' -TCS = 'temperatureControlSystems' +# The Parent's (i.e. TCS, Controller's) operating mode is one of: +EVO_RESET = "AutoWithReset" +EVO_AUTO = "Auto" +EVO_AUTOECO = "AutoWithEco" +EVO_AWAY = "Away" +EVO_DAYOFF = "DayOff" +EVO_CUSTOM = "Custom" +EVO_HEATOFF = "HeatingOff" + +# The Children's operating mode is one of: +EVO_FOLLOW = "FollowSchedule" # the operating mode is 'inherited' from the TCS +EVO_TEMPOVER = "TemporaryOverride" +EVO_PERMOVER = "PermanentOverride" + +# These are used only to help prevent E501 (line too long) violations +GWS = "gateways" +TCS = "temperatureControlSystems" + +UTC_OFFSET = "currentOffsetMinutes" diff --git a/homeassistant/components/evohome/manifest.json b/homeassistant/components/evohome/manifest.json index 33c1dd247b621..8bcecca551b14 100644 --- a/homeassistant/components/evohome/manifest.json +++ b/homeassistant/components/evohome/manifest.json @@ -1,10 +1,7 @@ { "domain": "evohome", - "name": "Evohome", - "documentation": "https://www.home-assistant.io/components/evohome", - "requirements": [ - "evohomeclient==0.3.2" - ], - "dependencies": [], + "name": "Honeywell Total Connect Comfort (Europe)", + "documentation": "https://www.home-assistant.io/integrations/evohome", + "requirements": ["evohome-async==0.3.5.post1"], "codeowners": ["@zxdavb"] } diff --git a/homeassistant/components/evohome/services.yaml b/homeassistant/components/evohome/services.yaml new file mode 100644 index 0000000000000..04f7a3ac2aa33 --- /dev/null +++ b/homeassistant/components/evohome/services.yaml @@ -0,0 +1,53 @@ +# Support for (EMEA/EU-based) Honeywell TCC climate systems. +# Describes the format for available services + +set_system_mode: + description: >- + Set the system mode, either indefinitely, or for a specified period of time, after + which it will revert to Auto. Not all systems support all modes. + fields: + mode: + description: "One of: Auto, AutoWithEco, Away, DayOff, HeatingOff, or Custom." + example: Away + period: + description: >- + A period of time in days; used only with Away, DayOff, or Custom. The system + will revert to Auto at midnight (up to 99 days, today is day 1). + example: '{"days": 28}' + duration: + description: The duration in hours; used only with AutoWithEco (up to 24 hours). + example: '{"hours": 18}' + +reset_system: + description: >- + Set the system to Auto mode and reset all the zones to follow their schedules. + Not all Evohome systems support this feature (i.e. AutoWithReset mode). + +refresh_system: + description: >- + Pull the latest data from the vendor's servers now, rather than waiting for the + next scheduled update. + +set_zone_override: + description: >- + Override a zone's setpoint, either indefinitely, or for a specified period of + time, after which it will revert to following its schedule. + fields: + entity_id: + description: The entity_id of the Evohome zone. + example: climate.bathroom + setpoint: + description: The temperature to be used instead of the scheduled setpoint. + example: 5.0 + duration: + description: >- + The zone will revert to its schedule after this time. If 0 the change is until + the next scheduled setpoint. + example: '{"minutes": 135}' + +clear_zone_override: + description: Set a zone to follow its schedule. + fields: + entity_id: + description: The entity_id of the zone. + example: climate.bathroom diff --git a/homeassistant/components/evohome/water_heater.py b/homeassistant/components/evohome/water_heater.py new file mode 100644 index 0000000000000..846c8c0915532 --- /dev/null +++ b/homeassistant/components/evohome/water_heater.py @@ -0,0 +1,118 @@ +"""Support for WaterHeater devices of (EMEA/EU) Honeywell TCC systems.""" +import logging +from typing import List + +from homeassistant.components.water_heater import ( + SUPPORT_AWAY_MODE, + SUPPORT_OPERATION_MODE, + WaterHeaterEntity, +) +from homeassistant.const import PRECISION_TENTHS, PRECISION_WHOLE, STATE_OFF, STATE_ON +from homeassistant.helpers.typing import ConfigType, HomeAssistantType +import homeassistant.util.dt as dt_util + +from . import EvoChild +from .const import DOMAIN, EVO_FOLLOW, EVO_PERMOVER + +_LOGGER = logging.getLogger(__name__) + +STATE_AUTO = "auto" + +HA_STATE_TO_EVO = {STATE_AUTO: "", STATE_ON: "On", STATE_OFF: "Off"} +EVO_STATE_TO_HA = {v: k for k, v in HA_STATE_TO_EVO.items() if k != ""} + +STATE_ATTRS_DHW = ["dhwId", "activeFaults", "stateStatus", "temperatureStatus"] + + +async def async_setup_platform( + hass: HomeAssistantType, config: ConfigType, async_add_entities, discovery_info=None +) -> None: + """Create a DHW controller.""" + if discovery_info is None: + return + + broker = hass.data[DOMAIN]["broker"] + + _LOGGER.debug( + "Adding: DhwController (%s), id=%s", + broker.tcs.hotwater.zone_type, + broker.tcs.hotwater.zoneId, + ) + new_entity = EvoDHW(broker, broker.tcs.hotwater) + + async_add_entities([new_entity], update_before_add=True) + + +class EvoDHW(EvoChild, WaterHeaterEntity): + """Base for a Honeywell TCC DHW controller (aka boiler).""" + + def __init__(self, evo_broker, evo_device) -> None: + """Initialize an evohome DHW controller.""" + super().__init__(evo_broker, evo_device) + + self._unique_id = evo_device.dhwId + self._name = "DHW controller" + self._icon = "mdi:thermometer-lines" + + self._precision = PRECISION_TENTHS if evo_broker.client_v1 else PRECISION_WHOLE + self._supported_features = SUPPORT_AWAY_MODE | SUPPORT_OPERATION_MODE + + @property + def state(self): + """Return the current state.""" + return EVO_STATE_TO_HA[self._evo_device.stateStatus["state"]] + + @property + def current_operation(self) -> str: + """Return the current operating mode (Auto, On, or Off).""" + if self._evo_device.stateStatus["mode"] == EVO_FOLLOW: + return STATE_AUTO + return EVO_STATE_TO_HA[self._evo_device.stateStatus["state"]] + + @property + def operation_list(self) -> List[str]: + """Return the list of available operations.""" + return list(HA_STATE_TO_EVO) + + @property + def is_away_mode_on(self): + """Return True if away mode is on.""" + is_off = EVO_STATE_TO_HA[self._evo_device.stateStatus["state"]] == STATE_OFF + is_permanent = self._evo_device.stateStatus["mode"] == EVO_PERMOVER + return is_off and is_permanent + + async def async_set_operation_mode(self, operation_mode: str) -> None: + """Set new operation mode for a DHW controller. + + Except for Auto, the mode is only until the next SetPoint. + """ + if operation_mode == STATE_AUTO: + await self._evo_broker.call_client_api(self._evo_device.set_dhw_auto()) + else: + await self._update_schedule() + until = dt_util.parse_datetime(self.setpoints.get("next_sp_from", "")) + until = dt_util.as_utc(until) if until else None + + if operation_mode == STATE_ON: + await self._evo_broker.call_client_api( + self._evo_device.set_dhw_on(until=until) + ) + else: # STATE_OFF + await self._evo_broker.call_client_api( + self._evo_device.set_dhw_off(until=until) + ) + + async def async_turn_away_mode_on(self): + """Turn away mode on.""" + await self._evo_broker.call_client_api(self._evo_device.set_dhw_off()) + + async def async_turn_away_mode_off(self): + """Turn away mode off.""" + await self._evo_broker.call_client_api(self._evo_device.set_dhw_auto()) + + async def async_update(self) -> None: + """Get the latest state data for a DHW controller.""" + await super().async_update() + + for attr in STATE_ATTRS_DHW: + self._device_state_attrs[attr] = getattr(self._evo_device, attr) diff --git a/homeassistant/components/ezviz/__init__.py b/homeassistant/components/ezviz/__init__.py new file mode 100644 index 0000000000000..96891e8b291e4 --- /dev/null +++ b/homeassistant/components/ezviz/__init__.py @@ -0,0 +1 @@ +"""Support for Ezviz devices via Ezviz Cloud API.""" diff --git a/homeassistant/components/ezviz/camera.py b/homeassistant/components/ezviz/camera.py new file mode 100644 index 0000000000000..e7e6725e45571 --- /dev/null +++ b/homeassistant/components/ezviz/camera.py @@ -0,0 +1,238 @@ +"""This component provides basic support for Ezviz IP cameras.""" +import asyncio +import logging + +from haffmpeg.tools import IMAGE_JPEG, ImageFrame +from pyezviz.camera import EzvizCamera +from pyezviz.client import EzvizClient, PyEzvizError +import voluptuous as vol + +from homeassistant.components.camera import PLATFORM_SCHEMA, SUPPORT_STREAM, Camera +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.helpers import config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +CONF_CAMERAS = "cameras" + +DEFAULT_CAMERA_USERNAME = "admin" +DEFAULT_RTSP_PORT = "554" + +DATA_FFMPEG = "ffmpeg" + +EZVIZ_DATA = "ezviz" +ENTITIES = "entities" + +CAMERA_SCHEMA = vol.Schema( + {vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string} +) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_CAMERAS, default={}): {cv.string: CAMERA_SCHEMA}, + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Ezviz IP Cameras.""" + + conf_cameras = config[CONF_CAMERAS] + + account = config[CONF_USERNAME] + password = config[CONF_PASSWORD] + + try: + ezviz_client = EzvizClient(account, password) + ezviz_client.login() + cameras = ezviz_client.load_cameras() + + except PyEzvizError as exp: + _LOGGER.error(exp) + return + + # now, let's build the HASS devices + camera_entities = [] + + # Add the cameras as devices in HASS + for camera in cameras: + + camera_username = DEFAULT_CAMERA_USERNAME + camera_password = "" + camera_rtsp_stream = "" + camera_serial = camera["serial"] + + # There seem to be a bug related to localRtspPort in Ezviz API... + local_rtsp_port = DEFAULT_RTSP_PORT + if camera["local_rtsp_port"] and camera["local_rtsp_port"] != 0: + local_rtsp_port = camera["local_rtsp_port"] + + if camera_serial in conf_cameras: + camera_username = conf_cameras[camera_serial][CONF_USERNAME] + camera_password = conf_cameras[camera_serial][CONF_PASSWORD] + camera_rtsp_stream = f"rtsp://{camera_username}:{camera_password}@{camera['local_ip']}:{local_rtsp_port}" + _LOGGER.debug( + "Camera %s source stream: %s", camera["serial"], camera_rtsp_stream + ) + + else: + _LOGGER.info( + "Found camera with serial %s without configuration. Add it to configuration.yaml to see the camera stream", + camera_serial, + ) + + camera["username"] = camera_username + camera["password"] = camera_password + camera["rtsp_stream"] = camera_rtsp_stream + + camera["ezviz_camera"] = EzvizCamera(ezviz_client, camera_serial) + + camera_entities.append(HassEzvizCamera(**camera)) + + add_entities(camera_entities) + + +class HassEzvizCamera(Camera): + """An implementation of a Foscam IP camera.""" + + def __init__(self, **data): + """Initialize an Ezviz camera.""" + super().__init__() + + self._username = data["username"] + self._password = data["password"] + self._rtsp_stream = data["rtsp_stream"] + + self._ezviz_camera = data["ezviz_camera"] + self._serial = data["serial"] + self._name = data["name"] + self._status = data["status"] + self._privacy = data["privacy"] + self._audio = data["audio"] + self._ir_led = data["ir_led"] + self._state_led = data["state_led"] + self._follow_move = data["follow_move"] + self._alarm_notify = data["alarm_notify"] + self._alarm_sound_mod = data["alarm_sound_mod"] + self._encrypted = data["encrypted"] + self._local_ip = data["local_ip"] + self._detection_sensibility = data["detection_sensibility"] + self._device_sub_category = data["device_sub_category"] + self._local_rtsp_port = data["local_rtsp_port"] + + self._ffmpeg = None + + def update(self): + """Update the camera states.""" + + data = self._ezviz_camera.status() + + self._name = data["name"] + self._status = data["status"] + self._privacy = data["privacy"] + self._audio = data["audio"] + self._ir_led = data["ir_led"] + self._state_led = data["state_led"] + self._follow_move = data["follow_move"] + self._alarm_notify = data["alarm_notify"] + self._alarm_sound_mod = data["alarm_sound_mod"] + self._encrypted = data["encrypted"] + self._local_ip = data["local_ip"] + self._detection_sensibility = data["detection_sensibility"] + self._device_sub_category = data["device_sub_category"] + self._local_rtsp_port = data["local_rtsp_port"] + + async def async_added_to_hass(self): + """Subscribe to ffmpeg and add camera to list.""" + self._ffmpeg = self.hass.data[DATA_FFMPEG] + + @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 device_state_attributes(self): + """Return the Ezviz-specific camera state attributes.""" + return { + # if privacy == true, the device closed the lid or did a 180° tilt + "privacy": self._privacy, + # is the camera listening ? + "audio": self._audio, + # infrared led on ? + "ir_led": self._ir_led, + # state led on ? + "state_led": self._state_led, + # if true, the camera will move automatically to follow movements + "follow_move": self._follow_move, + # if true, if some movement is detected, the app is notified + "alarm_notify": self._alarm_notify, + # if true, if some movement is detected, the camera makes some sound + "alarm_sound_mod": self._alarm_sound_mod, + # are the camera's stored videos/images encrypted? + "encrypted": self._encrypted, + # camera's local ip on local network + "local_ip": self._local_ip, + # from 1 to 9, the higher is the sensibility, the more it will detect small movements + "detection_sensibility": self._detection_sensibility, + } + + @property + def available(self): + """Return True if entity is available.""" + return self._status + + @property + def brand(self): + """Return the camera brand.""" + return "Ezviz" + + @property + def supported_features(self): + """Return supported features.""" + if self._rtsp_stream: + return SUPPORT_STREAM + return 0 + + @property + def model(self): + """Return the camera model.""" + return self._device_sub_category + + @property + def is_on(self): + """Return true if on.""" + return self._status + + @property + def name(self): + """Return the name of this camera.""" + return self._name + + async def async_camera_image(self): + """Return a frame from the camera stream.""" + ffmpeg = ImageFrame(self._ffmpeg.binary, loop=self.hass.loop) + + image = await asyncio.shield( + ffmpeg.get_image(self._rtsp_stream, output_format=IMAGE_JPEG) + ) + return image + + async def stream_source(self): + """Return the stream source.""" + if self._local_rtsp_port: + rtsp_stream_source = ( + f"rtsp://{self._username}:{self._password}@" + f"{self._local_ip}:{self._local_rtsp_port}" + ) + _LOGGER.debug( + "Camera %s source stream: %s", self._serial, rtsp_stream_source + ) + self._rtsp_stream = rtsp_stream_source + return rtsp_stream_source + return None diff --git a/homeassistant/components/ezviz/manifest.json b/homeassistant/components/ezviz/manifest.json new file mode 100644 index 0000000000000..651fd77619cdc --- /dev/null +++ b/homeassistant/components/ezviz/manifest.json @@ -0,0 +1,7 @@ +{ + "domain": "ezviz", + "name": "Ezviz", + "documentation": "https://www.home-assistant.io/integrations/ezviz", + "codeowners": ["@baqs"], + "requirements": ["pyezviz==0.1.5"] +} diff --git a/homeassistant/components/facebook/manifest.json b/homeassistant/components/facebook/manifest.json index 9632906a25a74..5d44ccc40ce72 100644 --- a/homeassistant/components/facebook/manifest.json +++ b/homeassistant/components/facebook/manifest.json @@ -1,8 +1,6 @@ { "domain": "facebook", - "name": "Facebook", - "documentation": "https://www.home-assistant.io/components/facebook", - "requirements": [], - "dependencies": [], + "name": "Facebook Messenger", + "documentation": "https://www.home-assistant.io/integrations/facebook", "codeowners": [] } diff --git a/homeassistant/components/facebook/notify.py b/homeassistant/components/facebook/notify.py index 625b922927c55..0ce2fbfc665ed 100644 --- a/homeassistant/components/facebook/notify.py +++ b/homeassistant/components/facebook/notify.py @@ -6,23 +6,23 @@ import requests import voluptuous as vol -from homeassistant.const import CONTENT_TYPE_JSON +from homeassistant.components.notify import ( + ATTR_DATA, + ATTR_TARGET, + PLATFORM_SCHEMA, + BaseNotificationService, +) +from homeassistant.const import CONTENT_TYPE_JSON, HTTP_OK 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' +CONF_PAGE_ACCESS_TOKEN = "page_access_token" +BASE_URL = "https://graph.facebook.com/v2.6/me/messages" -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_PAGE_ACCESS_TOKEN): cv.string, -}) +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + {vol.Required(CONF_PAGE_ACCESS_TOKEN): cv.string} +) def get_service(hass, config, discovery_info=None): @@ -39,7 +39,7 @@ def __init__(self, access_token): def send_message(self, message="", **kwargs): """Send some message.""" - payload = {'access_token': self.page_access_token} + payload = {"access_token": self.page_access_token} targets = kwargs.get(ATTR_TARGET) data = kwargs.get(ATTR_DATA) @@ -48,67 +48,44 @@ def send_message(self, message="", **kwargs): 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 "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", + 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, + "messaging_type": "MESSAGE_TAG", + "tag": "ACCOUNT_UPDATE", } - - 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: + resp = requests.post( + BASE_URL, + data=json.dumps(body), + params=payload, + headers={CONTENT_TYPE: CONTENT_TYPE_JSON}, + timeout=10, + ) + if resp.status_code != HTTP_OK: 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'] + error_message = obj["error"]["message"] + error_code = obj["error"]["code"] _LOGGER.error( - "Error %s : %s (Code %s)", response.status_code, error_message, - error_code) + "Error %s : %s (Code %s)", response.status_code, error_message, error_code + ) diff --git a/homeassistant/components/facebox/const.py b/homeassistant/components/facebox/const.py new file mode 100644 index 0000000000000..991ec925a9817 --- /dev/null +++ b/homeassistant/components/facebox/const.py @@ -0,0 +1,4 @@ +"""Constants for the Facebox component.""" + +DOMAIN = "facebox" +SERVICE_TEACH_FACE = "teach_face" diff --git a/homeassistant/components/facebox/image_processing.py b/homeassistant/components/facebox/image_processing.py index 2b4f184c3fd49..ee6e4d8a6fa50 100644 --- a/homeassistant/components/facebox/image_processing.py +++ b/homeassistant/components/facebox/image_processing.py @@ -5,60 +5,73 @@ import requests import voluptuous as vol +from homeassistant.components.image_processing import ( + ATTR_CONFIDENCE, + CONF_ENTITY_ID, + CONF_NAME, + CONF_SOURCE, + PLATFORM_SCHEMA, + ImageProcessingFaceEntity, +) from homeassistant.const import ( - ATTR_ENTITY_ID, ATTR_NAME) + ATTR_ENTITY_ID, + ATTR_NAME, + CONF_IP_ADDRESS, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + HTTP_BAD_REQUEST, + HTTP_OK, + HTTP_UNAUTHORIZED, +) 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' +from .const import DOMAIN, SERVICE_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, -}) +_LOGGER = logging.getLogger(__name__) -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, -}) +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" + + +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) + kwargs["auth"] = requests.auth.HTTPBasicAuth(username, password) try: - response = requests.get( - url, - **kwargs - ) + 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'] + return response.json()["hostname"] except requests.exceptions.ConnectionError: _LOGGER.error("ConnectionError: Is %s running?", CLASSIFIER) return None @@ -66,14 +79,15 @@ def check_box_health(url, username, password): def encode_image(image): """base64 encode an image stream.""" - base64_img = base64.b64encode(image).decode('ascii') + 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']} + return { + face["name"]: round(face["confidence"], 2) for face in faces if face["matched"] + } def parse_faces(api_faces): @@ -81,15 +95,15 @@ def parse_faces(api_faces): 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'] + 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'] + 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 @@ -98,13 +112,9 @@ def post_image(url, image, username, password): """Post an image to the classifier.""" kwargs = {} if username: - kwargs['auth'] = requests.auth.HTTPBasicAuth(username, password) + kwargs["auth"] = requests.auth.HTTPBasicAuth(username, password) try: - response = requests.post( - url, - json={"base64": encode_image(image)}, - **kwargs - ) + response = requests.post(url, json={"base64": encode_image(image)}, **kwargs) if response.status_code == HTTP_UNAUTHORIZED: _LOGGER.error("AuthenticationError on %s", CLASSIFIER) return None @@ -118,20 +128,24 @@ 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) + kwargs["auth"] = requests.auth.HTTPBasicAuth(username, password) try: - with open(file_path, 'rb') as open_file: + 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 - ) + 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) + _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) @@ -142,8 +156,7 @@ def valid_file_path(file_path): cv.isfile(file_path) return True except vol.Invalid: - _LOGGER.error( - "%s error: Invalid file path: %s", CLASSIFIER, file_path) + _LOGGER.error("%s error: Invalid file path: %s", CLASSIFIER, file_path) return False @@ -156,7 +169,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): port = config[CONF_PORT] username = config.get(CONF_USERNAME) password = config.get(CONF_PASSWORD) - url_health = "http://{}:{}/healthz".format(ip_address, port) + url_health = f"http://{ip_address}:{port}/healthz" hostname = check_box_health(url_health, username, password) if hostname is None: return @@ -164,15 +177,21 @@ def setup_platform(hass, config, add_entities, discovery_info=None): entities = [] for camera in config[CONF_SOURCE]: facebox = FaceClassifyEntity( - ip_address, port, username, password, hostname, - camera[CONF_ENTITY_ID], camera.get(CONF_NAME)) + 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') + entity_ids = service.data.get("entity_id") classifiers = hass.data[DATA_FACEBOX] if entity_ids: @@ -184,21 +203,20 @@ def service_handle(service): classifier.teach(name, file_path) hass.services.register( - DOMAIN, SERVICE_TEACH_FACE, service_handle, - schema=SERVICE_TEACH_SCHEMA) + 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): + 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._url_check = f"http://{ip_address}:{port}/{CLASSIFIER}/check" + self._url_teach = f"http://{ip_address}:{port}/{CLASSIFIER}/teach" self._username = username self._password = password self._hostname = hostname @@ -207,18 +225,17 @@ def __init__(self, ip_address, port, username, password, hostname, self._name = name else: camera_name = split_entity_id(camera_entity)[1] - self._name = "{} {}".format(CLASSIFIER, camera_name) + self._name = f"{CLASSIFIER} {camera_name}" self._matched = {} def process_image(self, image): """Process an image.""" - response = post_image( - self._url_check, image, self._username, self._password) + 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']) + 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) @@ -229,11 +246,11 @@ def process_image(self, image): 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)): + 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) + teach_file(self._url_teach, name, file_path, self._username, self._password) @property def camera_entity(self): @@ -249,7 +266,7 @@ def name(self): def device_state_attributes(self): """Return the classifier attributes.""" return { - 'matched_faces': self._matched, - 'total_matched_faces': len(self._matched), - 'hostname': self._hostname - } + "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 index 4a3eefc135c56..d8a8fb457ea89 100644 --- a/homeassistant/components/facebox/manifest.json +++ b/homeassistant/components/facebox/manifest.json @@ -1,8 +1,6 @@ { "domain": "facebox", "name": "Facebox", - "documentation": "https://www.home-assistant.io/components/facebox", - "requirements": [], - "dependencies": [], + "documentation": "https://www.home-assistant.io/integrations/facebox", "codeowners": [] } diff --git a/homeassistant/components/facebox/services.yaml b/homeassistant/components/facebox/services.yaml index e69de29bb2d1d..caa2e7df2c669 100644 --- a/homeassistant/components/facebox/services.yaml +++ b/homeassistant/components/facebox/services.yaml @@ -0,0 +1,12 @@ +teach_face: + description: Teach facebox a face using a file. + fields: + entity_id: + description: The facebox entity to teach. + example: "image_processing.facebox" + name: + description: The name of the face to teach. + example: "my_name" + file_path: + description: The path to the image file. + example: "/images/my_image.jpg" diff --git a/homeassistant/components/fail2ban/manifest.json b/homeassistant/components/fail2ban/manifest.json index fc60658a3f2c6..4d8e50d507b1c 100644 --- a/homeassistant/components/fail2ban/manifest.json +++ b/homeassistant/components/fail2ban/manifest.json @@ -1,8 +1,6 @@ { "domain": "fail2ban", - "name": "Fail2ban", - "documentation": "https://www.home-assistant.io/components/fail2ban", - "requirements": [], - "dependencies": [], + "name": "Fail2Ban", + "documentation": "https://www.home-assistant.io/integrations/fail2ban", "codeowners": [] } diff --git a/homeassistant/components/fail2ban/sensor.py b/homeassistant/components/fail2ban/sensor.py index 78b11b1942b35..2f206dca737ad 100644 --- a/homeassistant/components/fail2ban/sensor.py +++ b/homeassistant/components/fail2ban/sensor.py @@ -1,45 +1,37 @@ -""" -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 - +"""Support for displaying IPs banned by fail2ban.""" from datetime import timedelta - +import logging +import os 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.const import CONF_FILE_PATH, CONF_NAME +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) -CONF_JAILS = 'jails' +CONF_JAILS = "jails" -DEFAULT_NAME = 'fail2ban' -DEFAULT_LOG = '/var/log/fail2ban.log' +DEFAULT_NAME = "fail2ban" +DEFAULT_LOG = "/var/log/fail2ban.log" -STATE_CURRENT_BANS = 'current_bans' -STATE_ALL_BANS = 'total_bans' +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, -}) +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): +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) @@ -58,7 +50,7 @@ class BanSensor(Entity): def __init__(self, name, jail, log_parser): """Initialize the sensor.""" - self._name = '{} {}'.format(name, jail) + self._name = f"{name} {jail}" self.jail = jail self.ban_dict = {STATE_CURRENT_BANS: [], STATE_ALL_BANS: []} self.last_ban = None @@ -91,7 +83,7 @@ def update(self): for entry in self.log_parser.data: _LOGGER.debug(entry) current_ip = entry[1] - if entry[0] == 'Ban': + 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]: @@ -99,14 +91,14 @@ def update(self): if len(self.ban_dict[STATE_ALL_BANS]) > 10: self.ban_dict[STATE_ALL_BANS].pop(0) - elif entry[0] == 'Unban': + 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' + self.last_ban = "None" class BanLogParser: @@ -115,17 +107,15 @@ class BanLogParser: def __init__(self, log_file): """Initialize the parser.""" self.log_file = log_file - self.data = list() - self.ip_regex = dict() + self.data = [] + self.ip_regex = {} def read_log(self, jail): """Read the fail2ban log and find entries for jail.""" - self.data = list() + self.data = [] try: - with open(self.log_file, 'r', encoding='utf-8') as file_data: + with open(self.log_file, 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)) + except (IndexError, FileNotFoundError, IsADirectoryError, UnboundLocalError): + _LOGGER.warning("File not present: %s", os.path.basename(self.log_file)) diff --git a/homeassistant/components/familyhub/camera.py b/homeassistant/components/familyhub/camera.py index e9a8bcd94a6c7..2e4e7085927fa 100644 --- a/homeassistant/components/familyhub/camera.py +++ b/homeassistant/components/familyhub/camera.py @@ -1,27 +1,29 @@ """Family Hub camera for Samsung Refrigerators.""" import logging +from pyfamilyhublocal import FamilyHubCam import voluptuous as vol -from homeassistant.components.camera import Camera, PLATFORM_SCHEMA +from homeassistant.components.camera import PLATFORM_SCHEMA, Camera 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' +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, -}) +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): +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) diff --git a/homeassistant/components/familyhub/manifest.json b/homeassistant/components/familyhub/manifest.json index 48a73dfb0300b..06acb922eeedc 100644 --- a/homeassistant/components/familyhub/manifest.json +++ b/homeassistant/components/familyhub/manifest.json @@ -1,10 +1,7 @@ { "domain": "familyhub", - "name": "Familyhub", - "documentation": "https://www.home-assistant.io/components/familyhub", - "requirements": [ - "python-family-hub-local==0.0.2" - ], - "dependencies": [], + "name": "Samsung Family Hub", + "documentation": "https://www.home-assistant.io/integrations/familyhub", + "requirements": ["python-family-hub-local==0.0.2"], "codeowners": [] } diff --git a/homeassistant/components/fan/__init__.py b/homeassistant/components/fan/__init__.py index 23015769f2886..a395a5da47004 100644 --- a/homeassistant/components/fan/__init__.py +++ b/homeassistant/components/fan/__init__.py @@ -2,91 +2,59 @@ from datetime import timedelta import functools as ft import logging +from typing import Optional import voluptuous as vol -from homeassistant.components import group -from homeassistant.const import (SERVICE_TURN_ON, SERVICE_TOGGLE, - SERVICE_TURN_OFF, ATTR_ENTITY_ID) -from homeassistant.loader import bind_hass +from homeassistant.const import SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.config_validation import ( # noqa: F401 + PLATFORM_SCHEMA, + PLATFORM_SCHEMA_BASE, +) from homeassistant.helpers.entity import ToggleEntity from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.config_validation import ( # noqa - PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE) -import homeassistant.helpers.config_validation as cv +from homeassistant.loader import bind_hass _LOGGER = logging.getLogger(__name__) -DOMAIN = 'fan' +DOMAIN = "fan" SCAN_INTERVAL = timedelta(seconds=30) -GROUP_NAME_ALL_FANS = 'all fans' -ENTITY_ID_ALL_FANS = group.ENTITY_ID_FORMAT.format(GROUP_NAME_ALL_FANS) - -ENTITY_ID_FORMAT = DOMAIN + '.{}' +ENTITY_ID_FORMAT = DOMAIN + ".{}" # Bitfield of features supported by the fan entity SUPPORT_SET_SPEED = 1 SUPPORT_OSCILLATE = 2 SUPPORT_DIRECTION = 4 -SERVICE_SET_SPEED = 'set_speed' -SERVICE_OSCILLATE = 'oscillate' -SERVICE_SET_DIRECTION = 'set_direction' +SERVICE_SET_SPEED = "set_speed" +SERVICE_OSCILLATE = "oscillate" +SERVICE_SET_DIRECTION = "set_direction" -SPEED_OFF = 'off' -SPEED_LOW = 'low' -SPEED_MEDIUM = 'medium' -SPEED_HIGH = 'high' +SPEED_OFF = "off" +SPEED_LOW = "low" +SPEED_MEDIUM = "medium" +SPEED_HIGH = "high" -DIRECTION_FORWARD = 'forward' -DIRECTION_REVERSE = 'reverse' +DIRECTION_FORWARD = "forward" +DIRECTION_REVERSE = "reverse" -ATTR_SPEED = 'speed' -ATTR_SPEED_LIST = 'speed_list' -ATTR_OSCILLATING = 'oscillating' -ATTR_DIRECTION = 'direction' +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, - 'direction': ATTR_DIRECTION, -} # type: dict - -FAN_SET_SPEED_SCHEMA = vol.Schema({ - 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.comp_entity_ids, - vol.Optional(ATTR_SPEED): cv.string -}) # type: dict - -FAN_TURN_OFF_SCHEMA = vol.Schema({ - vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids -}) # type: dict - -FAN_OSCILLATE_SCHEMA = vol.Schema({ - 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.comp_entity_ids -}) - -FAN_SET_DIRECTION_SCHEMA = vol.Schema({ - vol.Required(ATTR_ENTITY_ID): cv.comp_entity_ids, - vol.Optional(ATTR_DIRECTION): cv.string -}) # type: dict + "speed": ATTR_SPEED, + "oscillating": ATTR_OSCILLATING, + "current_direction": ATTR_DIRECTION, +} @bind_hass -def is_on(hass, entity_id: str = None) -> bool: +def is_on(hass, entity_id: str) -> 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, None] @@ -94,33 +62,33 @@ def is_on(hass, entity_id: str = None) -> bool: 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) + _LOGGER, DOMAIN, hass, SCAN_INTERVAL + ) 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' + SERVICE_TURN_ON, {vol.Optional(ATTR_SPEED): cv.string}, "async_turn_on" ) + component.async_register_entity_service(SERVICE_TURN_OFF, {}, "async_turn_off") + component.async_register_entity_service(SERVICE_TOGGLE, {}, "async_toggle") component.async_register_entity_service( - SERVICE_SET_SPEED, FAN_SET_SPEED_SCHEMA, - 'async_set_speed' + SERVICE_SET_SPEED, + {vol.Required(ATTR_SPEED): cv.string}, + "async_set_speed", + [SUPPORT_SET_SPEED], ) component.async_register_entity_service( - SERVICE_OSCILLATE, FAN_OSCILLATE_SCHEMA, - 'async_oscillate' + SERVICE_OSCILLATE, + {vol.Required(ATTR_OSCILLATING): cv.boolean}, + "async_oscillate", + [SUPPORT_OSCILLATE], ) component.async_register_entity_service( - SERVICE_SET_DIRECTION, FAN_SET_DIRECTION_SCHEMA, - 'async_set_direction' + SERVICE_SET_DIRECTION, + {vol.Optional(ATTR_DIRECTION): cv.string}, + "async_set_direction", + [SUPPORT_DIRECTION], ) return True @@ -143,52 +111,40 @@ def set_speed(self, speed: str) -> None: """Set the speed of the fan.""" raise NotImplementedError() - 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. - """ + async def async_set_speed(self, speed: str): + """Set the speed of the fan.""" if speed is SPEED_OFF: - return self.async_turn_off() - return self.hass.async_add_job(self.set_speed, speed) + await self.async_turn_off() + else: + await self.hass.async_add_job(self.set_speed, speed) def set_direction(self, direction: str) -> None: """Set the direction of the fan.""" raise NotImplementedError() - def async_set_direction(self, direction: str): - """Set the direction of the fan. - - This method must be run in the event loop and returns a coroutine. - """ - return self.hass.async_add_job(self.set_direction, direction) + async def async_set_direction(self, direction: str): + """Set the direction of the fan.""" + await self.hass.async_add_job(self.set_direction, direction) # pylint: disable=arguments-differ - def turn_on(self, speed: str = None, **kwargs) -> None: + def turn_on(self, speed: Optional[str] = None, **kwargs) -> None: """Turn on 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. - """ + async def async_turn_on(self, speed: Optional[str] = None, **kwargs): + """Turn on the fan.""" if speed is SPEED_OFF: - return self.async_turn_off() - return self.hass.async_add_job( - ft.partial(self.turn_on, speed, **kwargs)) + await self.async_turn_off() + else: + await self.hass.async_add_job(ft.partial(self.turn_on, speed, **kwargs)) 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) + async def async_oscillate(self, oscillating: bool): + """Oscillate the fan.""" + await self.hass.async_add_job(self.oscillate, oscillating) @property def is_on(self): @@ -196,7 +152,7 @@ def is_on(self): return self.speed not in [SPEED_OFF, None] @property - def speed(self) -> str: + def speed(self) -> Optional[str]: """Return the current speed.""" return None @@ -206,14 +162,19 @@ def speed_list(self) -> list: return [] @property - def current_direction(self) -> str: + def current_direction(self) -> Optional[str]: """Return the current direction of the fan.""" return None + @property + def capability_attributes(self): + """Return capability attributes.""" + return {ATTR_SPEED_LIST: self.speed_list} + @property def state_attributes(self) -> dict: """Return optional state attributes.""" - data = {} # type: dict + data = {} for prop, attr in PROP_TO_ATTR.items(): if not hasattr(self, prop): diff --git a/homeassistant/components/fan/device_action.py b/homeassistant/components/fan/device_action.py new file mode 100644 index 0000000000000..a5d35d741b640 --- /dev/null +++ b/homeassistant/components/fan/device_action.py @@ -0,0 +1,76 @@ +"""Provides device automations for Fan.""" +from typing import List, Optional + +import voluptuous as vol + +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_DEVICE_ID, + CONF_DOMAIN, + CONF_ENTITY_ID, + CONF_TYPE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import entity_registry +import homeassistant.helpers.config_validation as cv + +from . import DOMAIN + +ACTION_TYPES = {"turn_on", "turn_off"} + +ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( + { + vol.Required(CONF_TYPE): vol.In(ACTION_TYPES), + vol.Required(CONF_ENTITY_ID): cv.entity_domain(DOMAIN), + } +) + + +async def async_get_actions(hass: HomeAssistant, device_id: str) -> List[dict]: + """List device actions for Fan devices.""" + registry = await entity_registry.async_get_registry(hass) + actions = [] + + # Get all the integrations entities for this device + for entry in entity_registry.async_entries_for_device(registry, device_id): + if entry.domain != DOMAIN: + continue + + actions.append( + { + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "turn_on", + } + ) + actions.append( + { + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "turn_off", + } + ) + + return actions + + +async def async_call_action_from_config( + hass: HomeAssistant, config: dict, variables: dict, context: Optional[Context] +) -> None: + """Execute a device action.""" + config = ACTION_SCHEMA(config) + + service_data = {ATTR_ENTITY_ID: config[CONF_ENTITY_ID]} + + if config[CONF_TYPE] == "turn_on": + service = SERVICE_TURN_ON + elif config[CONF_TYPE] == "turn_off": + service = SERVICE_TURN_OFF + + await hass.services.async_call( + DOMAIN, service, service_data, blocking=True, context=context + ) diff --git a/homeassistant/components/fan/device_condition.py b/homeassistant/components/fan/device_condition.py new file mode 100644 index 0000000000000..d3a8aa5c39548 --- /dev/null +++ b/homeassistant/components/fan/device_condition.py @@ -0,0 +1,84 @@ +"""Provide the device automations for Fan.""" +from typing import Dict, List + +import voluptuous as vol + +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_CONDITION, + CONF_DEVICE_ID, + CONF_DOMAIN, + CONF_ENTITY_ID, + CONF_TYPE, + STATE_OFF, + STATE_ON, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import condition, config_validation as cv, entity_registry +from homeassistant.helpers.config_validation import DEVICE_CONDITION_BASE_SCHEMA +from homeassistant.helpers.typing import ConfigType, TemplateVarsType + +from . import DOMAIN + +CONDITION_TYPES = {"is_on", "is_off"} + +CONDITION_SCHEMA = DEVICE_CONDITION_BASE_SCHEMA.extend( + { + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_TYPE): vol.In(CONDITION_TYPES), + } +) + + +async def async_get_conditions( + hass: HomeAssistant, device_id: str +) -> List[Dict[str, str]]: + """List device conditions for Fan devices.""" + registry = await entity_registry.async_get_registry(hass) + conditions = [] + + # Get all the integrations entities for this device + for entry in entity_registry.async_entries_for_device(registry, device_id): + if entry.domain != DOMAIN: + continue + + conditions.append( + { + CONF_CONDITION: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "is_on", + } + ) + conditions.append( + { + CONF_CONDITION: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "is_off", + } + ) + + return conditions + + +@callback +def async_condition_from_config( + config: ConfigType, config_validation: bool +) -> condition.ConditionCheckerType: + """Create a function to test a device condition.""" + if config_validation: + config = CONDITION_SCHEMA(config) + if config[CONF_TYPE] == "is_on": + state = STATE_ON + else: + state = STATE_OFF + + @callback + def test_is_state(hass: HomeAssistant, variables: TemplateVarsType) -> bool: + """Test if an entity is a certain state.""" + return condition.state(hass, config[ATTR_ENTITY_ID], state) + + return test_is_state diff --git a/homeassistant/components/fan/device_trigger.py b/homeassistant/components/fan/device_trigger.py new file mode 100644 index 0000000000000..3bfeb5ee36b8b --- /dev/null +++ b/homeassistant/components/fan/device_trigger.py @@ -0,0 +1,91 @@ +"""Provides device automations for Fan.""" +from typing import List + +import voluptuous as vol + +from homeassistant.components.automation import AutomationActionType, state +from homeassistant.components.device_automation import TRIGGER_BASE_SCHEMA +from homeassistant.const import ( + CONF_DEVICE_ID, + CONF_DOMAIN, + CONF_ENTITY_ID, + CONF_PLATFORM, + CONF_TYPE, + STATE_OFF, + STATE_ON, +) +from homeassistant.core import CALLBACK_TYPE, HomeAssistant +from homeassistant.helpers import config_validation as cv, entity_registry +from homeassistant.helpers.typing import ConfigType + +from . import DOMAIN + +TRIGGER_TYPES = {"turned_on", "turned_off"} + +TRIGGER_SCHEMA = TRIGGER_BASE_SCHEMA.extend( + { + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_TYPE): vol.In(TRIGGER_TYPES), + } +) + + +async def async_get_triggers(hass: HomeAssistant, device_id: str) -> List[dict]: + """List device triggers for Fan devices.""" + registry = await entity_registry.async_get_registry(hass) + triggers = [] + + # Get all the integrations entities for this device + for entry in entity_registry.async_entries_for_device(registry, device_id): + if entry.domain != DOMAIN: + continue + + # Add triggers for each entity that belongs to this integration + triggers.append( + { + CONF_PLATFORM: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "turned_on", + } + ) + triggers.append( + { + CONF_PLATFORM: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "turned_off", + } + ) + + return triggers + + +async def async_attach_trigger( + hass: HomeAssistant, + config: ConfigType, + action: AutomationActionType, + automation_info: dict, +) -> CALLBACK_TYPE: + """Attach a trigger.""" + config = TRIGGER_SCHEMA(config) + + if config[CONF_TYPE] == "turned_on": + from_state = STATE_OFF + to_state = STATE_ON + else: + from_state = STATE_ON + to_state = STATE_OFF + + state_config = { + state.CONF_PLATFORM: "state", + CONF_ENTITY_ID: config[CONF_ENTITY_ID], + state.CONF_FROM: from_state, + state.CONF_TO: to_state, + } + state_config = state.TRIGGER_SCHEMA(state_config) + return await state.async_attach_trigger( + hass, state_config, action, automation_info, platform_type="device" + ) diff --git a/homeassistant/components/fan/manifest.json b/homeassistant/components/fan/manifest.json index 85bb982d2d1f4..76573e08cbb06 100644 --- a/homeassistant/components/fan/manifest.json +++ b/homeassistant/components/fan/manifest.json @@ -1,10 +1,7 @@ { "domain": "fan", "name": "Fan", - "documentation": "https://www.home-assistant.io/components/fan", - "requirements": [], - "dependencies": [ - "group" - ], - "codeowners": [] + "documentation": "https://www.home-assistant.io/integrations/fan", + "codeowners": [], + "quality_scale": "internal" } diff --git a/homeassistant/components/fan/reproduce_state.py b/homeassistant/components/fan/reproduce_state.py new file mode 100644 index 0000000000000..55ae78d90f692 --- /dev/null +++ b/homeassistant/components/fan/reproduce_state.py @@ -0,0 +1,113 @@ +"""Reproduce an Fan state.""" +import asyncio +import logging +from types import MappingProxyType +from typing import Any, Dict, Iterable, Optional + +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, +) +from homeassistant.core import Context, State +from homeassistant.helpers.typing import HomeAssistantType + +from . import ( + ATTR_DIRECTION, + ATTR_OSCILLATING, + ATTR_SPEED, + DOMAIN, + SERVICE_OSCILLATE, + SERVICE_SET_DIRECTION, + SERVICE_SET_SPEED, +) + +_LOGGER = logging.getLogger(__name__) + +VALID_STATES = {STATE_ON, STATE_OFF} +ATTRIBUTES = { # attribute: service + ATTR_DIRECTION: SERVICE_SET_DIRECTION, + ATTR_OSCILLATING: SERVICE_OSCILLATE, + ATTR_SPEED: SERVICE_SET_SPEED, +} + + +async def _async_reproduce_state( + hass: HomeAssistantType, + state: State, + *, + context: Optional[Context] = None, + reproduce_options: Optional[Dict[str, Any]] = None, +) -> None: + """Reproduce a single state.""" + cur_state = hass.states.get(state.entity_id) + + if cur_state is None: + _LOGGER.warning("Unable to find entity %s", state.entity_id) + return + + if state.state not in VALID_STATES: + _LOGGER.warning( + "Invalid state specified for %s: %s", state.entity_id, state.state + ) + return + + # Return if we are already at the right state. + if cur_state.state == state.state and all( + check_attr_equal(cur_state.attributes, state.attributes, attr) + for attr in ATTRIBUTES + ): + return + + service_data = {ATTR_ENTITY_ID: state.entity_id} + service_calls = {} # service: service_data + + if state.state == STATE_ON: + # The fan should be on + if cur_state.state != STATE_ON: + # Turn on the fan at first + service_calls[SERVICE_TURN_ON] = service_data + + for attr, service in ATTRIBUTES.items(): + # Call services to adjust the attributes + if attr in state.attributes and not check_attr_equal( + state.attributes, cur_state.attributes, attr + ): + data = service_data.copy() + data[attr] = state.attributes[attr] + service_calls[service] = data + + elif state.state == STATE_OFF: + service_calls[SERVICE_TURN_OFF] = service_data + + for service, data in service_calls.items(): + await hass.services.async_call( + DOMAIN, service, data, context=context, blocking=True + ) + + +async def async_reproduce_states( + hass: HomeAssistantType, + states: Iterable[State], + *, + context: Optional[Context] = None, + reproduce_options: Optional[Dict[str, Any]] = None, +) -> None: + """Reproduce Fan states.""" + await asyncio.gather( + *( + _async_reproduce_state( + hass, state, context=context, reproduce_options=reproduce_options + ) + for state in states + ) + ) + + +def check_attr_equal( + attr1: MappingProxyType, attr2: MappingProxyType, attr_str: str +) -> bool: + """Return true if the given attributes are equal.""" + return attr1.get(attr_str) == attr2.get(attr_str) diff --git a/homeassistant/components/fan/services.yaml b/homeassistant/components/fan/services.yaml index 16d3742d9ab99..1fb88a36d2c1b 100644 --- a/homeassistant/components/fan/services.yaml +++ b/homeassistant/components/fan/services.yaml @@ -1,213 +1,54 @@ # Describes the format for available fan services - set_speed: description: Sets fan speed. fields: entity_id: description: Name(s) of the entities to set - example: 'fan.living_room' + example: "fan.living_room" speed: description: Speed setting - example: 'low' + example: "low" turn_on: description: Turns fan on. fields: entity_id: description: Names(s) of the entities to turn on - example: 'fan.living_room' + example: "fan.living_room" speed: description: Speed setting - example: 'high' + example: "high" turn_off: description: Turns fan off. fields: entity_id: description: Names(s) of the entities to turn off - example: 'fan.living_room' + example: "fan.living_room" oscillate: description: Oscillates the fan. fields: entity_id: description: Name(s) of the entities to oscillate - example: 'fan.desk_fan' + example: "fan.desk_fan" oscillating: description: Flag to turn on/off oscillation - example: True + example: true toggle: description: Toggle the fan on/off. fields: entity_id: description: Name(s) of the entities to toggle - exampl: 'fan.living_room' + example: "fan.living_room" set_direction: description: Set the fan rotation. fields: entity_id: - description: Name(s) of the entities to toggle - example: 'fan.living_room' + description: Name(s) of the entities to set + 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' + example: "forward" diff --git a/homeassistant/components/fan/strings.json b/homeassistant/components/fan/strings.json new file mode 100644 index 0000000000000..7ec4eebea7e4e --- /dev/null +++ b/homeassistant/components/fan/strings.json @@ -0,0 +1,23 @@ +{ + "title": "Fan", + "device_automation": { + "condition_type": { + "is_on": "{entity_name} is on", + "is_off": "{entity_name} is off" + }, + "trigger_type": { + "turned_on": "{entity_name} turned on", + "turned_off": "{entity_name} turned off" + }, + "action_type": { + "turn_on": "Turn on {entity_name}", + "turn_off": "Turn off {entity_name}" + } + }, + "state": { + "_": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" + } + } +} diff --git a/homeassistant/components/fan/translations/af.json b/homeassistant/components/fan/translations/af.json new file mode 100644 index 0000000000000..cb955a8bec9c7 --- /dev/null +++ b/homeassistant/components/fan/translations/af.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "Af", + "on": "Aan" + } + }, + "title": "Waaier" +} \ No newline at end of file diff --git a/homeassistant/components/fan/translations/ar.json b/homeassistant/components/fan/translations/ar.json new file mode 100644 index 0000000000000..d20f0b68e89e9 --- /dev/null +++ b/homeassistant/components/fan/translations/ar.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "\u0625\u064a\u0642\u0627\u0641", + "on": "\u0642\u064a\u062f \u0627\u0644\u062a\u0634\u063a\u064a\u0644" + } + }, + "title": "\u0645\u0631\u0648\u062d\u0629" +} \ No newline at end of file diff --git a/homeassistant/components/fan/translations/bg.json b/homeassistant/components/fan/translations/bg.json new file mode 100644 index 0000000000000..34eaae0e84e92 --- /dev/null +++ b/homeassistant/components/fan/translations/bg.json @@ -0,0 +1,23 @@ +{ + "device_automation": { + "action_type": { + "turn_off": "\u0418\u0437\u043a\u043b\u044e\u0447\u0438 {entity_name}", + "turn_on": "\u0412\u043a\u043b\u044e\u0447\u0438 {entity_name}" + }, + "condition_type": { + "is_off": "{entity_name} \u0435 \u0438\u0437\u043a\u043b\u044e\u0447\u0435\u043d", + "is_on": "{entity_name} \u0435 \u0432\u043a\u043b\u044e\u0447\u0435\u043d" + }, + "trigger_type": { + "turned_off": "{entity_name} \u0431\u044a\u0434\u0435 \u0438\u0437\u043a\u043b\u044e\u0447\u0435\u043d", + "turned_on": "{entity_name} \u0431\u044a\u0434\u0435 \u0432\u043a\u043b\u044e\u0447\u0435\u043d" + } + }, + "state": { + "_": { + "off": "\u0418\u0437\u043a\u043b\u044e\u0447\u0435\u043d", + "on": "\u0412\u043a\u043b\u044e\u0447\u0435\u043d" + } + }, + "title": "\u0412\u0435\u043d\u0442\u0438\u043b\u0430\u0442\u043e\u0440" +} \ No newline at end of file diff --git a/homeassistant/components/fan/translations/bs.json b/homeassistant/components/fan/translations/bs.json new file mode 100644 index 0000000000000..e0cbfcbec89e1 --- /dev/null +++ b/homeassistant/components/fan/translations/bs.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "Isklju\u010den", + "on": "Uklju\u010den" + } + }, + "title": "Ventilator" +} \ No newline at end of file diff --git a/homeassistant/components/fan/translations/ca.json b/homeassistant/components/fan/translations/ca.json new file mode 100644 index 0000000000000..2c9ea5f4e7084 --- /dev/null +++ b/homeassistant/components/fan/translations/ca.json @@ -0,0 +1,23 @@ +{ + "device_automation": { + "action_type": { + "turn_off": "Apaga {entity_name}", + "turn_on": "Enc\u00e9n {entity_name}" + }, + "condition_type": { + "is_off": "{entity_name} est\u00e0 apagat", + "is_on": "{entity_name} est\u00e0 enc\u00e8s" + }, + "trigger_type": { + "turned_off": "{entity_name} s'ha apagat", + "turned_on": "{entity_name} s'ha enc\u00e8s" + } + }, + "state": { + "_": { + "off": "Apagat", + "on": "Enc\u00e8s" + } + }, + "title": "Ventiladors" +} \ No newline at end of file diff --git a/homeassistant/components/fan/translations/cs.json b/homeassistant/components/fan/translations/cs.json new file mode 100644 index 0000000000000..58f27535d2bd9 --- /dev/null +++ b/homeassistant/components/fan/translations/cs.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "Neaktivn\u00ed", + "on": "Aktivn\u00ed" + } + }, + "title": "Ventil\u00e1tor" +} \ No newline at end of file diff --git a/homeassistant/components/fan/translations/cy.json b/homeassistant/components/fan/translations/cy.json new file mode 100644 index 0000000000000..6454924ef2369 --- /dev/null +++ b/homeassistant/components/fan/translations/cy.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "i ffwrdd", + "on": "Ar" + } + }, + "title": "Ffan" +} \ No newline at end of file diff --git a/homeassistant/components/fan/translations/da.json b/homeassistant/components/fan/translations/da.json new file mode 100644 index 0000000000000..b6028ee6a18f7 --- /dev/null +++ b/homeassistant/components/fan/translations/da.json @@ -0,0 +1,23 @@ +{ + "device_automation": { + "action_type": { + "turn_off": "Sluk {entity_name}", + "turn_on": "T\u00e6nd for {entity_name}" + }, + "condition_type": { + "is_off": "{entity_name} er slukket", + "is_on": "{entity_name} er t\u00e6ndt" + }, + "trigger_type": { + "turned_off": "{entity_name} blev slukket", + "turned_on": "{entity_name} blev t\u00e6ndt" + } + }, + "state": { + "_": { + "off": "Fra", + "on": "Til" + } + }, + "title": "Bl\u00e6ser" +} \ No newline at end of file diff --git a/homeassistant/components/fan/translations/de.json b/homeassistant/components/fan/translations/de.json new file mode 100644 index 0000000000000..04d15e42706f9 --- /dev/null +++ b/homeassistant/components/fan/translations/de.json @@ -0,0 +1,23 @@ +{ + "device_automation": { + "action_type": { + "turn_off": "Schalte {entity_name} aus.", + "turn_on": "Schalte {entity_name} ein." + }, + "condition_type": { + "is_off": "{entity_name} ist ausgeschaltet", + "is_on": "{entity_name} ist eingeschaltet" + }, + "trigger_type": { + "turned_off": "{entity_name} ausgeschaltet", + "turned_on": "{entity_name} eingeschaltet" + } + }, + "state": { + "_": { + "off": "Aus", + "on": "An" + } + }, + "title": "Ventilator" +} \ No newline at end of file diff --git a/homeassistant/components/fan/translations/el.json b/homeassistant/components/fan/translations/el.json new file mode 100644 index 0000000000000..518f25b6b8967 --- /dev/null +++ b/homeassistant/components/fan/translations/el.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "\u039a\u03bb\u03b5\u03b9\u03c3\u03c4\u03cc", + "on": "\u0391\u03bd\u03bf\u03b9\u03c7\u03c4\u03cc" + } + }, + "title": "\u0391\u03bd\u03b5\u03bc\u03b9\u03c3\u03c4\u03ae\u03c1\u03b1\u03c2" +} \ No newline at end of file diff --git a/homeassistant/components/fan/translations/en.json b/homeassistant/components/fan/translations/en.json new file mode 100644 index 0000000000000..8ef5c8b8b4bc8 --- /dev/null +++ b/homeassistant/components/fan/translations/en.json @@ -0,0 +1,23 @@ +{ + "device_automation": { + "action_type": { + "turn_off": "Turn off {entity_name}", + "turn_on": "Turn on {entity_name}" + }, + "condition_type": { + "is_off": "{entity_name} is off", + "is_on": "{entity_name} is on" + }, + "trigger_type": { + "turned_off": "{entity_name} turned off", + "turned_on": "{entity_name} turned on" + } + }, + "state": { + "_": { + "off": "Off", + "on": "On" + } + }, + "title": "Fan" +} \ No newline at end of file diff --git a/homeassistant/components/fan/translations/es-419.json b/homeassistant/components/fan/translations/es-419.json new file mode 100644 index 0000000000000..6060cff985a74 --- /dev/null +++ b/homeassistant/components/fan/translations/es-419.json @@ -0,0 +1,19 @@ +{ + "device_automation": { + "condition_type": { + "is_off": "{entity_name} est\u00e1 apagado", + "is_on": "{entity_name} est\u00e1 encendido" + }, + "trigger_type": { + "turned_off": "{entity_name} se apag\u00f3", + "turned_on": "{entity_name} se encendi\u00f3" + } + }, + "state": { + "_": { + "off": "Desactivado", + "on": "Encendido" + } + }, + "title": "Ventilador" +} \ No newline at end of file diff --git a/homeassistant/components/fan/translations/es.json b/homeassistant/components/fan/translations/es.json new file mode 100644 index 0000000000000..645f0c820c68b --- /dev/null +++ b/homeassistant/components/fan/translations/es.json @@ -0,0 +1,23 @@ +{ + "device_automation": { + "action_type": { + "turn_off": "Desactivar {entity_name}", + "turn_on": "Activar {entity_name}" + }, + "condition_type": { + "is_off": "{entity_name} est\u00e1 desactivado", + "is_on": "{entity_name} est\u00e1 activado" + }, + "trigger_type": { + "turned_off": "{entity_name} desactivado", + "turned_on": "{entity_name} activado" + } + }, + "state": { + "_": { + "off": "Apagado", + "on": "Encendido" + } + }, + "title": "Ventilador" +} \ No newline at end of file diff --git a/homeassistant/components/fan/translations/et.json b/homeassistant/components/fan/translations/et.json new file mode 100644 index 0000000000000..6652568a0a7b2 --- /dev/null +++ b/homeassistant/components/fan/translations/et.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "V\u00e4ljas", + "on": "Sees" + } + }, + "title": "Ventilaator" +} \ No newline at end of file diff --git a/homeassistant/components/fan/translations/eu.json b/homeassistant/components/fan/translations/eu.json new file mode 100644 index 0000000000000..e421d7a29f729 --- /dev/null +++ b/homeassistant/components/fan/translations/eu.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "Itzalita", + "on": "Piztuta" + } + }, + "title": "Haizagailua" +} \ No newline at end of file diff --git a/homeassistant/components/fan/translations/fa.json b/homeassistant/components/fan/translations/fa.json new file mode 100644 index 0000000000000..0cfd0f47f4fed --- /dev/null +++ b/homeassistant/components/fan/translations/fa.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "\u062e\u0627\u0645\u0648\u0634", + "on": "\u0631\u0648\u0634\u0646" + } + }, + "title": "\u0641\u0646" +} \ No newline at end of file diff --git a/homeassistant/components/fan/translations/fi.json b/homeassistant/components/fan/translations/fi.json new file mode 100644 index 0000000000000..20ae0a77799a4 --- /dev/null +++ b/homeassistant/components/fan/translations/fi.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "Pois", + "on": "P\u00e4\u00e4ll\u00e4" + } + }, + "title": "Tuuletin" +} \ No newline at end of file diff --git a/homeassistant/components/fan/translations/fr.json b/homeassistant/components/fan/translations/fr.json new file mode 100644 index 0000000000000..88b4057a3ebbb --- /dev/null +++ b/homeassistant/components/fan/translations/fr.json @@ -0,0 +1,23 @@ +{ + "device_automation": { + "action_type": { + "turn_off": "\u00c9teindre {entity_name}", + "turn_on": "Allumer {entity_name}" + }, + "condition_type": { + "is_off": "{entity_name} est d\u00e9sactiv\u00e9", + "is_on": "{entity_name} est activ\u00e9" + }, + "trigger_type": { + "turned_off": "{entity_name} est \u00e9teint", + "turned_on": "{entity_name} allum\u00e9" + } + }, + "state": { + "_": { + "off": "\u00c9teint", + "on": "Marche" + } + }, + "title": "Ventilateur" +} \ No newline at end of file diff --git a/homeassistant/components/fan/translations/gsw.json b/homeassistant/components/fan/translations/gsw.json new file mode 100644 index 0000000000000..badd78cb9fe8b --- /dev/null +++ b/homeassistant/components/fan/translations/gsw.json @@ -0,0 +1,8 @@ +{ + "state": { + "_": { + "off": "Us", + "on": "Ah" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fan/translations/he.json b/homeassistant/components/fan/translations/he.json new file mode 100644 index 0000000000000..e2081b7460eba --- /dev/null +++ b/homeassistant/components/fan/translations/he.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "\u05db\u05d1\u05d5\u05d9", + "on": "\u05d3\u05dc\u05d5\u05e7" + } + }, + "title": "\u05de\u05d0\u05d5\u05d5\u05e8\u05e8" +} \ No newline at end of file diff --git a/homeassistant/components/fan/translations/hi.json b/homeassistant/components/fan/translations/hi.json new file mode 100644 index 0000000000000..555d17b7d3eb9 --- /dev/null +++ b/homeassistant/components/fan/translations/hi.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "\u092c\u0902\u0926", + "on": "\u091a\u093e\u0932\u0942" + } + }, + "title": "\u092a\u0902\u0916\u093e" +} \ No newline at end of file diff --git a/homeassistant/components/fan/translations/hr.json b/homeassistant/components/fan/translations/hr.json new file mode 100644 index 0000000000000..e0cbfcbec89e1 --- /dev/null +++ b/homeassistant/components/fan/translations/hr.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "Isklju\u010den", + "on": "Uklju\u010den" + } + }, + "title": "Ventilator" +} \ No newline at end of file diff --git a/homeassistant/components/fan/translations/hu.json b/homeassistant/components/fan/translations/hu.json new file mode 100644 index 0000000000000..daa8f0acf97c7 --- /dev/null +++ b/homeassistant/components/fan/translations/hu.json @@ -0,0 +1,23 @@ +{ + "device_automation": { + "action_type": { + "turn_off": "{entity_name} kikapcsol\u00e1sa", + "turn_on": "{entity_name} bekapcsol\u00e1sa" + }, + "condition_type": { + "is_off": "{entity_name} ki van kapcsolva", + "is_on": "{entity_name} be van kapcsolva" + }, + "trigger_type": { + "turned_off": "{entity_name} ki lett kapcsolva", + "turned_on": "{entity_name} be lett kapcsolva" + } + }, + "state": { + "_": { + "off": "Ki", + "on": "Be" + } + }, + "title": "Ventil\u00e1tor" +} \ No newline at end of file diff --git a/homeassistant/components/fan/translations/hy.json b/homeassistant/components/fan/translations/hy.json new file mode 100644 index 0000000000000..a1688e90b09d5 --- /dev/null +++ b/homeassistant/components/fan/translations/hy.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "\u0531\u0576\u057b\u0561\u057f\u057e\u0561\u056e", + "on": "\u0544\u056b\u0561\u0581\u0561\u056e" + } + }, + "title": "\u0585\u0564\u0561\u0583\u0578\u056d\u056b\u0579" +} \ No newline at end of file diff --git a/homeassistant/components/fan/translations/id.json b/homeassistant/components/fan/translations/id.json new file mode 100644 index 0000000000000..b5324f36f6a8d --- /dev/null +++ b/homeassistant/components/fan/translations/id.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "Off", + "on": "On" + } + }, + "title": "Kipas angin" +} \ No newline at end of file diff --git a/homeassistant/components/fan/translations/is.json b/homeassistant/components/fan/translations/is.json new file mode 100644 index 0000000000000..f06d44366b02a --- /dev/null +++ b/homeassistant/components/fan/translations/is.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "Sl\u00f6kkt", + "on": "\u00cd gangi" + } + }, + "title": "Vifta" +} \ No newline at end of file diff --git a/homeassistant/components/fan/translations/it.json b/homeassistant/components/fan/translations/it.json new file mode 100644 index 0000000000000..2a8ca655f6642 --- /dev/null +++ b/homeassistant/components/fan/translations/it.json @@ -0,0 +1,23 @@ +{ + "device_automation": { + "action_type": { + "turn_off": "Spegnere {entity_name}", + "turn_on": "Accendere {entity_name}" + }, + "condition_type": { + "is_off": "{entity_name} \u00e8 spento", + "is_on": "{entity_name} \u00e8 acceso" + }, + "trigger_type": { + "turned_off": "{entity_name} disattivato", + "turned_on": "{entity_name} attivato" + } + }, + "state": { + "_": { + "off": "Spento", + "on": "Acceso" + } + }, + "title": "Ventilatore" +} \ No newline at end of file diff --git a/homeassistant/components/fan/translations/ja.json b/homeassistant/components/fan/translations/ja.json new file mode 100644 index 0000000000000..15dd3796187f2 --- /dev/null +++ b/homeassistant/components/fan/translations/ja.json @@ -0,0 +1,8 @@ +{ + "state": { + "_": { + "off": "\u30aa\u30d5", + "on": "\u30aa\u30f3" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fan/translations/ko.json b/homeassistant/components/fan/translations/ko.json new file mode 100644 index 0000000000000..5f6116d48d23d --- /dev/null +++ b/homeassistant/components/fan/translations/ko.json @@ -0,0 +1,23 @@ +{ + "device_automation": { + "action_type": { + "turn_off": "{entity_name} \ub044\uae30", + "turn_on": "{entity_name} \ucf1c\uae30" + }, + "condition_type": { + "is_off": "{entity_name} \uc774(\uac00) \uaebc\uc838 \uc788\uc73c\uba74", + "is_on": "{entity_name} \uc774(\uac00) \ucf1c\uc838 \uc788\uc73c\uba74" + }, + "trigger_type": { + "turned_off": "{entity_name} \uc774(\uac00) \uaebc\uc9c8 \ub54c", + "turned_on": "{entity_name} \uc774(\uac00) \ucf1c\uc9c8 \ub54c" + } + }, + "state": { + "_": { + "off": "\uaebc\uc9d0", + "on": "\ucf1c\uc9d0" + } + }, + "title": "\uc1a1\ud48d\uae30" +} \ No newline at end of file diff --git a/homeassistant/components/fan/translations/lb.json b/homeassistant/components/fan/translations/lb.json new file mode 100644 index 0000000000000..acac97c93cfbc --- /dev/null +++ b/homeassistant/components/fan/translations/lb.json @@ -0,0 +1,23 @@ +{ + "device_automation": { + "action_type": { + "turn_off": "{entity_name} ausschalten", + "turn_on": "{entity_name} uschalten" + }, + "condition_type": { + "is_off": "{entity_name} ass aus", + "is_on": "{entity_name} ass un" + }, + "trigger_type": { + "turned_off": "{entity_name} gouf ausgeschalt", + "turned_on": "{entity_name} gouf ugeschalt" + } + }, + "state": { + "_": { + "off": "Aus", + "on": "Un" + } + }, + "title": "Ventilator" +} \ No newline at end of file diff --git a/homeassistant/components/fan/translations/lt.json b/homeassistant/components/fan/translations/lt.json new file mode 100644 index 0000000000000..3cf0e9b442d9f --- /dev/null +++ b/homeassistant/components/fan/translations/lt.json @@ -0,0 +1,8 @@ +{ + "state": { + "_": { + "off": "I\u0161jungta", + "on": "\u012ejungta" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fan/translations/lv.json b/homeassistant/components/fan/translations/lv.json new file mode 100644 index 0000000000000..0c18f8cc0ebac --- /dev/null +++ b/homeassistant/components/fan/translations/lv.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "Izsl\u0113gts", + "on": "Iesl\u0113gts" + } + }, + "title": "Ventilators" +} \ No newline at end of file diff --git a/homeassistant/components/fan/translations/nb.json b/homeassistant/components/fan/translations/nb.json new file mode 100644 index 0000000000000..f9d1def352beb --- /dev/null +++ b/homeassistant/components/fan/translations/nb.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "Av", + "on": "P\u00e5" + } + }, + "title": "Vifte" +} \ No newline at end of file diff --git a/homeassistant/components/fan/translations/nl.json b/homeassistant/components/fan/translations/nl.json new file mode 100644 index 0000000000000..07f6bbf8c7b8c --- /dev/null +++ b/homeassistant/components/fan/translations/nl.json @@ -0,0 +1,23 @@ +{ + "device_automation": { + "action_type": { + "turn_off": "Schakel {entity_name} uit", + "turn_on": "Schakel {entity_name} in" + }, + "condition_type": { + "is_off": "{entity_name} is uitgeschakeld", + "is_on": "{entity_name} is ingeschakeld" + }, + "trigger_type": { + "turned_off": "{entity_name} uitgeschakeld", + "turned_on": "{entity_name} ingeschakeld" + } + }, + "state": { + "_": { + "off": "Uit", + "on": "Aan" + } + }, + "title": "Ventilator" +} \ No newline at end of file diff --git a/homeassistant/components/fan/translations/nn.json b/homeassistant/components/fan/translations/nn.json new file mode 100644 index 0000000000000..f9d1def352beb --- /dev/null +++ b/homeassistant/components/fan/translations/nn.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "Av", + "on": "P\u00e5" + } + }, + "title": "Vifte" +} \ No newline at end of file diff --git a/homeassistant/components/fan/translations/no.json b/homeassistant/components/fan/translations/no.json new file mode 100644 index 0000000000000..c4c425c0eb822 --- /dev/null +++ b/homeassistant/components/fan/translations/no.json @@ -0,0 +1,23 @@ +{ + "device_automation": { + "action_type": { + "turn_off": "Sl\u00e5 av {entity_name}", + "turn_on": "Sl\u00e5 p\u00e5 {entity_name}" + }, + "condition_type": { + "is_off": "{entity_name} er av", + "is_on": "{entity_name} er p\u00e5" + }, + "trigger_type": { + "turned_off": "{entity_name} sl\u00e5tt av", + "turned_on": "{entity_name} sl\u00e5tt p\u00e5" + } + }, + "state": { + "_": { + "off": "Av", + "on": "P\u00e5" + } + }, + "title": "Vifte" +} \ No newline at end of file diff --git a/homeassistant/components/fan/translations/pl.json b/homeassistant/components/fan/translations/pl.json new file mode 100644 index 0000000000000..b90d6084ca0e4 --- /dev/null +++ b/homeassistant/components/fan/translations/pl.json @@ -0,0 +1,23 @@ +{ + "device_automation": { + "action_type": { + "turn_off": "wy\u0142\u0105cz {entity_name}", + "turn_on": "w\u0142\u0105cz {entity_name}" + }, + "condition_type": { + "is_off": "wentylator (entity_name} jest wy\u0142\u0105czony", + "is_on": "wentylator (entity_name} jest w\u0142\u0105czony" + }, + "trigger_type": { + "turned_off": "nast\u0105pi wy\u0142\u0105czenie {entity_name}", + "turned_on": "nast\u0105pi w\u0142\u0105czenie {entity_name}" + } + }, + "state": { + "_": { + "off": "wy\u0142\u0105czony", + "on": "w\u0142\u0105czony" + } + }, + "title": "Wentylator" +} \ No newline at end of file diff --git a/homeassistant/components/fan/translations/pt-BR.json b/homeassistant/components/fan/translations/pt-BR.json new file mode 100644 index 0000000000000..f5e9e2f8629fb --- /dev/null +++ b/homeassistant/components/fan/translations/pt-BR.json @@ -0,0 +1,15 @@ +{ + "device_automation": { + "condition_type": { + "is_off": "{entity_name} est\u00e1 desligado", + "is_on": "{entity_name} est\u00e1 ligado" + } + }, + "state": { + "_": { + "off": "Desligado", + "on": "Ligado" + } + }, + "title": "Ventilador" +} \ No newline at end of file diff --git a/homeassistant/components/fan/translations/pt.json b/homeassistant/components/fan/translations/pt.json new file mode 100644 index 0000000000000..5373dde19b1e5 --- /dev/null +++ b/homeassistant/components/fan/translations/pt.json @@ -0,0 +1,23 @@ +{ + "device_automation": { + "action_type": { + "turn_off": "Desligar {entity_name}", + "turn_on": "Ligar {entity_name}" + }, + "condition_type": { + "is_off": "{entity_name} est\u00e1 desligada", + "is_on": "{entity_name} est\u00e1 ligada" + }, + "trigger_type": { + "turned_off": "{entity_name} desligou-se", + "turned_on": "{entity_name} ligou-se" + } + }, + "state": { + "_": { + "off": "Desligada", + "on": "Ligado" + } + }, + "title": "Vento\u00ednha" +} \ No newline at end of file diff --git a/homeassistant/components/fan/translations/ro.json b/homeassistant/components/fan/translations/ro.json new file mode 100644 index 0000000000000..926aba2b9f496 --- /dev/null +++ b/homeassistant/components/fan/translations/ro.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "Oprit", + "on": "Pornit" + } + }, + "title": "Ventilator" +} \ No newline at end of file diff --git a/homeassistant/components/fan/translations/ru.json b/homeassistant/components/fan/translations/ru.json new file mode 100644 index 0000000000000..8d1bf91ed1efb --- /dev/null +++ b/homeassistant/components/fan/translations/ru.json @@ -0,0 +1,23 @@ +{ + "device_automation": { + "action_type": { + "turn_off": "\u0412\u044b\u043a\u043b\u044e\u0447\u0438\u0442\u044c {entity_name}", + "turn_on": "\u0412\u043a\u043b\u044e\u0447\u0438\u0442\u044c {entity_name}" + }, + "condition_type": { + "is_off": "{entity_name} \u0432 \u0432\u044b\u043a\u043b\u044e\u0447\u0435\u043d\u043d\u043e\u043c \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0438", + "is_on": "{entity_name} \u0432\u043e \u0432\u043a\u043b\u044e\u0447\u0435\u043d\u043d\u043e\u043c \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0438" + }, + "trigger_type": { + "turned_off": "{entity_name} \u0432\u044b\u043a\u043b\u044e\u0447\u0430\u0435\u0442\u0441\u044f", + "turned_on": "{entity_name} \u0432\u043a\u043b\u044e\u0447\u0430\u0435\u0442\u0441\u044f" + } + }, + "state": { + "_": { + "off": "\u0412\u044b\u043a\u043b", + "on": "\u0412\u043a\u043b" + } + }, + "title": "\u0412\u0435\u043d\u0442\u0438\u043b\u044f\u0442\u043e\u0440" +} \ No newline at end of file diff --git a/homeassistant/components/fan/translations/sk.json b/homeassistant/components/fan/translations/sk.json new file mode 100644 index 0000000000000..1dc17560e3427 --- /dev/null +++ b/homeassistant/components/fan/translations/sk.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "Neakt\u00edvny", + "on": "Zapnut\u00fd" + } + }, + "title": "Ventil\u00e1tor" +} \ No newline at end of file diff --git a/homeassistant/components/fan/translations/sl.json b/homeassistant/components/fan/translations/sl.json new file mode 100644 index 0000000000000..c987bd921c8af --- /dev/null +++ b/homeassistant/components/fan/translations/sl.json @@ -0,0 +1,23 @@ +{ + "device_automation": { + "action_type": { + "turn_off": "Izklopite {entity_name}", + "turn_on": "Vklopite {entity_name}" + }, + "condition_type": { + "is_off": "{entity_name} je izklopljen", + "is_on": "{entity_name} je vklopljen" + }, + "trigger_type": { + "turned_off": "{entity_name} izklopljen", + "turned_on": "{entity_name} vklopljen" + } + }, + "state": { + "_": { + "off": "Izklju\u010den", + "on": "Vklopljen" + } + }, + "title": "Ventilator" +} \ No newline at end of file diff --git a/homeassistant/components/fan/translations/sv.json b/homeassistant/components/fan/translations/sv.json new file mode 100644 index 0000000000000..dd1aaad405227 --- /dev/null +++ b/homeassistant/components/fan/translations/sv.json @@ -0,0 +1,23 @@ +{ + "device_automation": { + "action_type": { + "turn_off": "St\u00e4ng av {entity_name}", + "turn_on": "Sl\u00e5 p\u00e5 {entity_name}" + }, + "condition_type": { + "is_off": "{entity_name} \u00e4r avst\u00e4ngd", + "is_on": "{entity_name} \u00e4r p\u00e5" + }, + "trigger_type": { + "turned_off": "{entity_name} st\u00e4ngdes av", + "turned_on": "{entity_name} aktiverades" + } + }, + "state": { + "_": { + "off": "Av", + "on": "P\u00e5" + } + }, + "title": "Fl\u00e4kt" +} \ No newline at end of file diff --git a/homeassistant/components/fan/translations/ta.json b/homeassistant/components/fan/translations/ta.json new file mode 100644 index 0000000000000..02e61095e836a --- /dev/null +++ b/homeassistant/components/fan/translations/ta.json @@ -0,0 +1,8 @@ +{ + "state": { + "_": { + "off": "\u0b86\u0b83\u0baa\u0bcd", + "on": "\u0bb5\u0bbf\u0b9a\u0bbf\u0bb1\u0bbf \u0b86\u0ba9\u0bcd" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fan/translations/te.json b/homeassistant/components/fan/translations/te.json new file mode 100644 index 0000000000000..83ed200c7b163 --- /dev/null +++ b/homeassistant/components/fan/translations/te.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "\u0c06\u0c2b\u0c4d", + "on": "\u0c06\u0c28\u0c4d" + } + }, + "title": "\u0c2b\u0c4d\u0c2f\u0c3e\u0c28\u0c4d" +} \ No newline at end of file diff --git a/homeassistant/components/fan/translations/th.json b/homeassistant/components/fan/translations/th.json new file mode 100644 index 0000000000000..8626b372d3221 --- /dev/null +++ b/homeassistant/components/fan/translations/th.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "\u0e1b\u0e34\u0e14", + "on": "\u0e40\u0e1b\u0e34\u0e14" + } + }, + "title": "\u0e1e\u0e31\u0e14\u0e25\u0e21" +} \ No newline at end of file diff --git a/homeassistant/components/fan/translations/tr.json b/homeassistant/components/fan/translations/tr.json new file mode 100644 index 0000000000000..4ffc57601bdd5 --- /dev/null +++ b/homeassistant/components/fan/translations/tr.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "Kapal\u0131", + "on": "A\u00e7\u0131k" + } + }, + "title": "Fan" +} \ No newline at end of file diff --git a/homeassistant/components/fan/translations/uk.json b/homeassistant/components/fan/translations/uk.json new file mode 100644 index 0000000000000..80b64c28c2f9c --- /dev/null +++ b/homeassistant/components/fan/translations/uk.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "\u0412\u0438\u043c\u043a\u043d\u0435\u043d\u043e", + "on": "\u0423\u0432\u0456\u043c\u043a\u043d\u0435\u043d\u043e" + } + }, + "title": "\u0412\u0435\u043d\u0442\u0438\u043b\u044f\u0442\u043e\u0440" +} \ No newline at end of file diff --git a/homeassistant/components/fan/translations/vi.json b/homeassistant/components/fan/translations/vi.json new file mode 100644 index 0000000000000..0208c6de8cbd7 --- /dev/null +++ b/homeassistant/components/fan/translations/vi.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "T\u1eaft", + "on": "B\u1eadt" + } + }, + "title": "Qu\u1ea1t" +} \ No newline at end of file diff --git a/homeassistant/components/fan/translations/zh-Hans.json b/homeassistant/components/fan/translations/zh-Hans.json new file mode 100644 index 0000000000000..6dde9459c1aca --- /dev/null +++ b/homeassistant/components/fan/translations/zh-Hans.json @@ -0,0 +1,23 @@ +{ + "device_automation": { + "action_type": { + "turn_off": "\u5173\u95ed {entity_name}", + "turn_on": "\u6253\u5f00 {entity_name}" + }, + "condition_type": { + "is_off": "{entity_name} \u5df2\u5173\u95ed", + "is_on": "{entity_name} \u5df2\u5f00\u542f" + }, + "trigger_type": { + "turned_off": "{entity_name} \u88ab\u5173\u95ed", + "turned_on": "{entity_name} \u88ab\u5f00\u542f" + } + }, + "state": { + "_": { + "off": "\u5173\u95ed", + "on": "\u5f00" + } + }, + "title": "\u98ce\u6247" +} \ No newline at end of file diff --git a/homeassistant/components/fan/translations/zh-Hant.json b/homeassistant/components/fan/translations/zh-Hant.json new file mode 100644 index 0000000000000..aca88f36fda6d --- /dev/null +++ b/homeassistant/components/fan/translations/zh-Hant.json @@ -0,0 +1,23 @@ +{ + "device_automation": { + "action_type": { + "turn_off": "\u95dc\u9589{entity_name}", + "turn_on": "\u958b\u555f{entity_name}" + }, + "condition_type": { + "is_off": "{entity_name}\u95dc\u9589", + "is_on": "{entity_name}\u958b\u555f" + }, + "trigger_type": { + "turned_off": "{entity_name}\u5df2\u95dc\u9589", + "turned_on": "{entity_name}\u5df2\u958b\u555f" + } + }, + "state": { + "_": { + "off": "\u95dc\u9589", + "on": "\u958b\u555f" + } + }, + "title": "\u98a8\u6247" +} \ No newline at end of file diff --git a/homeassistant/components/fastdotcom/__init__.py b/homeassistant/components/fastdotcom/__init__.py index 3fe860a81fdb1..e0a4782493e4e 100644 --- a/homeassistant/components/fastdotcom/__init__.py +++ b/homeassistant/components/fastdotcom/__init__.py @@ -1,31 +1,38 @@ """Support for testing internet speed via Fast.com.""" -import logging from datetime import timedelta +import logging +from fastdotcom import fast_com import voluptuous as vol -import homeassistant.helpers.config_validation as cv from homeassistant.const import CONF_SCAN_INTERVAL +import homeassistant.helpers.config_validation as cv 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) +DOMAIN = "fastdotcom" +DATA_UPDATED = f"{DOMAIN}_data_updated" _LOGGER = logging.getLogger(__name__) -CONF_MANUAL = 'manual' +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) +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): @@ -34,19 +41,15 @@ async def async_setup(hass, config): data = hass.data[DOMAIN] = SpeedtestData(hass) if not conf[CONF_MANUAL]: - async_track_time_interval( - hass, data.update, conf[CONF_SCAN_INTERVAL] - ) + 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.services.async_register(DOMAIN, "speedtest", update) - hass.async_create_task( - async_load_platform(hass, 'sensor', DOMAIN, {}, config) - ) + hass.async_create_task(async_load_platform(hass, "sensor", DOMAIN, {}, config)) return True @@ -61,7 +64,7 @@ def __init__(self, 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()} + 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 index f4bf021380c98..ca7a720668bf1 100644 --- a/homeassistant/components/fastdotcom/manifest.json +++ b/homeassistant/components/fastdotcom/manifest.json @@ -1,10 +1,7 @@ { "domain": "fastdotcom", - "name": "Fastdotcom", - "documentation": "https://www.home-assistant.io/components/fastdotcom", - "requirements": [ - "fastdotcom==0.0.3" - ], - "dependencies": [], - "codeowners": [] + "name": "Fast.com", + "documentation": "https://www.home-assistant.io/integrations/fastdotcom", + "requirements": ["fastdotcom==0.0.3"], + "codeowners": ["@rohankapoorcom"] } diff --git a/homeassistant/components/fastdotcom/sensor.py b/homeassistant/components/fastdotcom/sensor.py index c9af8e53ab86c..fe131e4dab48d 100644 --- a/homeassistant/components/fastdotcom/sensor.py +++ b/homeassistant/components/fastdotcom/sensor.py @@ -1,6 +1,7 @@ """Support for Fast.com internet speed testing sensor.""" import logging +from homeassistant.const import DATA_RATE_MEGABITS_PER_SECOND from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.restore_state import RestoreEntity @@ -9,13 +10,10 @@ _LOGGER = logging.getLogger(__name__) -ICON = 'mdi:speedometer' +ICON = "mdi:speedometer" -UNIT_OF_MEASUREMENT = 'Mbit/s' - -async def async_setup_platform(hass, config, async_add_entities, - discovery_info=None): +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])]) @@ -25,7 +23,7 @@ class SpeedtestSensor(RestoreEntity): def __init__(self, speedtest_data): """Initialize the sensor.""" - self._name = 'Fast.com Download' + self._name = "Fast.com Download" self.speedtest_client = speedtest_data self._state = None @@ -42,7 +40,7 @@ def state(self): @property def unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" - return UNIT_OF_MEASUREMENT + return DATA_RATE_MEGABITS_PER_SECOND @property def icon(self): @@ -57,21 +55,24 @@ def should_poll(self): async def async_added_to_hass(self): """Handle entity which will be added.""" await super().async_added_to_hass() + + self.async_on_remove( + async_dispatcher_connect( + self.hass, DATA_UPDATED, self._schedule_immediate_update + ) + ) + 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'] + self._state = data["download"] @callback def _schedule_immediate_update(self): diff --git a/homeassistant/components/fastdotcom/services.yaml b/homeassistant/components/fastdotcom/services.yaml index fe6cb1ac12dba..f0afb49dbe0a7 100644 --- a/homeassistant/components/fastdotcom/services.yaml +++ b/homeassistant/components/fastdotcom/services.yaml @@ -1,2 +1,2 @@ speedtest: - description: Immediately take a speedest with Fast.com \ No newline at end of file + description: Immediately take a speedest with Fast.com diff --git a/homeassistant/components/fedex/__init__.py b/homeassistant/components/fedex/__init__.py deleted file mode 100644 index d685ab50372de..0000000000000 --- a/homeassistant/components/fedex/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""The fedex component.""" diff --git a/homeassistant/components/fedex/manifest.json b/homeassistant/components/fedex/manifest.json deleted file mode 100644 index b34a8b8383ef8..0000000000000 --- a/homeassistant/components/fedex/manifest.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "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 deleted file mode 100644 index aec1cee053c14..0000000000000 --- a/homeassistant/components/fedex/sensor.py +++ /dev/null @@ -1,106 +0,0 @@ -"""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/__init__.py b/homeassistant/components/feedreader/__init__.py index d2acb674ec7d3..548654c11c026 100644 --- a/homeassistant/components/feedreader/__init__.py +++ b/homeassistant/components/feedreader/__init__.py @@ -2,36 +2,42 @@ from datetime import datetime, timedelta from logging import getLogger from os.path import exists -from threading import Lock import pickle +from threading import Lock +import feedparser import voluptuous as vol -from homeassistant.const import EVENT_HOMEASSISTANT_START, CONF_SCAN_INTERVAL -from homeassistant.helpers.event import track_time_interval +from homeassistant.const import CONF_SCAN_INTERVAL, EVENT_HOMEASSISTANT_START import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.event import track_time_interval _LOGGER = getLogger(__name__) -CONF_URLS = 'urls' -CONF_MAX_ENTRIES = 'max_entries' +CONF_URLS = "urls" +CONF_MAX_ENTRIES = "max_entries" DEFAULT_MAX_ENTRIES = 20 DEFAULT_SCAN_INTERVAL = timedelta(hours=1) -DOMAIN = 'feedreader' +DOMAIN = "feedreader" -EVENT_FEEDREADER = '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) +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): @@ -39,10 +45,11 @@ def setup(hass, config): 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)) + data_file = hass.config.path(f"{DOMAIN}.pickle") storage = StoredData(data_file) - feeds = [FeedManager(url, scan_interval, max_entries, hass, storage) for - url in urls] + feeds = [ + FeedManager(url, scan_interval, max_entries, hass, storage) for url in urls + ] return len(feeds) > 0 @@ -63,8 +70,7 @@ def __init__(self, url, scan_interval, max_entries, hass, storage): self._has_published_parsed = False self._event_type = EVENT_FEEDREADER self._feed_id = url - hass.bus.listen_once( - EVENT_HOMEASSISTANT_START, lambda _: self._update()) + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, lambda _: self._update()) self._init_regular_updates(hass) def _log_no_entries(self): @@ -73,8 +79,7 @@ def _log_no_entries(self): 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) + track_time_interval(hass, lambda now: self._update(), self._scan_interval) @property def last_update_successful(self): @@ -83,13 +88,12 @@ def last_update_successful(self): 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')) + 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 @@ -98,21 +102,28 @@ def _update(self): # 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 + # If an error is detected here, log warning 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) + _LOGGER.warning( + "Possible issue 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) + _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) + self._feed_id, self._last_entry_timestamp + ) else: self._log_no_entries() self._last_update_successful = True @@ -121,23 +132,27 @@ def _update(self): 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] + _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(): + # Check if the entry has a published date. + if "published_parsed" in entry.keys() and entry.published_parsed: + # We are lucky, `published_parsed` data available, let's make use of + # it to publish only new available entries since the last run self._has_published_parsed = True self._last_entry_timestamp = max( - entry.published_parsed, self._last_entry_timestamp) + 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}) + _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): @@ -148,12 +163,12 @@ def _publish_new_entries(self): self._firstrun = False else: # Set last entry timestamp as epoch time if not available - self._last_entry_timestamp = \ - datetime.utcfromtimestamp(0).timetuple() + 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): + "published_parsed" in entry.keys() + and entry.published_parsed > self._last_entry_timestamp + ): self._update_and_fire_entry(entry) new_entries = True else: @@ -179,12 +194,13 @@ def _fetch_data(self): 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: + 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) + _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).""" @@ -194,13 +210,15 @@ def get_timestamp(self, 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: + 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) + _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) + _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 index e458d30073e8a..30413d10e43c2 100644 --- a/homeassistant/components/feedreader/manifest.json +++ b/homeassistant/components/feedreader/manifest.json @@ -1,10 +1,7 @@ { "domain": "feedreader", "name": "Feedreader", - "documentation": "https://www.home-assistant.io/components/feedreader", - "requirements": [ - "feedparser-homeassistant==5.2.2.dev1" - ], - "dependencies": [], + "documentation": "https://www.home-assistant.io/integrations/feedreader", + "requirements": ["feedparser-homeassistant==5.2.2.dev1"], "codeowners": [] } diff --git a/homeassistant/components/ffmpeg/__init__.py b/homeassistant/components/ffmpeg/__init__.py index 7252e617c5ace..f109103a99c9b 100644 --- a/homeassistant/components/ffmpeg/__init__.py +++ b/homeassistant/components/ffmpeg/__init__.py @@ -2,57 +2,61 @@ import logging import re +from haffmpeg.tools import FFVersion 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) + ATTR_ENTITY_ID, + EVENT_HOMEASSISTANT_START, + EVENT_HOMEASSISTANT_STOP, +) +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) from homeassistant.helpers.entity import Entity -DOMAIN = 'ffmpeg' +DOMAIN = "ffmpeg" _LOGGER = logging.getLogger(__name__) -SERVICE_START = 'start' -SERVICE_STOP = 'stop' -SERVICE_RESTART = 'restart' +SERVICE_START = "start" +SERVICE_STOP = "stop" +SERVICE_RESTART = "restart" -SIGNAL_FFMPEG_START = 'ffmpeg.start' -SIGNAL_FFMPEG_STOP = 'ffmpeg.stop' -SIGNAL_FFMPEG_RESTART = 'ffmpeg.restart' +SIGNAL_FFMPEG_START = "ffmpeg.start" +SIGNAL_FFMPEG_STOP = "ffmpeg.stop" +SIGNAL_FFMPEG_RESTART = "ffmpeg.restart" -DATA_FFMPEG = 'ffmpeg' +DATA_FFMPEG = "ffmpeg" -CONF_INITIAL_STATE = 'initial_state' -CONF_INPUT = 'input' -CONF_FFMPEG_BIN = 'ffmpeg_bin' -CONF_EXTRA_ARGUMENTS = 'extra_arguments' -CONF_OUTPUT = 'output' +CONF_INITIAL_STATE = "initial_state" +CONF_INPUT = "input" +CONF_FFMPEG_BIN = "ffmpeg_bin" +CONF_EXTRA_ARGUMENTS = "extra_arguments" +CONF_OUTPUT = "output" -DEFAULT_BINARY = 'ffmpeg' +DEFAULT_BINARY = "ffmpeg" -CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ - vol.Optional(CONF_FFMPEG_BIN, default=DEFAULT_BINARY): cv.string, - }), -}, extra=vol.ALLOW_EXTRA) +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, -}) +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) - ) + manager = FFmpegManager(hass, conf.get(CONF_FFMPEG_BIN, DEFAULT_BINARY)) await manager.async_get_version() @@ -69,16 +73,16 @@ async def async_service_handle(service): async_dispatcher_send(hass, SIGNAL_FFMPEG_RESTART, entity_ids) hass.services.async_register( - DOMAIN, SERVICE_START, async_service_handle, - schema=SERVICE_FFMPEG_SCHEMA) + DOMAIN, SERVICE_START, async_service_handle, schema=SERVICE_FFMPEG_SCHEMA + ) hass.services.async_register( - DOMAIN, SERVICE_STOP, async_service_handle, - schema=SERVICE_FFMPEG_SCHEMA) + DOMAIN, SERVICE_STOP, async_service_handle, schema=SERVICE_FFMPEG_SCHEMA + ) hass.services.async_register( - DOMAIN, SERVICE_RESTART, async_service_handle, - schema=SERVICE_FFMPEG_SCHEMA) + DOMAIN, SERVICE_RESTART, async_service_handle, schema=SERVICE_FFMPEG_SCHEMA + ) hass.data[DATA_FFMPEG] = manager return True @@ -102,7 +106,6 @@ def binary(self): 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() @@ -119,9 +122,9 @@ async def async_get_version(self): 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=ffmpeg" - return 'multipart/x-mixed-replace;boundary=ffserver' + return "multipart/x-mixed-replace;boundary=ffserver" class FFmpegBase(Entity): @@ -137,12 +140,21 @@ async def async_added_to_hass(self): 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) + self.async_on_remove( + async_dispatcher_connect( + self.hass, SIGNAL_FFMPEG_START, self._async_start_ffmpeg + ) + ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, SIGNAL_FFMPEG_STOP, self._async_stop_ffmpeg + ) + ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, SIGNAL_FFMPEG_RESTART, self._async_restart_ffmpeg + ) + ) # register start/stop self._async_register_events() @@ -184,12 +196,12 @@ async def _async_restart_ffmpeg(self, entity_ids): @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) + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_shutdown_handle) # start on startup if not self.initial_state: @@ -198,7 +210,6 @@ async def async_shutdown_handle(event): async def async_start_handle(event): """Start FFmpeg process.""" await self._async_start_ffmpeg(None) - self.async_schedule_update_ha_state() + self.async_write_ha_state() - self.hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_START, async_start_handle) + 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 index 0e8a69e0bcf3d..db3eb5621ff7b 100644 --- a/homeassistant/components/ffmpeg/camera.py +++ b/homeassistant/components/ffmpeg/camera.py @@ -2,10 +2,11 @@ import asyncio import logging +from haffmpeg.camera import CameraMjpeg +from haffmpeg.tools import IMAGE_JPEG, ImageFrame import voluptuous as vol -from homeassistant.components.camera import ( - PLATFORM_SCHEMA, Camera, SUPPORT_STREAM) +from homeassistant.components.camera import PLATFORM_SCHEMA, SUPPORT_STREAM, Camera from homeassistant.const import CONF_NAME from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream import homeassistant.helpers.config_validation as cv @@ -14,18 +15,19 @@ _LOGGER = logging.getLogger(__name__) -DEFAULT_NAME = 'FFmpeg' +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, -}) +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): +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up a FFmpeg camera.""" async_add_entities([FFmpegCamera(hass, config)]) @@ -47,34 +49,36 @@ def supported_features(self): """Return supported features.""" return SUPPORT_STREAM - @property - def stream_source(self): + async def stream_source(self): """Return the stream source.""" - return self._input.split(' ')[-1] + 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), 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) + 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) + self.hass, + request, + stream_reader, + self._manager.ffmpeg_stream_content_type, + ) finally: await stream.close() diff --git a/homeassistant/components/ffmpeg/manifest.json b/homeassistant/components/ffmpeg/manifest.json index 4a3695e7dcc52..aee0b85d056ec 100644 --- a/homeassistant/components/ffmpeg/manifest.json +++ b/homeassistant/components/ffmpeg/manifest.json @@ -1,10 +1,7 @@ { "domain": "ffmpeg", - "name": "Ffmpeg", - "documentation": "https://www.home-assistant.io/components/ffmpeg", - "requirements": [ - "ha-ffmpeg==2.0" - ], - "dependencies": [], + "name": "FFmpeg", + "documentation": "https://www.home-assistant.io/integrations/ffmpeg", + "requirements": ["ha-ffmpeg==2.0"], "codeowners": [] } diff --git a/homeassistant/components/ffmpeg/services.yaml b/homeassistant/components/ffmpeg/services.yaml index 05c9c4fb18060..15afa82ed0a4e 100644 --- a/homeassistant/components/ffmpeg/services.yaml +++ b/homeassistant/components/ffmpeg/services.yaml @@ -1,15 +1,18 @@ 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} + 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} + 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} + entity_id: + description: Name(s) of entities that will stop. Platform dependent. + example: binary_sensor.ffmpeg_noise diff --git a/homeassistant/components/ffmpeg_motion/binary_sensor.py b/homeassistant/components/ffmpeg_motion/binary_sensor.py index 03aacf3aafbe3..a8842f9c40183 100644 --- a/homeassistant/components/ffmpeg_motion/binary_sensor.py +++ b/homeassistant/components/ffmpeg_motion/binary_sensor.py @@ -1,52 +1,61 @@ """Provides a binary sensor which is a collection of ffmpeg tools.""" import logging +import haffmpeg.sensor as ffmpeg_sensor 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.binary_sensor import PLATFORM_SCHEMA, BinarySensorEntity from homeassistant.components.ffmpeg import ( - FFmpegBase, DATA_FFMPEG, CONF_INPUT, CONF_EXTRA_ARGUMENTS, - CONF_INITIAL_STATE) + CONF_EXTRA_ARGUMENTS, + CONF_INITIAL_STATE, + CONF_INPUT, + DATA_FFMPEG, + FFmpegBase, +) from homeassistant.const import CONF_NAME +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -CONF_RESET = 'reset' -CONF_CHANGES = 'changes' -CONF_REPEAT = 'repeat' -CONF_REPEAT_TIME = 'repeat_time' +CONF_RESET = "reset" +CONF_CHANGES = "changes" +CONF_REPEAT = "repeat" +CONF_REPEAT_TIME = "repeat_time" -DEFAULT_NAME = 'FFmpeg Motion' +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): +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): +class FFmpegBinarySensor(FFmpegBase, BinarySensorEntity): """A binary sensor which use FFmpeg for noise detection.""" def __init__(self, config): @@ -61,7 +70,7 @@ def __init__(self, config): def _async_callback(self, state): """HA-FFmpeg callback for noise detection.""" self._state = state - self.async_schedule_update_ha_state() + self.async_write_ha_state() @property def is_on(self): @@ -79,11 +88,11 @@ class FFmpegMotion(FFmpegBinarySensor): 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) + self.ffmpeg = ffmpeg_sensor.SensorMotion( + manager.binary, hass.loop, self._async_callback + ) async def _async_start_ffmpeg(self, entity_ids): """Start a FFmpeg instance. @@ -110,4 +119,4 @@ async def _async_start_ffmpeg(self, entity_ids): @property def device_class(self): """Return the class of this sensor, from DEVICE_CLASSES.""" - return 'motion' + return "motion" diff --git a/homeassistant/components/ffmpeg_motion/manifest.json b/homeassistant/components/ffmpeg_motion/manifest.json index e9a0e7b10143f..854bca7f9bdd9 100644 --- a/homeassistant/components/ffmpeg_motion/manifest.json +++ b/homeassistant/components/ffmpeg_motion/manifest.json @@ -1,8 +1,7 @@ { "domain": "ffmpeg_motion", - "name": "Ffmpeg motion", - "documentation": "https://www.home-assistant.io/components/ffmpeg_motion", - "requirements": [], + "name": "FFmpeg Motion", + "documentation": "https://www.home-assistant.io/integrations/ffmpeg_motion", "dependencies": ["ffmpeg"], "codeowners": [] } diff --git a/homeassistant/components/ffmpeg_noise/binary_sensor.py b/homeassistant/components/ffmpeg_noise/binary_sensor.py index 7fbda8ca18b61..6ada2bb274896 100644 --- a/homeassistant/components/ffmpeg_noise/binary_sensor.py +++ b/homeassistant/components/ffmpeg_noise/binary_sensor.py @@ -1,42 +1,49 @@ """Provides a binary sensor which is a collection of ffmpeg tools.""" import logging +import haffmpeg.sensor as ffmpeg_sensor 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) + CONF_EXTRA_ARGUMENTS, + CONF_INITIAL_STATE, + CONF_INPUT, + CONF_OUTPUT, + DATA_FFMPEG, +) +from homeassistant.components.ffmpeg_motion.binary_sensor import FFmpegBinarySensor from homeassistant.const import CONF_NAME +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -CONF_PEAK = 'peak' -CONF_DURATION = 'duration' -CONF_RESET = 'reset' +CONF_PEAK = "peak" +CONF_DURATION = "duration" +CONF_RESET = "reset" -DEFAULT_NAME = 'FFmpeg Noise' +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): +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) @@ -48,11 +55,11 @@ class FFmpegNoise(FFmpegBinarySensor): 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) + self.ffmpeg = ffmpeg_sensor.SensorNoise( + manager.binary, hass.loop, self._async_callback + ) async def _async_start_ffmpeg(self, entity_ids): """Start a FFmpeg instance. @@ -77,4 +84,4 @@ async def _async_start_ffmpeg(self, entity_ids): @property def device_class(self): """Return the class of this sensor, from DEVICE_CLASSES.""" - return 'sound' + return "sound" diff --git a/homeassistant/components/ffmpeg_noise/manifest.json b/homeassistant/components/ffmpeg_noise/manifest.json index 71600b311177f..b2b4148a02291 100644 --- a/homeassistant/components/ffmpeg_noise/manifest.json +++ b/homeassistant/components/ffmpeg_noise/manifest.json @@ -1,8 +1,7 @@ { "domain": "ffmpeg_noise", - "name": "Ffmpeg noise", - "documentation": "https://www.home-assistant.io/components/ffmpeg_noise", - "requirements": [], + "name": "FFmpeg Noise", + "documentation": "https://www.home-assistant.io/integrations/ffmpeg_noise", "dependencies": ["ffmpeg"], "codeowners": [] } diff --git a/homeassistant/components/fibaro/__init__.py b/homeassistant/components/fibaro/__init__.py index f78afbf10e534..dcbffe2a568b0 100644 --- a/homeassistant/components/fibaro/__init__.py +++ b/homeassistant/components/fibaro/__init__.py @@ -1,13 +1,23 @@ """Support for the Fibaro devices.""" -import logging from collections import defaultdict +import logging from typing import Optional + +from fiblary3.client.v4.client import Client as FibaroClient, StateHandler 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) + 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 @@ -15,80 +25,96 @@ _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'] +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' + "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, -}) +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(): +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]) + 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] @@ -99,7 +125,7 @@ def __init__(self, config): 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 + self.hub_serial = None # Unique serial number of the hub def connect(self): """Start the communication with the Fibaro controller.""" @@ -108,12 +134,12 @@ def connect(self): info = self._client.info.get() self.hub_serial = slugify(info.serialNumber) except AssertionError: - _LOGGER.error("Can't connect to Fibaro HC. " - "Please check URL.") + _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") + _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()} @@ -123,7 +149,6 @@ def connect(self): 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): @@ -134,28 +159,26 @@ def disable_state_handler(self): def _on_state_change(self, state): """Handle change report received from the HomeCenter.""" callback_set = set() - for change in state.get('changes', []): + for change in state.get("changes", []): try: - dev_id = change.pop('id') + 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 property_name == "log": if value and value != "transfer OK": - _LOGGER.debug("LOG %s: %s", - device.friendly_name, value) + _LOGGER.debug("LOG %s: %s", device.friendly_name, value) continue - if property_name == 'logTemp': + 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)) + 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) + _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): @@ -170,8 +193,10 @@ def register(self, 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] + 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.""" @@ -182,29 +207,31 @@ 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: + if "type" in device: device_type = FIBARO_TYPEMAP.get(device.type) - if device_type is None and 'baseType' in device: + 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' + 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' + 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' + if ( + device_type == "switch" + and device.properties.get("isLight", "false") == "true" + ): + device_type = "light" return device_type def _read_scenes(self): @@ -215,17 +242,17 @@ def _read_scenes(self): continue device.fibaro_controller = self if device.roomID == 0: - room_name = 'Unknown' + 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) + device.friendly_name = f"{room_name} {device.name}" + device.ha_id = ( + f"scene_{slugify(room_name)}_{slugify(device.name)}_{device.id}" + ) + device.unique_id_str = f"{self.hub_serial}.scene.{device.id}" self._scene_map[device.id] = device - self.fibaro_devices['scene'].append(device) + self.fibaro_devices["scene"].append(device) def _read_devices(self): """Read and process the device list.""" @@ -237,42 +264,48 @@ def _read_devices(self): try: device.fibaro_controller = self if device.roomID == 0: - room_name = 'Unknown' + 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.friendly_name = f"{room_name} {device.name}" + device.ha_id = ( + f"{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, {}) + 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) + device.unique_id_str = f"{self.hub_serial}.{device.id}" self._device_map[device.id] = device - if dtype != 'climate': + 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: + 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)) + _LOGGER.debug( + "%s (%s, %s) -> %s %s", + device.ha_id, + device.type, + device.baseType, + dtype, + str(device), + ) except (KeyError, ValueError): pass @@ -298,12 +331,12 @@ def stop_fibaro(event): hass.data[FIBARO_CONTROLLERS][controller.hub_serial] = controller for component in FIBARO_COMPONENTS: hass.data[FIBARO_DEVICES][component].extend( - controller.fibaro_devices[component]) + controller.fibaro_devices[component] + ) if hass.data[FIBARO_CONTROLLERS]: for component in FIBARO_COMPONENTS: - discovery.load_platform(hass, component, DOMAIN, {}, - base_config) + 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) @@ -333,35 +366,37 @@ def _update_callback(self): @property def level(self): """Get the level of Fibaro device.""" - if 'value' in self.fibaro_device.properties: + 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: + 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)) + _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: + if "value" in self.fibaro_device.properties: self.fibaro_device.properties.value = level - if 'brightness' in self.fibaro_device.properties: + 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: + if "value2" in self.fibaro_device.properties: self.fibaro_device.properties.value2 = level def call_turn_on(self): @@ -378,29 +413,22 @@ def call_set_color(self, red, green, blue, white): 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) + color_str = f"{red},{green},{blue},{white}" self.fibaro_device.properties.color = color_str - self.action("setColor", str(red), str(green), - str(blue), str(white)) + 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)) + _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: + if "power" in self.fibaro_device.properties: power = self.fibaro_device.properties.power if power: return convert(power, float, 0.0) @@ -410,10 +438,12 @@ def current_power_w(self): @property def current_binary_state(self): """Return the current binary state.""" - if self.fibaro_device.properties.value == 'false': + if self.fibaro_device.properties.value == "false": return False - if self.fibaro_device.properties.value == 'true' or \ - int(self.fibaro_device.properties.value) > 0: + if ( + self.fibaro_device.properties.value == "true" + or int(self.fibaro_device.properties.value) > 0 + ): return True return False @@ -432,29 +462,28 @@ 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: + 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: + 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: + 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) + self.fibaro_device.properties.energy, float, 0.0 + ) except (ValueError, KeyError): pass - attr['fibaro_id'] = self.fibaro_device.id + 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 index 44448227a1c24..251bd1df6a3ed 100644 --- a/homeassistant/components/fibaro/binary_sensor.py +++ b/homeassistant/components/fibaro/binary_sensor.py @@ -1,8 +1,7 @@ """Support for Fibaro binary sensors.""" import logging -from homeassistant.components.binary_sensor import ( - ENTITY_ID_FORMAT, BinarySensorDevice) +from homeassistant.components.binary_sensor import DOMAIN, BinarySensorEntity from homeassistant.const import CONF_DEVICE_CLASS, CONF_ICON from . import FIBARO_DEVICES, FibaroDevice @@ -10,13 +9,13 @@ _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'], + "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"], } @@ -26,18 +25,22 @@ def setup_platform(hass, config, add_entities, discovery_info=None): return add_entities( - [FibaroBinarySensor(device) - for device in hass.data[FIBARO_DEVICES]['binary_sensor']], True) + [ + FibaroBinarySensor(device) + for device in hass.data[FIBARO_DEVICES]["binary_sensor"] + ], + True, + ) -class FibaroBinarySensor(FibaroDevice, BinarySensorDevice): +class FibaroBinarySensor(FibaroDevice, BinarySensorEntity): """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) + self.entity_id = f"{DOMAIN}.{self.ha_id}" stype = None devconf = fibaro_device.device_config if fibaro_device.type in SENSOR_TYPES: @@ -51,8 +54,7 @@ def __init__(self, fibaro_device): self._device_class = None self._icon = None # device_config overrides: - self._device_class = devconf.get(CONF_DEVICE_CLASS, - self._device_class) + self._device_class = devconf.get(CONF_DEVICE_CLASS, self._device_class) self._icon = devconf.get(CONF_ICON, self._icon) @property diff --git a/homeassistant/components/fibaro/climate.py b/homeassistant/components/fibaro/climate.py index 4b12a907ce325..191185c4a2a6c 100644 --- a/homeassistant/components/fibaro/climate.py +++ b/homeassistant/components/fibaro/climate.py @@ -1,90 +1,96 @@ """Support for Fibaro thermostats.""" import logging +from homeassistant.components.climate import ClimateEntity 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' + HVAC_MODE_AUTO, + HVAC_MODE_COOL, + HVAC_MODE_DRY, + HVAC_MODE_FAN_ONLY, + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + PRESET_AWAY, + PRESET_BOOST, + SUPPORT_FAN_MODE, + SUPPORT_PRESET_MODE, + SUPPORT_TARGET_TEMPERATURE, +) +from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS, TEMP_FAHRENHEIT + +from . import FIBARO_DEVICES, FibaroDevice + +PRESET_RESUME = "resume" +PRESET_MOIST = "moist" +PRESET_FURNACE = "furnace" +PRESET_CHANGEOVER = "changeover" +PRESET_ECO_HEAT = "eco_heat" +PRESET_ECO_COOL = "eco_cool" +PRESET_FORCE_OPEN = "force_open" _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 + 0: "off", + 1: "low", + 2: "auto_high", + 3: "medium", + 4: "auto_medium", + 5: "high", + 6: "circulation", + 7: "humidity_circulation", + 8: "left_right", + 9: "up_down", + 10: "quiet", + 128: "auto", } +HA_FANMODES = {v: k for k, v in FANMODES.items()} + # 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 +# 4 AUXILIARY +OPMODES_PRESET = { + 5: PRESET_RESUME, + 7: PRESET_FURNACE, + 9: PRESET_MOIST, + 10: PRESET_CHANGEOVER, + 11: PRESET_ECO_HEAT, + 12: PRESET_ECO_COOL, + 13: PRESET_AWAY, + 15: PRESET_BOOST, + 31: PRESET_FORCE_OPEN, } -SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE) +HA_OPMODES_PRESET = {v: k for k, v in OPMODES_PRESET.items()} + +OPMODES_HVAC = { + 0: HVAC_MODE_OFF, + 1: HVAC_MODE_HEAT, + 2: HVAC_MODE_COOL, + 3: HVAC_MODE_AUTO, + 4: HVAC_MODE_HEAT, + 5: HVAC_MODE_AUTO, + 6: HVAC_MODE_FAN_ONLY, + 7: HVAC_MODE_HEAT, + 8: HVAC_MODE_DRY, + 9: HVAC_MODE_DRY, + 10: HVAC_MODE_AUTO, + 11: HVAC_MODE_HEAT, + 12: HVAC_MODE_COOL, + 13: HVAC_MODE_AUTO, + 15: HVAC_MODE_AUTO, + 31: HVAC_MODE_HEAT, +} + +HA_OPMODES_HVAC = { + HVAC_MODE_OFF: 0, + HVAC_MODE_HEAT: 1, + HVAC_MODE_COOL: 2, + HVAC_MODE_AUTO: 3, + HVAC_MODE_FAN_ONLY: 6, +} def setup_platform(hass, config, add_entities, discovery_info=None): @@ -93,11 +99,12 @@ def setup_platform(hass, config, add_entities, discovery_info=None): return add_entities( - [FibaroThermostat(device) - for device in hass.data[FIBARO_DEVICES]['climate']], True) + [FibaroThermostat(device) for device in hass.data[FIBARO_DEVICES]["climate"]], + True, + ) -class FibaroThermostat(FibaroDevice, ClimateDevice): +class FibaroThermostat(FibaroDevice, ClimateEntity): """Representation of a Fibaro Thermostat.""" def __init__(self, fibaro_device): @@ -108,46 +115,46 @@ def __init__(self, fibaro_device): 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' + self.entity_id = f"climate.{self.ha_id}" + self._hvac_support = [] + self._preset_support = [] + self._fan_support = [] + + siblings = fibaro_device.fibaro_controller.get_siblings(fibaro_device.id) + tempunit = "C" for device in siblings: - if device.type == 'com.fibaro.temperatureSensor': + 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: + 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: + 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._support_flags |= SUPPORT_PRESET_MODE + if "setFanMode" in device.actions: self._fan_mode_device = FibaroDevice(device) self._support_flags |= SUPPORT_FAN_MODE - if tempunit == 'F': + 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(",") + 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' + mode = int(mode) + if mode not in FANMODES: + _LOGGER.warning("%d unknown fan mode", mode) + continue + self._fan_support.append(FANMODES[int(mode)]) if self._op_mode_device: prop = self._op_mode_device.fibaro_device.properties @@ -156,37 +163,37 @@ def __init__(self, fibaro_device): 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' + mode = int(mode) + if mode in OPMODES_HVAC: + mode_ha = OPMODES_HVAC[mode] + if mode_ha not in self._hvac_support: + self._hvac_support.append(mode_ha) + if mode in OPMODES_PRESET: + self._preset_support.append(OPMODES_PRESET[mode]) 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") + _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) + self.fibaro_device.id + ) for device in siblings: if device != self.fibaro_device: - self.controller.register(device.id, - self._update_callback) + self.controller.register(device.id, self._update_callback) @property def supported_features(self): @@ -194,58 +201,99 @@ def supported_features(self): return self._support_flags @property - def fan_list(self): + def fan_modes(self): """Return the list of available fan modes.""" - if self._fan_mode_device is None: + if not self._fan_mode_device: return None - return list(self._fan_state_to_mode) + return self._fan_support @property - def current_fan_mode(self): + def fan_mode(self): """Return the fan setting.""" - if self._fan_mode_device is None: + if not self._fan_mode_device: return None - mode = int(self._fan_mode_device.fibaro_device.properties.mode) - return self._fan_mode_to_state[mode] + return FANMODES[mode] def set_fan_mode(self, fan_mode): """Set new target fan mode.""" - if self._fan_mode_device is None: + if not self._fan_mode_device: return - self._fan_mode_device.action( - "setFanMode", self._fan_state_to_mode[fan_mode]) + self._fan_mode_device.action("setFanMode", HA_FANMODES[fan_mode]) @property - def current_operation(self): + def fibaro_op_mode(self): + """Return the operating mode of the device.""" + if not self._op_mode_device: + return 6 # Fan only + + if "operatingMode" in self._op_mode_device.fibaro_device.properties: + return int(self._op_mode_device.fibaro_device.properties.operatingMode) + + return int(self._op_mode_device.fibaro_device.properties.mode) + + @property + def hvac_mode(self): """Return current operation ie. heat, cool, idle.""" - if self._op_mode_device is None: + return OPMODES_HVAC[self.fibaro_op_mode] + + @property + def hvac_modes(self): + """Return the list of available operation modes.""" + if not self._op_mode_device: + return [HVAC_MODE_FAN_ONLY] + return self._hvac_support + + def set_hvac_mode(self, hvac_mode): + """Set new target operation mode.""" + if not self._op_mode_device: + return + if self.preset_mode: + return + + if "setOperatingMode" in self._op_mode_device.fibaro_device.actions: + self._op_mode_device.action("setOperatingMode", HA_OPMODES_HVAC[hvac_mode]) + elif "setMode" in self._op_mode_device.fibaro_device.actions: + self._op_mode_device.action("setMode", HA_OPMODES_HVAC[hvac_mode]) + + @property + def preset_mode(self): + """Return the current preset mode, e.g., home, away, temp. + + Requires SUPPORT_PRESET_MODE. + """ + if not self._op_mode_device: return None if "operatingMode" in self._op_mode_device.fibaro_device.properties: - mode = int(self._op_mode_device.fibaro_device. - properties.operatingMode) + 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) + + if mode not in OPMODES_PRESET: + return None + return OPMODES_PRESET[mode] @property - def operation_list(self): - """Return the list of available operation modes.""" - if self._op_mode_device is None: + def preset_modes(self): + """Return a list of available preset modes. + + Requires SUPPORT_PRESET_MODE. + """ + if not self._op_mode_device: return None - return list(self._op_state_to_mode) + return self._preset_support - def set_operation_mode(self, operation_mode): - """Set new target operation mode.""" + def set_preset_mode(self, preset_mode: str) -> None: + """Set new preset 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]) + "setOperatingMode", HA_OPMODES_PRESET[preset_mode] + ) elif "setMode" in self._op_mode_device.fibaro_device.actions: - self._op_mode_device.action( - "setMode", self._op_state_to_mode[operation_mode]) + self._op_mode_device.action("setMode", HA_OPMODES_PRESET[preset_mode]) @property def temperature_unit(self): @@ -274,16 +322,6 @@ def set_temperature(self, **kwargs): 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) + target.action("setThermostatSetpoint", self.fibaro_op_mode, 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 + target.action("setTargetLevel", temperature) diff --git a/homeassistant/components/fibaro/cover.py b/homeassistant/components/fibaro/cover.py index 0ccbed0144b38..943df5b5681cf 100644 --- a/homeassistant/components/fibaro/cover.py +++ b/homeassistant/components/fibaro/cover.py @@ -2,7 +2,11 @@ import logging from homeassistant.components.cover import ( - ATTR_POSITION, ATTR_TILT_POSITION, ENTITY_ID_FORMAT, CoverDevice) + ATTR_POSITION, + ATTR_TILT_POSITION, + DOMAIN, + CoverEntity, +) from . import FIBARO_DEVICES, FibaroDevice @@ -15,17 +19,17 @@ def setup_platform(hass, config, add_entities, discovery_info=None): return add_entities( - [FibaroCover(device) for - device in hass.data[FIBARO_DEVICES]['cover']], True) + [FibaroCover(device) for device in hass.data[FIBARO_DEVICES]["cover"]], True + ) -class FibaroCover(FibaroDevice, CoverDevice): +class FibaroCover(FibaroDevice, CoverEntity): """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) + self.entity_id = f"{DOMAIN}.{self.ha_id}" @staticmethod def bound(position): diff --git a/homeassistant/components/fibaro/light.py b/homeassistant/components/fibaro/light.py index a741de972f054..f73347cf35695 100644 --- a/homeassistant/components/fibaro/light.py +++ b/homeassistant/components/fibaro/light.py @@ -4,13 +4,19 @@ 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) + ATTR_BRIGHTNESS, + ATTR_HS_COLOR, + ATTR_WHITE_VALUE, + DOMAIN, + SUPPORT_BRIGHTNESS, + SUPPORT_COLOR, + SUPPORT_WHITE_VALUE, + LightEntity, +) 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) +from . import CONF_COLOR, CONF_DIMMING, CONF_RESET_COLOR, FIBARO_DEVICES, FibaroDevice _LOGGER = logging.getLogger(__name__) @@ -32,20 +38,17 @@ def scaleto100(value): return max(0, min(100, ((value * 100.0) / 255.0))) -async def async_setup_platform(hass, - config, - async_add_entities, - discovery_info=None): +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) + [FibaroLight(device) for device in hass.data[FIBARO_DEVICES]["light"]], True + ) -class FibaroLight(FibaroDevice, Light): +class FibaroLight(FibaroDevice, LightEntity): """Representation of a Fibaro Light, including dimmable.""" def __init__(self, fibaro_device): @@ -59,12 +62,13 @@ def __init__(self, fibaro_device): 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 + 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 + # Configuration can override default capability detection if devconf.get(CONF_DIMMING, supports_dimming): self._supported_flags |= SUPPORT_BRIGHTNESS if devconf.get(CONF_COLOR, supports_color): @@ -73,7 +77,7 @@ def __init__(self, fibaro_device): self._supported_flags |= SUPPORT_WHITE_VALUE super().__init__(fibaro_device) - self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id) + self.entity_id = f"{DOMAIN}.{self.ha_id}" @property def brightness(self): @@ -98,8 +102,7 @@ def supported_features(self): 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)) + await self.hass.async_add_executor_job(partial(self._turn_on, **kwargs)) def _turn_on(self, **kwargs): """Really turn the light on.""" @@ -119,10 +122,12 @@ def _turn_on(self, **kwargs): 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: + 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 @@ -133,9 +138,10 @@ def _turn_on(self, **kwargs): 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)) + round(self._white * self._brightness / 100.0), + ) - if self.state == 'off': + if self.state == "off": self.set_level(int(self._brightness)) return @@ -153,14 +159,16 @@ def _turn_on(self, **kwargs): 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)) + 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: + 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() @@ -185,18 +193,17 @@ def _update(self): 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: + 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: + 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)) + 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 index 3574e6254ded3..ff6d881009dfd 100644 --- a/homeassistant/components/fibaro/manifest.json +++ b/homeassistant/components/fibaro/manifest.json @@ -1,10 +1,7 @@ { "domain": "fibaro", "name": "Fibaro", - "documentation": "https://www.home-assistant.io/components/fibaro", - "requirements": [ - "fiblary3==0.1.7" - ], - "dependencies": [], + "documentation": "https://www.home-assistant.io/integrations/fibaro", + "requirements": ["fiblary3==0.1.7"], "codeowners": [] } diff --git a/homeassistant/components/fibaro/scene.py b/homeassistant/components/fibaro/scene.py index f9f96844319b9..714f019168dc9 100644 --- a/homeassistant/components/fibaro/scene.py +++ b/homeassistant/components/fibaro/scene.py @@ -1,5 +1,6 @@ """Support for Fibaro scenes.""" import logging +from typing import Any from homeassistant.components.scene import Scene @@ -8,20 +9,19 @@ _LOGGER = logging.getLogger(__name__) -async def async_setup_platform(hass, config, async_add_entities, - discovery_info=None): +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) + [FibaroScene(scene) for scene in hass.data[FIBARO_DEVICES]["scene"]], True + ) class FibaroScene(FibaroDevice, Scene): """Representation of a Fibaro scene entity.""" - def activate(self): + def activate(self, **kwargs: Any) -> None: """Activate the scene.""" self.fibaro_device.start() diff --git a/homeassistant/components/fibaro/sensor.py b/homeassistant/components/fibaro/sensor.py index db9d103d87eb6..68a39431a98b3 100644 --- a/homeassistant/components/fibaro/sensor.py +++ b/homeassistant/components/fibaro/sensor.py @@ -1,25 +1,41 @@ """Support for Fibaro sensors.""" import logging -from homeassistant.components.sensor import ENTITY_ID_FORMAT +from homeassistant.components.sensor import DOMAIN from homeassistant.const import ( - DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, DEVICE_CLASS_TEMPERATURE, - TEMP_CELSIUS, TEMP_FAHRENHEIT) + CONCENTRATION_PARTS_PER_MILLION, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_ILLUMINANCE, + DEVICE_CLASS_TEMPERATURE, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, + UNIT_PERCENTAGE, +) 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] + "com.fibaro.temperatureSensor": [ + "Temperature", + None, + None, + DEVICE_CLASS_TEMPERATURE, + ], + "com.fibaro.smokeSensor": [ + "Smoke", + CONCENTRATION_PARTS_PER_MILLION, + "mdi:fire", + None, + ], + "CO2": ["CO2", CONCENTRATION_PARTS_PER_MILLION, "mdi:cloud", None], + "com.fibaro.humiditySensor": [ + "Humidity", + UNIT_PERCENTAGE, + None, + DEVICE_CLASS_HUMIDITY, + ], + "com.fibaro.lightSensor": ["Light", "lx", None, DEVICE_CLASS_ILLUMINANCE], } _LOGGER = logging.getLogger(__name__) @@ -31,8 +47,8 @@ def setup_platform(hass, config, add_entities, discovery_info=None): return add_entities( - [FibaroSensor(device) - for device in hass.data[FIBARO_DEVICES]['sensor']], True) + [FibaroSensor(device) for device in hass.data[FIBARO_DEVICES]["sensor"]], True + ) class FibaroSensor(FibaroDevice, Entity): @@ -43,7 +59,7 @@ def __init__(self, fibaro_device): self.current_value = None self.last_changed_time = None super().__init__(fibaro_device) - self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id) + self.entity_id = f"{DOMAIN}.{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] @@ -54,11 +70,11 @@ def __init__(self, fibaro_device): 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': + 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': + elif self.fibaro_device.properties.unit == "F": self._unit = TEMP_FAHRENHEIT else: self._unit = self.fibaro_device.properties.unit diff --git a/homeassistant/components/fibaro/switch.py b/homeassistant/components/fibaro/switch.py index f134b424484de..a38f642775fdf 100644 --- a/homeassistant/components/fibaro/switch.py +++ b/homeassistant/components/fibaro/switch.py @@ -1,7 +1,7 @@ """Support for Fibaro switches.""" import logging -from homeassistant.components.switch import ENTITY_ID_FORMAT, SwitchDevice +from homeassistant.components.switch import DOMAIN, SwitchEntity from homeassistant.util import convert from . import FIBARO_DEVICES, FibaroDevice @@ -15,18 +15,18 @@ def setup_platform(hass, config, add_entities, discovery_info=None): return add_entities( - [FibaroSwitch(device) for - device in hass.data[FIBARO_DEVICES]['switch']], True) + [FibaroSwitch(device) for device in hass.data[FIBARO_DEVICES]["switch"]], True + ) -class FibaroSwitch(FibaroDevice, SwitchDevice): +class FibaroSwitch(FibaroDevice, SwitchEntity): """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) + self.entity_id = f"{DOMAIN}.{self.ha_id}" def turn_on(self, **kwargs): """Turn device on.""" @@ -41,14 +41,14 @@ def turn_off(self, **kwargs): @property def current_power_w(self): """Return the current power usage in W.""" - if 'power' in self.fibaro_device.interfaces: + 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: + if "energy" in self.fibaro_device.interfaces: return convert(self.fibaro_device.properties.energy, float, 0.0) return None diff --git a/homeassistant/components/fido/manifest.json b/homeassistant/components/fido/manifest.json index 343a21ff072fa..9c150d479158f 100644 --- a/homeassistant/components/fido/manifest.json +++ b/homeassistant/components/fido/manifest.json @@ -1,10 +1,7 @@ { "domain": "fido", "name": "Fido", - "documentation": "https://www.home-assistant.io/components/fido", - "requirements": [ - "pyfido==2.1.1" - ], - "dependencies": [], + "documentation": "https://www.home-assistant.io/integrations/fido", + "requirements": ["pyfido==2.1.1"], "codeowners": [] } diff --git a/homeassistant/components/fido/sensor.py b/homeassistant/components/fido/sensor.py index ea66acaf808ec..951d13dadb4df 100644 --- a/homeassistant/components/fido/sensor.py +++ b/homeassistant/components/fido/sensor.py @@ -3,72 +3,73 @@ 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 logging +from pyfido import FidoClient +from pyfido.client import PyFidoError import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_USERNAME, CONF_PASSWORD, - CONF_NAME, CONF_MONITORED_VARIABLES) + CONF_MONITORED_VARIABLES, + CONF_NAME, + CONF_PASSWORD, + CONF_USERNAME, + DATA_KILOBITS, + TIME_MINUTES, +) +import homeassistant.helpers.config_validation as cv 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 +PRICE = "CAD" +MESSAGES = "messages" -DEFAULT_NAME = 'Fido' +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'], + "fido_dollar": ["Fido dollar", PRICE, "mdi:square-inc-cash"], + "balance": ["Balance", PRICE, "mdi:square-inc-cash"], + "data_used": ["Data used", DATA_KILOBITS, "mdi:download"], + "data_limit": ["Data limit", DATA_KILOBITS, "mdi:download"], + "data_remaining": ["Data remaining", DATA_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", TIME_MINUTES, "mdi:cellphone"], + "talk_limit": ["Talk limit", TIME_MINUTES, "mdi:cellphone"], + "talk_remaining": ["Talk remaining", TIME_MINUTES, "mdi:cellphone"], + "other_talk_used": ["Other Talk used", TIME_MINUTES, "mdi:cellphone"], + "other_talk_limit": ["Other Talk limit", TIME_MINUTES, "mdi:cellphone"], + "other_talk_remaining": ["Other Talk remaining", TIME_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, -}) +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): +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) @@ -106,7 +107,7 @@ def __init__(self, fido_data, sensor_type, name, number): @property def name(self): """Return the name of the sensor.""" - return '{} {} {}'.format(self.client_name, self._number, self._name) + return f"{self.client_name} {self._number} {self._name}" @property def state(self): @@ -126,19 +127,16 @@ def icon(self): @property def device_state_attributes(self): """Return the state attributes of the sensor.""" - return { - 'number': self._number, - } + 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.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: + 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) @@ -148,15 +146,14 @@ class FidoData: def __init__(self, username, password, httpsession): """Initialize the data object.""" - from pyfido import FidoClient - self.client = FidoClient(username, password, - REQUESTS_TIMEOUT, httpsession) + + 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: diff --git a/homeassistant/components/file/manifest.json b/homeassistant/components/file/manifest.json index 581b0e1415666..cac7fc98fb1d1 100644 --- a/homeassistant/components/file/manifest.json +++ b/homeassistant/components/file/manifest.json @@ -1,10 +1,6 @@ { "domain": "file", "name": "File", - "documentation": "https://www.home-assistant.io/components/file", - "requirements": [], - "dependencies": [], - "codeowners": [ - "@fabaff" - ] + "documentation": "https://www.home-assistant.io/integrations/file", + "codeowners": ["@fabaff"] } diff --git a/homeassistant/components/file/notify.py b/homeassistant/components/file/notify.py index 07718dcf36c77..528d44bbb838b 100644 --- a/homeassistant/components/file/notify.py +++ b/homeassistant/components/file/notify.py @@ -4,19 +4,24 @@ import voluptuous as vol +from homeassistant.components.notify import ( + ATTR_TITLE, + ATTR_TITLE_DEFAULT, + PLATFORM_SCHEMA, + BaseNotificationService, +) from homeassistant.const import CONF_FILENAME import homeassistant.helpers.config_validation as cv import homeassistant.util.dt as dt_util -from homeassistant.components.notify import ( - ATTR_TITLE, ATTR_TITLE_DEFAULT, PLATFORM_SCHEMA, BaseNotificationService) - -CONF_TIMESTAMP = 'timestamp' +CONF_TIMESTAMP = "timestamp" -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_FILENAME): cv.string, - vol.Optional(CONF_TIMESTAMP, default=False): cv.boolean, -}) +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_FILENAME): cv.string, + vol.Optional(CONF_TIMESTAMP, default=False): cv.boolean, + } +) _LOGGER = logging.getLogger(__name__) @@ -39,16 +44,13 @@ def __init__(self, hass, filename, add_timestamp): def send_message(self, message="", **kwargs): """Send a message to a file.""" - with open(self.filepath, 'a') as 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) + title = f"{kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT)} notifications (Log started: {dt_util.utcnow().isoformat()})\n{'-' * 80}\n" file.write(title) if self.add_timestamp: - text = '{} {}\n'.format(dt_util.utcnow().isoformat(), message) + text = f"{dt_util.utcnow().isoformat()} {message}\n" else: - text = '{}\n'.format(message) + text = f"{message}\n" file.write(text) diff --git a/homeassistant/components/file/sensor.py b/homeassistant/components/file/sensor.py index a618c1e56dc83..3f6d69325f01c 100644 --- a/homeassistant/components/file/sensor.py +++ b/homeassistant/components/file/sensor.py @@ -1,33 +1,33 @@ """Support for sensor value(s) stored in local files.""" -import os import logging +import os 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.const import CONF_NAME, CONF_UNIT_OF_MEASUREMENT, CONF_VALUE_TEMPLATE +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) -CONF_FILE_PATH = 'file_path' +CONF_FILE_PATH = "file_path" -DEFAULT_NAME = 'File' +DEFAULT_NAME = "File" -ICON = 'mdi: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, -}) +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): +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) @@ -38,8 +38,7 @@ async def async_setup_platform(hass, config, async_add_entities, value_template.hass = hass if hass.config.is_allowed_path(file_path): - async_add_entities( - [FileSensor(name, file_path, unit, value_template)], True) + async_add_entities([FileSensor(name, file_path, unit, value_template)], True) else: _LOGGER.error("'%s' is not a whitelisted directory", file_path) @@ -78,18 +77,20 @@ def state(self): 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: + with open(self._file_path, 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)) + 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) + data, None + ) else: self._state = data diff --git a/homeassistant/components/filesize/manifest.json b/homeassistant/components/filesize/manifest.json index f76bcd27466c6..6ef52457eaaf1 100644 --- a/homeassistant/components/filesize/manifest.json +++ b/homeassistant/components/filesize/manifest.json @@ -1,8 +1,6 @@ { "domain": "filesize", - "name": "Filesize", - "documentation": "https://www.home-assistant.io/components/filesize", - "requirements": [], - "dependencies": [], + "name": "File Size", + "documentation": "https://www.home-assistant.io/integrations/filesize", "codeowners": [] } diff --git a/homeassistant/components/filesize/sensor.py b/homeassistant/components/filesize/sensor.py index 3e1394c72d68e..3d96aab04e95a 100644 --- a/homeassistant/components/filesize/sensor.py +++ b/homeassistant/components/filesize/sensor.py @@ -5,20 +5,20 @@ import voluptuous as vol -from homeassistant.helpers.entity import Entity -import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import DATA_MEGABYTES +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) -CONF_FILE_PATHS = 'file_paths' -ICON = 'mdi:file' +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]), -}) +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): @@ -26,11 +26,9 @@ def setup_platform(hass, config, add_entities, discovery_info=None): 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) + _LOGGER.error("Filepath %s is not valid or allowed", path) continue - else: - sensors.append(Filesize(path)) + sensors.append(Filesize(path)) if sensors: add_entities(sensors, True) @@ -41,11 +39,11 @@ class Filesize(Entity): def __init__(self, path): """Initialize the data object.""" - self._path = path # Need to check its a valid path + 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' + self._unit_of_measurement = DATA_MEGABYTES def update(self): """Update the sensor.""" @@ -63,7 +61,7 @@ def name(self): def state(self): """Return the size of the file in MB.""" decimals = 2 - state_mb = round(self._size/1e6, decimals) + state_mb = round(self._size / 1e6, decimals) return state_mb @property @@ -75,10 +73,10 @@ def icon(self): def device_state_attributes(self): """Return other details about the sensor state.""" attr = { - 'path': self._path, - 'last_updated': self._last_updated, - 'bytes': self._size, - } + "path": self._path, + "last_updated": self._last_updated, + "bytes": self._size, + } return attr @property diff --git a/homeassistant/components/filter/manifest.json b/homeassistant/components/filter/manifest.json index 28f061d26f7c5..7b474c2b53a6a 100644 --- a/homeassistant/components/filter/manifest.json +++ b/homeassistant/components/filter/manifest.json @@ -1,10 +1,8 @@ { "domain": "filter", "name": "Filter", - "documentation": "https://www.home-assistant.io/components/filter", - "requirements": [], - "dependencies": [], - "codeowners": [ - "@dgomes" - ] + "documentation": "https://www.home-assistant.io/integrations/filter", + "dependencies": ["history"], + "codeowners": ["@dgomes"], + "quality_scale": "internal" } diff --git a/homeassistant/components/filter/sensor.py b/homeassistant/components/filter/sensor.py index 734caa3127028..7c2a35938b261 100644 --- a/homeassistant/components/filter/sensor.py +++ b/homeassistant/components/filter/sensor.py @@ -1,47 +1,54 @@ """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 collections import Counter, deque from copy import copy from datetime import timedelta +from functools import partial +import logging +from numbers import Number +import statistics +from typing import Optional import voluptuous as vol -from homeassistant.core import callback +from homeassistant.components import history 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) + ATTR_ENTITY_ID, + ATTR_ICON, + ATTR_UNIT_OF_MEASUREMENT, + CONF_ENTITY_ID, + CONF_NAME, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) +from homeassistant.core import callback 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 +from homeassistant.util.decorator import Registry 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' +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' +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' +TIME_SMA_LAST = "last" WINDOW_SIZE_UNIT_NUMBER_EVENTS = 1 WINDOW_SIZE_UNIT_TIME = 2 @@ -52,79 +59,104 @@ 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): +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 = FILTER_SCHEMA.extend( + { + 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]] + 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)]) @@ -143,9 +175,9 @@ def __init__(self, name, entity_id, filters): async def async_added_to_hass(self): """Register callbacks.""" + @callback - def filter_sensor_state_listener(entity, old_state, new_state, - update_ha=True): + 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 @@ -155,76 +187,96 @@ def filter_sensor_state_listener(entity, old_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) + _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) + _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) + 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) + ATTR_UNIT_OF_MEASUREMENT + ) if update_ha: - self.async_schedule_update_ha_state() + self.async_write_ha_state() - if 'recorder' in self.hass.config.components: + 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: + 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: + 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]]) + filter_history = await self.hass.async_add_job( + partial( + history.get_last_state_changes, + self.hass, + largest_window_items, + entity_id=self._entity, + ) + ) + if self._entity in filter_history: + history_list.extend(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]) + filter_history = await self.hass.async_add_job( + partial( + history.state_changes_during_period, + self.hass, + start, + entity_id=self._entity, + ) + ) + if self._entity in filter_history: + 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]) + _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) + 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) + async_track_state_change(self.hass, self._entity, filter_sensor_state_listener) @property def name(self): @@ -254,9 +306,7 @@ def should_poll(self): @property def device_state_attributes(self): """Return the state attributes of the sensor.""" - state_attr = { - ATTR_ENTITY_ID: self._entity - } + state_attr = {ATTR_ENTITY_ID: self._entity} return state_attr @@ -274,7 +324,8 @@ def __init__(self, state): def set_precision(self, precision): """Set precision of Number based states.""" if isinstance(self.state, Number): - self.state = round(float(self.state), precision) + value = round(float(self.state), precision) + self.state = int(value) if precision == 0 else value def __str__(self): """Return state as the string representation of FilterState.""" @@ -282,21 +333,25 @@ def __str__(self): def __repr__(self): """Return timestamp and state as the representation of FilterState.""" - return "{} : {}".format(self.timestamp, self.state) + return f"{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.""" + """Filter skeleton.""" + + def __init__( + self, + name, + window_size: int = 1, + precision: Optional[int] = None, + entity: Optional[str] = None, + ): + """Initialize common attributes. + + :param window_size: size of the sliding window that holds previous values + :param precision: round filtered value to precision value + :param entity: used for debugging only + """ if isinstance(window_size, int): self.states = deque(maxlen=window_size) self.window_unit = WINDOW_SIZE_UNIT_NUMBER_EVENTS @@ -309,6 +364,7 @@ def __init__(self, name, window_size=1, precision=None, entity=None): self._skip_processing = False self._window_size = window_size self._store_raw = False + self._only_numbers = True @property def window_size(self): @@ -322,7 +378,7 @@ def name(self): @property def skip_processing(self): - """Return wether the current filter_state should be skipped.""" + """Return whether the current filter_state should be skipped.""" return self._skip_processing def _filter_state(self, new_state): @@ -331,7 +387,11 @@ def _filter_state(self, new_state): def filter_state(self, new_state): """Implement a common interface for filters.""" - filtered = self._filter_state(FilterState(new_state)) + fstate = FilterState(new_state) + if self._only_numbers and not isinstance(fstate.state, Number): + raise ValueError + + filtered = self._filter_state(fstate) filtered.set_precision(self.precision) if self._store_raw: self.states.append(copy(FilterState(new_state))) @@ -347,40 +407,50 @@ class RangeFilter(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) + def __init__( + self, + entity, + precision: Optional[int] = DEFAULT_PRECISION, + lower_bound: Optional[float] = None, + upper_bound: Optional[float] = None, + ): + """Initialize Filter. + + :param upper_bound: band upper bound + :param lower_bound: band lower bound + """ + super().__init__(FILTER_NAME_RANGE, precision=precision, 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 + if self._upper_bound is not None and new_state.state > self._upper_bound: - _LOGGER.debug("Upper outlier nr. %s in %s: %s", - self._stats_internal['erasures_up'], - self._entity, new_state) + 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): + elif self._lower_bound is not None and new_state.state < self._lower_bound: - self._stats_internal['erasures_low'] += 1 + self._stats_internal["erasures_low"] += 1 - _LOGGER.debug("Lower outlier nr. %s in %s: %s", - self._stats_internal['erasures_low'], - self._entity, new_state) + _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 @@ -391,13 +461,13 @@ 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.""" + def __init__(self, window_size, precision, entity, radius: float): + """Initialize Filter. + + :param radius: band radius + """ super().__init__(FILTER_NAME_OUTLIER, window_size, precision, entity) self._radius = radius self._stats_internal = Counter() @@ -405,43 +475,45 @@ def __init__(self, window_size, precision, entity, radius): 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 + 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 + ): - _LOGGER.debug("Outlier nr. %s in %s: %s", - self._stats_internal['erasures'], - self._entity, new_state) + 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. - """ + """BASIC Low Pass Filter.""" - def __init__(self, window_size, precision, entity, time_constant): + def __init__(self, window_size, precision, entity, time_constant: int): """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 + new_state.state = ( + prev_weight * self.states[-1].state + new_weight * new_state.state + ) return new_state @@ -451,14 +523,15 @@ 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.""" + def __init__( + self, window_size, precision, entity, type + ): # pylint: disable=redefined-builtin + """Initialize Filter. + + :param type: type of algorithm used to connect discrete values + """ super().__init__(FILTER_NAME_TIME_SMA, window_size, precision, entity) self._time_window = window_size self.last_leak = None @@ -474,6 +547,7 @@ def _leak(self, left_boundary): def _filter_state(self, new_state): """Implement the Simple Moving Average filter.""" + self._leak(new_state.timestamp) self.queue.append(copy(new_state)) @@ -481,8 +555,7 @@ def _filter_state(self, new_state): 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 + moving_sum += (state.timestamp - start).total_seconds() * prev_state.state start = state.timestamp prev_state = state @@ -501,6 +574,7 @@ class ThrottleFilter(Filter): def __init__(self, window_size, precision, entity): """Initialize Filter.""" super().__init__(FILTER_NAME_THROTTLE, window_size, precision, entity) + self._only_numbers = False def _filter_state(self, new_state): """Implement the throttle filter.""" @@ -522,10 +596,10 @@ class TimeThrottleFilter(Filter): def __init__(self, window_size, precision, entity): """Initialize Filter.""" - super().__init__(FILTER_NAME_TIME_THROTTLE, - window_size, precision, entity) + super().__init__(FILTER_NAME_TIME_THROTTLE, window_size, precision, entity) self._time_window = window_size self._last_emitted_at = None + self._only_numbers = False def _filter_state(self, new_state): """Implement the filter.""" diff --git a/homeassistant/components/fints/manifest.json b/homeassistant/components/fints/manifest.json index e3580676290b9..4a1a7b8f89d66 100644 --- a/homeassistant/components/fints/manifest.json +++ b/homeassistant/components/fints/manifest.json @@ -1,10 +1,7 @@ { "domain": "fints", - "name": "Fints", - "documentation": "https://www.home-assistant.io/components/fints", - "requirements": [ - "fints==1.0.1" - ], - "dependencies": [], + "name": "FinTS", + "documentation": "https://www.home-assistant.io/integrations/fints", + "requirements": ["fints==1.0.1"], "codeowners": [] } diff --git a/homeassistant/components/fints/sensor.py b/homeassistant/components/fints/sensor.py index cb993ada8dade..d81f353c222f8 100644 --- a/homeassistant/components/fints/sensor.py +++ b/homeassistant/components/fints/sensor.py @@ -3,10 +3,13 @@ from collections import namedtuple from datetime import timedelta import logging + +from fints.client import FinTS3PinTanClient +from fints.dialog import FinTSDialogError import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import CONF_USERNAME, CONF_PIN, CONF_URL, CONF_NAME +from homeassistant.const import CONF_NAME, CONF_PIN, CONF_URL, CONF_USERNAME import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -14,33 +17,37 @@ SCAN_INTERVAL = timedelta(hours=4) -ICON = 'mdi:currency-eur' +ICON = "mdi:currency-eur" -BankCredentials = namedtuple('BankCredentials', 'blz login pin url') +BankCredentials = namedtuple("BankCredentials", "blz login pin url") -CONF_BIN = 'bank_identification_number' -CONF_ACCOUNTS = 'accounts' -CONF_HOLDINGS = 'holdings' -CONF_ACCOUNT = 'account' +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), -}) +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): @@ -49,15 +56,18 @@ def setup_platform(hass, config, add_entities, discovery_info=None): 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]) + 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]} + 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]} + 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() @@ -65,31 +75,29 @@ def setup_platform(hass, config, add_entities, discovery_info=None): 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) + _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) + account_name = f"{fints_name} - {account.iban}" accounts.append(FinTsAccount(client, account, account_name)) - _LOGGER.debug('Creating account %s for bank %s', - account.iban, fints_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) + 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) + account_name = f"{fints_name} - {account.accountnumber}" accounts.append(FinTsHoldingsAccount(client, account, account_name)) - _LOGGER.debug('Creating holdings %s for bank %s', - account.accountnumber, fints_name) + _LOGGER.debug( + "Creating holdings %s for bank %s", account.accountnumber, fints_name + ) add_entities(accounts, True) @@ -113,14 +121,17 @@ def client(self): 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) + 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(): @@ -152,11 +163,11 @@ class FinTsAccount(Entity): def __init__(self, client: FinTsClient, account, name: str) -> None: """Initialize a FinTs balance account.""" - self._client = client # type: FinTsClient + self._client = client self._account = account - self._name = name # type: str - self._balance = None # type: float - self._currency = None # type: str + self._name = name + self._balance: float = None + self._currency: str = None @property def should_poll(self) -> bool: @@ -172,7 +183,7 @@ def update(self) -> None: 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) + _LOGGER.debug("updated balance of account %s", self.name) @property def name(self) -> str: @@ -192,10 +203,7 @@ def unit_of_measurement(self) -> str: @property def device_state_attributes(self) -> dict: """Additional attributes of the sensor.""" - attributes = { - ATTR_ACCOUNT: self._account.iban, - ATTR_ACCOUNT_TYPE: 'balance', - } + attributes = {ATTR_ACCOUNT: self._account.iban, ATTR_ACCOUNT_TYPE: "balance"} if self._client.name: attributes[ATTR_BANK] = self._client.name return attributes @@ -215,11 +223,11 @@ class FinTsHoldingsAccount(Entity): 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._client = client + self._name = name self._account = account self._holdings = [] - self._total = None # type: float + self._total: float = None @property def should_poll(self) -> bool: @@ -253,16 +261,16 @@ def device_state_attributes(self) -> dict: """ attributes = { ATTR_ACCOUNT: self._account.accountnumber, - ATTR_ACCOUNT_TYPE: 'holdings', + 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) + total_name = f"{holding.name} total" attributes[total_name] = holding.total_value - pieces_name = '{} pieces'.format(holding.name) + pieces_name = f"{holding.name} pieces" attributes[pieces_name] = holding.pieces - price_name = '{} price'.format(holding.name) + price_name = f"{holding.name} price" attributes[price_name] = holding.market_value return attributes diff --git a/homeassistant/components/fitbit/manifest.json b/homeassistant/components/fitbit/manifest.json index baf0d8aaed1db..88620785e141b 100644 --- a/homeassistant/components/fitbit/manifest.json +++ b/homeassistant/components/fitbit/manifest.json @@ -1,15 +1,8 @@ { "domain": "fitbit", "name": "Fitbit", - "documentation": "https://www.home-assistant.io/components/fitbit", - "requirements": [ - "fitbit==0.3.0" - ], - "dependencies": [ - "configurator", - "http" - ], - "codeowners": [ - "@robbiet480" - ] + "documentation": "https://www.home-assistant.io/integrations/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 index 889920239edbb..66c283f20ef86 100644 --- a/homeassistant/components/fitbit/sensor.py +++ b/homeassistant/components/fitbit/sensor.py @@ -1,149 +1,166 @@ """Support for the Fitbit API.""" -import os -import logging import datetime +import logging +import os import time +from fitbit import Fitbit +from fitbit.api import FitbitOauth2Client +from oauthlib.oauth2.rfc6749.errors import MismatchingStateError, MissingTokenError 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.const import ( + ATTR_ATTRIBUTION, + CONF_UNIT_SYSTEM, + MASS_KILOGRAMS, + MASS_MILLIGRAMS, + TIME_MILLISECONDS, + TIME_MINUTES, + UNIT_PERCENTAGE, +) +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv 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' +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' +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'] +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' -} +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'] + "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", TIME_MINUTES, "walk"], + "activities/minutesLightlyActive": ["Minutes Lightly Active", TIME_MINUTES, "walk"], + "activities/minutesSedentary": [ + "Minutes Sedentary", + TIME_MINUTES, + "seat-recline-normal", + ], + "activities/minutesVeryActive": ["Minutes Very Active", TIME_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", + TIME_MINUTES, + "walk", + ], + "activities/tracker/minutesLightlyActive": [ + "Tracker Minutes Lightly Active", + TIME_MINUTES, + "walk", + ], + "activities/tracker/minutesSedentary": [ + "Tracker Minutes Sedentary", + TIME_MINUTES, + "seat-recline-normal", + ], + "activities/tracker/minutesVeryActive": [ + "Tracker Minutes Very Active", + TIME_MINUTES, + "run", + ], + "activities/tracker/steps": ["Tracker Steps", "steps", "walk"], + "body/bmi": ["BMI", "BMI", "human"], + "body/fat": ["Body Fat", UNIT_PERCENTAGE, "human"], + "body/weight": ["Weight", "", "human"], + "devices/battery": ["Battery", None, None], + "sleep/awakeningsCount": ["Awakenings Count", "times awaken", "sleep"], + "sleep/efficiency": ["Sleep Efficiency", UNIT_PERCENTAGE, "sleep"], + "sleep/minutesAfterWakeup": ["Minutes After Wakeup", TIME_MINUTES, "sleep"], + "sleep/minutesAsleep": ["Sleep Minutes Asleep", TIME_MINUTES, "sleep"], + "sleep/minutesAwake": ["Sleep Minutes Awake", TIME_MINUTES, "sleep"], + "sleep/minutesToFallAsleep": [ + "Sleep Minutes to Fall Asleep", + TIME_MINUTES, + "sleep", + ], + "sleep/startTime": ["Sleep Start Time", None, "clock"], + "sleep/timeInBed": ["Sleep Time in Bed", TIME_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_US": { + "duration": TIME_MILLISECONDS, + "distance": "mi", + "elevation": "ft", + "height": "in", + "weight": "lbs", + "body": "in", + "liquids": "fl. oz.", + "blood glucose": f"{MASS_MILLIGRAMS}/dL", + "battery": "", }, - 'en_GB': { - 'duration': 'milliseconds', - 'distance': 'kilometers', - 'elevation': 'meters', - 'height': 'centimeters', - 'weight': 'stone', - 'body': 'centimeters', - 'liquids': 'milliliters', - 'blood glucose': 'mmol/L', - 'battery': '', + "en_GB": { + "duration": TIME_MILLISECONDS, + "distance": "kilometers", + "elevation": "meters", + "height": "centimeters", + "weight": "stone", + "body": "centimeters", + "liquids": "milliliters", + "blood glucose": "mmol/L", + "battery": "", + }, + "metric": { + "duration": TIME_MILLISECONDS, + "distance": "kilometers", + "elevation": "meters", + "height": "centimeters", + "weight": MASS_KILOGRAMS, + "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']) -}) +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): +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 @@ -153,33 +170,35 @@ def fitbit_configuration_callback(callback_data): 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) + 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) + start_url = f"{hass.config.api.base_url}{FITBIT_AUTH_CALLBACK_PATH}" - description = """Please create a Fitbit developer app at + description = f"""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 {}. + Set the Callback URL to {start_url}. They will provide you a Client ID and secret. - These need to be saved into the file located at: {}. + These need to be saved into the file located at: {config_path}. 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" + _CONFIGURING["fitbit"] = configurator.request_config( + "Fitbit", + fitbit_configuration_callback, + description=description, + submit_caption=submit, + description_image="/static/images/config_fitbit_app.png", ) @@ -188,21 +207,23 @@ def request_oauth_completion(hass): configurator = hass.components.configurator if "fitbit" in _CONFIGURING: configurator.notify_errors( - _CONFIGURING['fitbit'], "Failed to register, please try again.") + _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) + start_url = f"{hass.config.api.base_url}{FITBIT_AUTH_START}" - description = "Please authorize Fitbit by visiting {}".format(start_url) + description = f"Please authorize Fitbit by visiting {start_url}" - _CONFIGURING['fitbit'] = configurator.request_config( - 'Fitbit', fitbit_configuration_callback, + _CONFIGURING["fitbit"] = configurator.request_config( + "Fitbit", + fitbit_configuration_callback, description=description, - submit_caption="I have authorized Fitbit." + submit_caption="I have authorized Fitbit.", ) @@ -213,42 +234,41 @@ def setup_platform(hass, config, add_entities, discovery_info=None): config_file = load_json(config_path) if config_file == DEFAULT_CONFIG: request_app_setup( - hass, config, add_entities, config_path, discovery_info=None) + 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) + 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) + authd_client = 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 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' + authd_client.system = "metric" else: - authd_client.system = 'en_US' + authd_client.system = "en_US" else: authd_client.system = unit_system @@ -258,33 +278,52 @@ def setup_platform(hass, config, add_entities, discovery_info=None): for resource in config.get(CONF_MONITORED_RESOURCES): # monitor battery for all linked FitBit devices - if resource == 'devices/battery': + 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)) + 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)) + 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)) + oauth = 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) + redirect_uri = f"{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']) + 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)) + hass.http.register_view(FitbitAuthCallbackView(config, add_entities, oauth)) request_oauth_completion(hass) @@ -294,7 +333,7 @@ class FitbitAuthCallbackView(HomeAssistantView): requires_auth = False url = FITBIT_AUTH_CALLBACK_PATH - name = 'api:fitbit:callback' + name = "api:fitbit:callback" def __init__(self, config, add_entities, oauth): """Initialize the OAuth callback view.""" @@ -305,33 +344,28 @@ def __init__(self, config, add_entities, 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'] + 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) + if data.get("code") is not None: + redirect_uri = f"{hass.config.api.base_url}{FITBIT_AUTH_CALLBACK_PATH}" try: - result = self.oauth.fetch_access_token(data.get('code'), - redirect_uri) + 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 + response_message = f"""Something went wrong when attempting authenticating with Fitbit. The error - encountered was {}. Please try again!""".format(error) + encountered was {error}. Please try again!""" except MismatchingStateError as error: _LOGGER.error("Mismatched state, CSRF error: %s", error) - response_message = """Something went wrong when + response_message = f"""Something went wrong when attempting authenticating with Fitbit. The error - encountered was {}. Please try again!""".format(error) + encountered was {error}. Please try again!""" else: _LOGGER.error("Unknown error when authing") response_message = """Something went wrong when @@ -346,21 +380,20 @@ def get(self, request): An unknown error occurred. Please try again! """ - html_response = """Fitbit Auth -

{}

""".format(response_message) + html_response = f"""Fitbit Auth +

{response_message}

""" if result: config_contents = { - ATTR_ACCESS_TOKEN: result.get('access_token'), - ATTR_REFRESH_TOKEN: result.get('refresh_token'), + 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()) + 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) + hass.async_add_job(setup_platform, hass, self.config, self.add_entities) return html_response @@ -368,8 +401,9 @@ def get(self, request): class FitbitSensor(Entity): """Implementation of a Fitbit sensor.""" - def __init__(self, client, config_path, resource_type, - is_metric, clock_format, extra=None): + 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 @@ -379,17 +413,17 @@ def __init__(self, client, config_path, resource_type, self.extra = extra self._name = FITBIT_RESOURCES_LIST[self.resource_type][0] if self.extra: - self._name = '{0} Battery'.format(self.extra.get('deviceVersion')) + self._name = f"{self.extra.get('deviceVersion')} Battery" unit_type = FITBIT_RESOURCES_LIST[self.resource_type][1] if unit_type == "": - split_resource = self.resource_type.split('/') + 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'] + measurement_system = FITBIT_MEASUREMENTS["metric"] else: - measurement_system = FITBIT_MEASUREMENTS['en_US'] + measurement_system = FITBIT_MEASUREMENTS["en_US"] unit_type = measurement_system[split_resource[-1]] self._unit_of_measurement = unit_type self._state = 0 @@ -412,11 +446,10 @@ def unit_of_measurement(self): @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]) + 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 f"mdi:{FITBIT_RESOURCES_LIST[self.resource_type][2]}" @property def device_state_attributes(self): @@ -426,43 +459,42 @@ def device_state_attributes(self): attrs[ATTR_ATTRIBUTION] = ATTRIBUTION if self.extra: - attrs['model'] = self.extra.get('deviceVersion') - attrs['type'] = self.extra.get('type').lower() + 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') + 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(':') + 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' + setting = "AM" if hours > 12: - setting = 'PM' + setting = "PM" hours -= 12 elif hours == 0: hours = 12 - self._state = '{}:{:02d} {}'.format(hours, minutes, - setting) + self._state = f"{hours}:{minutes:02d} {setting}" else: self._state = raw_state else: @@ -470,20 +502,19 @@ def update(self): self._state = raw_state else: try: - self._state = '{0:,}'.format(int(raw_state)) + self._state = f"{int(raw_state):,}" except TypeError: self._state = raw_state - if self.resource_type == 'activities/heart': - self._state = response[container][-1]. \ - get('value').get('restingHeartRate') + 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_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()) + ATTR_LAST_SAVED_AT: int(time.time()), } save_json(self.config_path, config_contents) diff --git a/homeassistant/components/fixer/manifest.json b/homeassistant/components/fixer/manifest.json index 1e010bb06ed0c..6dbeae949f210 100644 --- a/homeassistant/components/fixer/manifest.json +++ b/homeassistant/components/fixer/manifest.json @@ -1,12 +1,7 @@ { "domain": "fixer", "name": "Fixer", - "documentation": "https://www.home-assistant.io/components/fixer", - "requirements": [ - "fixerio==1.0.0a0" - ], - "dependencies": [], - "codeowners": [ - "@fabaff" - ] + "documentation": "https://www.home-assistant.io/integrations/fixer", + "requirements": ["fixerio==1.0.0a0"], + "codeowners": ["@fabaff"] } diff --git a/homeassistant/components/fixer/sensor.py b/homeassistant/components/fixer/sensor.py index 4cf2b0b924326..e3dfd432a416a 100644 --- a/homeassistant/components/fixer/sensor.py +++ b/homeassistant/components/fixer/sensor.py @@ -2,6 +2,8 @@ from datetime import timedelta import logging +from fixerio import Fixerio +from fixerio.exceptions import FixerioException import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA @@ -11,29 +13,30 @@ _LOGGER = logging.getLogger(__name__) -ATTR_EXCHANGE_RATE = 'Exchange rate' -ATTR_TARGET = 'Target currency' +ATTR_EXCHANGE_RATE = "Exchange rate" +ATTR_TARGET = "Target currency" ATTRIBUTION = "Data provided by the European Central Bank (ECB)" -CONF_TARGET = 'target' +CONF_TARGET = "target" -DEFAULT_BASE = 'USD' -DEFAULT_NAME = 'Exchange rate' +DEFAULT_BASE = "USD" +DEFAULT_NAME = "Exchange rate" -ICON = 'mdi:currency-usd' +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, -}) +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) @@ -41,7 +44,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): try: Fixerio(symbols=[target], access_key=api_key).latest() - except exceptions.FixerioException: + except FixerioException: _LOGGER.error("One of the given currencies is not supported") return @@ -80,7 +83,7 @@ def device_state_attributes(self): if self.data.rate is not None: return { ATTR_ATTRIBUTION: ATTRIBUTION, - ATTR_EXCHANGE_RATE: self.data.rate['rates'][self._target], + ATTR_EXCHANGE_RATE: self.data.rate["rates"][self._target], ATTR_TARGET: self._target, } @@ -92,7 +95,7 @@ def icon(self): def update(self): """Get the latest data and updates the states.""" self.data.update() - self._state = round(self.data.rate['rates'][self._target], 3) + self._state = round(self.data.rate["rates"][self._target], 3) class ExchangeData: @@ -100,13 +103,11 @@ class ExchangeData: 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) + self.exchange = Fixerio(symbols=[self.target_currency], access_key=self.api_key) def update(self): """Get the latest data from Fixer.io.""" diff --git a/homeassistant/components/fleetgo/__init__.py b/homeassistant/components/fleetgo/__init__.py new file mode 100644 index 0000000000000..659f30ac443c4 --- /dev/null +++ b/homeassistant/components/fleetgo/__init__.py @@ -0,0 +1 @@ +"""The FleetGO component.""" diff --git a/homeassistant/components/fleetgo/device_tracker.py b/homeassistant/components/fleetgo/device_tracker.py new file mode 100644 index 0000000000000..5a922ed4b9243 --- /dev/null +++ b/homeassistant/components/fleetgo/device_tracker.py @@ -0,0 +1,88 @@ +"""Support for FleetGO Platform.""" +import logging + +import requests +from ritassist import API +import voluptuous as vol + +from homeassistant.components.device_tracker import PLATFORM_SCHEMA +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.event import track_utc_time_change + +_LOGGER = logging.getLogger(__name__) + +CONF_CLIENT_ID = "client_id" +CONF_CLIENT_SECRET = "client_secret" +CONF_INCLUDE = "include" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_CLIENT_ID): cv.string, + vol.Required(CONF_CLIENT_SECRET): cv.string, + vol.Optional(CONF_INCLUDE, default=[]): vol.All(cv.ensure_list, [cv.string]), + } +) + + +def setup_scanner(hass, config: dict, see, discovery_info=None): + """Set up the DeviceScanner and check if login is valid.""" + scanner = FleetGoDeviceScanner(config, see) + if not scanner.login(hass): + _LOGGER.error("FleetGO authentication failed") + return False + return True + + +class FleetGoDeviceScanner: + """Define a scanner for the FleetGO platform.""" + + def __init__(self, config, see): + """Initialize FleetGoDeviceScanner.""" + + self._include = config.get(CONF_INCLUDE) + self._see = see + + self._api = API( + config.get(CONF_CLIENT_ID), + config.get(CONF_CLIENT_SECRET), + config.get(CONF_USERNAME), + config.get(CONF_PASSWORD), + ) + + def setup(self, hass): + """Set up a timer and start gathering devices.""" + self._refresh() + track_utc_time_change( + hass, lambda now: self._refresh(), second=range(0, 60, 30) + ) + + def login(self, hass): + """Perform a login on the FleetGO API.""" + if self._api.login(): + self.setup(hass) + return True + return False + + def _refresh(self) -> None: + """Refresh device information from the platform.""" + try: + devices = self._api.get_devices() + + for device in devices: + if not self._include or device.license_plate in self._include: + + if device.active or device.current_address is None: + device.get_map_details() + + self._see( + dev_id=device.plate_as_id, + gps=(device.latitude, device.longitude), + attributes=device.state_attributes, + icon="mdi:car", + ) + + except requests.exceptions.ConnectionError: + _LOGGER.error("ConnectionError: Could not connect to FleetGO") diff --git a/homeassistant/components/fleetgo/manifest.json b/homeassistant/components/fleetgo/manifest.json new file mode 100644 index 0000000000000..148d79f45c236 --- /dev/null +++ b/homeassistant/components/fleetgo/manifest.json @@ -0,0 +1,7 @@ +{ + "domain": "fleetgo", + "name": "FleetGO", + "documentation": "https://www.home-assistant.io/integrations/fleetgo", + "requirements": ["ritassist==0.9.2"], + "codeowners": [] +} diff --git a/homeassistant/components/flexit/climate.py b/homeassistant/components/flexit/climate.py index d1cf97f047a27..450d09edeb8af 100644 --- a/homeassistant/components/flexit/climate.py +++ b/homeassistant/components/flexit/climate.py @@ -1,35 +1,33 @@ -""" -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/ -""" +"""Platform for Flexit AC units with CI66 Modbus adapter.""" import logging +from typing import List + +from pyflexit.pyflexit import pyflexit 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 import PLATFORM_SCHEMA, ClimateEntity from homeassistant.components.climate.const import ( + HVAC_MODE_COOL, + SUPPORT_FAN_MODE, SUPPORT_TARGET_TEMPERATURE, - SUPPORT_FAN_MODE) -from homeassistant.components.modbus import ( - CONF_HUB, DEFAULT_HUB, DOMAIN as MODBUS_DOMAIN) +) +from homeassistant.components.modbus.const import CONF_HUB, DEFAULT_HUB, MODBUS_DOMAIN +from homeassistant.const import ( + ATTR_TEMPERATURE, + CONF_NAME, + CONF_SLAVE, + DEVICE_DEFAULT_NAME, + TEMP_CELSIUS, +) 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 -}) +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__) @@ -38,18 +36,17 @@ 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) + modbus_slave = config.get(CONF_SLAVE) + name = config.get(CONF_NAME) hub = hass.data[MODBUS_DOMAIN][config.get(CONF_HUB)] add_entities([Flexit(hub, modbus_slave, name)], True) -class Flexit(ClimateDevice): +class Flexit(ClimateEntity): """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 @@ -57,7 +54,7 @@ def __init__(self, hub, modbus_slave, name): self._current_temperature = None self._current_fan_mode = None self._current_operation = None - self._fan_list = ['Off', 'Low', 'Medium', 'High'] + self._fan_modes = ["Off", "Low", "Medium", "High"] self._current_operation = None self._filter_hours = None self._filter_alarm = None @@ -66,7 +63,7 @@ def __init__(self, hub, modbus_slave, name): self._heating = None self._cooling = None self._alarm = False - self.unit = pyflexit.pyflexit(hub, modbus_slave) + self.unit = pyflexit(hub, modbus_slave) @property def supported_features(self): @@ -80,8 +77,7 @@ def update(self): 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._current_fan_mode = self._fan_modes[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 @@ -100,12 +96,12 @@ def update(self): 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 + "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 @@ -134,19 +130,27 @@ def target_temperature(self): return self._target_temperature @property - def current_operation(self): + def hvac_mode(self): """Return current operation ie. heat, cool, idle.""" return self._current_operation @property - def current_fan_mode(self): + def hvac_modes(self) -> List[str]: + """Return the list of available hvac operation modes. + + Need to be a subset of HVAC_MODES. + """ + return [HVAC_MODE_COOL] + + @property + def fan_mode(self): """Return the fan setting.""" return self._current_fan_mode @property - def fan_list(self): + def fan_modes(self): """Return the list of available fan modes.""" - return self._fan_list + return self._fan_modes def set_temperature(self, **kwargs): """Set new target temperature.""" @@ -156,4 +160,4 @@ def set_temperature(self, **kwargs): def set_fan_mode(self, fan_mode): """Set new fan mode.""" - self.unit.set_fan_speed(self._fan_list.index(fan_mode)) + self.unit.set_fan_speed(self._fan_modes.index(fan_mode)) diff --git a/homeassistant/components/flexit/manifest.json b/homeassistant/components/flexit/manifest.json index 0ee0e81143cd6..6c98925ababdf 100644 --- a/homeassistant/components/flexit/manifest.json +++ b/homeassistant/components/flexit/manifest.json @@ -1,12 +1,8 @@ { "domain": "flexit", "name": "Flexit", - "documentation": "https://www.home-assistant.io/components/flexit", - "requirements": [ - "pyflexit==0.3" - ], - "dependencies": [ - "modbus" - ], + "documentation": "https://www.home-assistant.io/integrations/flexit", + "requirements": ["pyflexit==0.3"], + "dependencies": ["modbus"], "codeowners": [] } diff --git a/homeassistant/components/flic/binary_sensor.py b/homeassistant/components/flic/binary_sensor.py index 3381550b5781e..e81f8f2f5b048 100644 --- a/homeassistant/components/flic/binary_sensor.py +++ b/homeassistant/components/flic/binary_sensor.py @@ -2,48 +2,61 @@ import logging import threading +from pyflic import ( + ButtonConnectionChannel, + ClickType, + ConnectionStatus, + FlicClient, + ScanWizard, + ScanWizardResult, +) import voluptuous as vol -import homeassistant.helpers.config_validation as cv +from homeassistant.components.binary_sensor import PLATFORM_SCHEMA, BinarySensorEntity from homeassistant.const import ( - CONF_HOST, CONF_PORT, CONF_DISCOVERY, CONF_TIMEOUT, - EVENT_HOMEASSISTANT_STOP) -from homeassistant.components.binary_sensor import ( - BinarySensorDevice, PLATFORM_SCHEMA) + CONF_DISCOVERY, + CONF_HOST, + CONF_PORT, + CONF_TIMEOUT, + EVENT_HOMEASSISTANT_STOP, +) +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) DEFAULT_TIMEOUT = 3 -CLICK_TYPE_SINGLE = 'single' -CLICK_TYPE_DOUBLE = 'double' -CLICK_TYPE_HOLD = 'hold' +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' +CONF_IGNORED_CLICK_TYPES = "ignored_click_types" -DEFAULT_HOST = 'localhost' +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)]) -}) +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 @@ -52,7 +65,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): discovery = config.get(CONF_DISCOVERY) try: - client = pyflic.FlicClient(host, port) + client = FlicClient(host, port) except ConnectionRefusedError: _LOGGER.error("Failed to connect to flic server") return @@ -65,15 +78,14 @@ def new_button_callback(address): if discovery: start_scanning(config, add_entities, client) - hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, - lambda event: client.close()) + 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 [] + addresses = items["bd_addr_of_verified_buttons"] or [] for address in addresses: setup_button(hass, config, add_entities, client, address) @@ -83,17 +95,16 @@ def get_info_callback(items): 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() + scan_wizard = ScanWizard() def scan_completed_callback(scan_wizard, result, address, name): """Restart scan wizard to constantly check for new buttons.""" - if result == pyflic.ScanWizardResult.WizardSuccess: + if result == ScanWizardResult.WizardSuccess: _LOGGER.info("Found new button %s", address) - elif result != pyflic.ScanWizardResult.WizardFailedTimeout: + elif result != ScanWizardResult.WizardFailedTimeout: _LOGGER.warning( - "Failed to connect to button %s. Reason: %s", address, result) + "Failed to connect to button %s. Reason: %s", address, result + ) # Restart scan wizard start_scanning(config, add_entities, client) @@ -112,12 +123,11 @@ def setup_button(hass, config, add_entities, client, address): add_entities([button]) -class FlicButton(BinarySensorDevice): +class FlicButton(BinarySensorEntity): """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 @@ -125,10 +135,10 @@ def __init__(self, hass, client, address, timeout, ignored_click_types): 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, + ClickType.ButtonClick: CLICK_TYPE_SINGLE, + ClickType.ButtonSingleClick: CLICK_TYPE_SINGLE, + ClickType.ButtonDoubleClick: CLICK_TYPE_DOUBLE, + ClickType.ButtonHold: CLICK_TYPE_HOLD, } self._channel = self._create_channel() @@ -136,9 +146,7 @@ def __init__(self, hass, client, address, timeout, ignored_click_types): def _create_channel(self): """Create a new connection channel to the button.""" - import pyflic - - channel = pyflic.ButtonConnectionChannel(self._address) + channel = ButtonConnectionChannel(self._address) channel.on_button_up_or_down = self._on_up_down # If all types of clicks should be ignored, skip registering callbacks @@ -160,7 +168,7 @@ def _create_channel(self): @property def name(self): """Return the name of the device.""" - return 'flic_{}'.format(self.address.replace(':', '')) + return f"flic_{self.address.replace(':', '')}" @property def address(self): @@ -180,31 +188,34 @@ def should_poll(self): @property def device_state_attributes(self): """Return device specific state attributes.""" - return {'address': self.address} + 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') + time_string = f"{time_diff:d} {'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) + 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) + 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._is_down = click_type == ClickType.ButtonDown self.schedule_update_ha_state() def _on_click(self, channel, click_type, was_queued, time_diff): @@ -218,18 +229,19 @@ def _on_click(self, channel, click_type, was_queued, time_diff): 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): + 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) + if connection_status == 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 index 827bcb167c397..f638908a80f61 100644 --- a/homeassistant/components/flic/manifest.json +++ b/homeassistant/components/flic/manifest.json @@ -1,10 +1,7 @@ { "domain": "flic", "name": "Flic", - "documentation": "https://www.home-assistant.io/components/flic", - "requirements": [ - "pyflic-homeassistant==0.4.dev0" - ], - "dependencies": [], + "documentation": "https://www.home-assistant.io/integrations/flic", + "requirements": ["pyflic-homeassistant==0.4.dev0"], "codeowners": [] } diff --git a/homeassistant/components/flock/manifest.json b/homeassistant/components/flock/manifest.json index a5af541eeeef3..29328cfd1f6c6 100644 --- a/homeassistant/components/flock/manifest.json +++ b/homeassistant/components/flock/manifest.json @@ -1,10 +1,6 @@ { "domain": "flock", "name": "Flock", - "documentation": "https://www.home-assistant.io/components/flock", - "requirements": [], - "dependencies": [], - "codeowners": [ - "@fabaff" - ] + "documentation": "https://www.home-assistant.io/integrations/flock", + "codeowners": ["@fabaff"] } diff --git a/homeassistant/components/flock/notify.py b/homeassistant/components/flock/notify.py index 384bf26599ad2..7bdd1b33c5b2b 100644 --- a/homeassistant/components/flock/notify.py +++ b/homeassistant/components/flock/notify.py @@ -5,53 +5,50 @@ import async_timeout import voluptuous as vol -from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.components.notify import PLATFORM_SCHEMA, BaseNotificationService +from homeassistant.const import CONF_ACCESS_TOKEN, HTTP_OK 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/' +_RESOURCE = "https://api.flock.com/hooks/sendMessage/" -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_ACCESS_TOKEN): cv.string, -}) +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({vol.Required(CONF_ACCESS_TOKEN): cv.string}) -async def get_service(hass, config, discovery_info=None): +async def async_get_service(hass, config, discovery_info=None): """Get the Flock notification service.""" access_token = config.get(CONF_ACCESS_TOKEN) - url = '{}{}'.format(_RESOURCE, access_token) + url = f"{_RESOURCE}{access_token}" session = async_get_clientsession(hass) - return FlockNotificationService(url, session, hass.loop) + return FlockNotificationService(url, session) class FlockNotificationService(BaseNotificationService): """Implement the notification service for Flock.""" - def __init__(self, url, session, loop): + def __init__(self, url, session): """Initialize the Flock notification service.""" - self._loop = loop self._url = url self._session = session async def async_send_message(self, message, **kwargs): """Send the message to the user.""" - payload = {'text': message} + payload = {"text": message} _LOGGER.debug("Attempting to call Flock at %s", self._url) try: - with async_timeout.timeout(10, loop=self._loop): + 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: + if response.status != HTTP_OK or "error" in result: _LOGGER.error( "Flock service returned HTTP status %d, response %s", - response.status, result) + response.status, + result, + ) except asyncio.TimeoutError: _LOGGER.error("Timeout accessing Flock at %s", self._url) diff --git a/homeassistant/components/flume/__init__.py b/homeassistant/components/flume/__init__.py new file mode 100644 index 0000000000000..2c18864194ece --- /dev/null +++ b/homeassistant/components/flume/__init__.py @@ -0,0 +1,99 @@ +"""The flume integration.""" +import asyncio +from functools import partial +import logging + +from pyflume import FlumeAuth, FlumeDeviceList +from requests import Session +from requests.exceptions import RequestException + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import ( + BASE_TOKEN_FILENAME, + CONF_CLIENT_ID, + CONF_CLIENT_SECRET, + DOMAIN, + FLUME_AUTH, + FLUME_DEVICES, + FLUME_HTTP_SESSION, + PLATFORMS, +) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the flume component.""" + hass.data.setdefault(DOMAIN, {}) + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up flume from a config entry.""" + + config = entry.data + + username = config[CONF_USERNAME] + password = config[CONF_PASSWORD] + client_id = config[CONF_CLIENT_ID] + client_secret = config[CONF_CLIENT_SECRET] + flume_token_full_path = hass.config.path(f"{BASE_TOKEN_FILENAME}-{username}") + + http_session = Session() + + try: + flume_auth = await hass.async_add_executor_job( + partial( + FlumeAuth, + username, + password, + client_id, + client_secret, + flume_token_file=flume_token_full_path, + http_session=http_session, + ) + ) + flume_devices = await hass.async_add_executor_job( + partial(FlumeDeviceList, flume_auth, http_session=http_session,) + ) + except RequestException: + raise ConfigEntryNotReady + except Exception as ex: # pylint: disable=broad-except + _LOGGER.error("Invalid credentials for flume: %s", ex) + return False + + hass.data[DOMAIN][entry.entry_id] = { + FLUME_DEVICES: flume_devices, + FLUME_AUTH: flume_auth, + FLUME_HTTP_SESSION: http_session, + } + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ] + ) + ) + + hass.data[DOMAIN][entry.entry_id][FLUME_HTTP_SESSION].close() + + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/flume/config_flow.py b/homeassistant/components/flume/config_flow.py new file mode 100644 index 0000000000000..3232245a4a99b --- /dev/null +++ b/homeassistant/components/flume/config_flow.py @@ -0,0 +1,104 @@ +"""Config flow for flume integration.""" +from functools import partial +import logging + +from pyflume import FlumeAuth, FlumeDeviceList +from requests.exceptions import RequestException +import voluptuous as vol + +from homeassistant import config_entries, core, exceptions +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME + +from .const import BASE_TOKEN_FILENAME, CONF_CLIENT_ID, CONF_CLIENT_SECRET +from .const import DOMAIN # pylint:disable=unused-import + +_LOGGER = logging.getLogger(__name__) + +# If flume ever implements a login page for oauth +# we can use the oauth2 support built into Home Assistant. +# +# Currently they only implement the token endpoint +# +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + vol.Required(CONF_CLIENT_ID): str, + vol.Required(CONF_CLIENT_SECRET): str, + } +) + + +async def validate_input(hass: core.HomeAssistant, data): + """Validate the user input allows us to connect. + + Data has the keys from DATA_SCHEMA with values provided by the user. + """ + username = data[CONF_USERNAME] + password = data[CONF_PASSWORD] + client_id = data[CONF_CLIENT_ID] + client_secret = data[CONF_CLIENT_SECRET] + flume_token_full_path = hass.config.path(f"{BASE_TOKEN_FILENAME}-{username}") + + try: + flume_auth = await hass.async_add_executor_job( + partial( + FlumeAuth, + username, + password, + client_id, + client_secret, + flume_token_file=flume_token_full_path, + ) + ) + flume_devices = await hass.async_add_executor_job(FlumeDeviceList, flume_auth) + except RequestException: + raise CannotConnect + except Exception: # pylint: disable=broad-except + raise InvalidAuth + if not flume_devices or not flume_devices.device_list: + raise CannotConnect + + # Return info that you want to store in the config entry. + return {"title": username} + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for flume.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + if user_input is not None: + await self.async_set_unique_id(user_input[CONF_USERNAME]) + self._abort_if_unique_id_configured() + + try: + info = await validate_input(self.hass, user_input) + return self.async_create_entry(title=info["title"], data=user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + async def async_step_import(self, user_input): + """Handle import.""" + return await self.async_step_user(user_input) + + +class CannotConnect(exceptions.HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(exceptions.HomeAssistantError): + """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/flume/const.py b/homeassistant/components/flume/const.py new file mode 100644 index 0000000000000..17bbb60edb0c9 --- /dev/null +++ b/homeassistant/components/flume/const.py @@ -0,0 +1,24 @@ +"""The Flume component.""" +DOMAIN = "flume" + +PLATFORMS = ["sensor"] + +DEFAULT_NAME = "Flume Sensor" + +CONF_CLIENT_ID = "client_id" +CONF_CLIENT_SECRET = "client_secret" +FLUME_TYPE_SENSOR = 2 + +FLUME_AUTH = "flume_auth" +FLUME_HTTP_SESSION = "http_session" +FLUME_DEVICES = "devices" + + +CONF_TOKEN_FILE = "token_filename" +BASE_TOKEN_FILENAME = "FLUME_TOKEN_FILE" + + +KEY_DEVICE_TYPE = "type" +KEY_DEVICE_ID = "id" +KEY_DEVICE_LOCATION = "location" +KEY_DEVICE_LOCATION_NAME = "name" diff --git a/homeassistant/components/flume/manifest.json b/homeassistant/components/flume/manifest.json new file mode 100644 index 0000000000000..f801eedf73b5d --- /dev/null +++ b/homeassistant/components/flume/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "flume", + "name": "Flume", + "documentation": "https://www.home-assistant.io/integrations/flume/", + "requirements": ["pyflume==0.4.0"], + "dependencies": [], + "codeowners": ["@ChrisMandich", "@bdraco"], + "config_flow": true +} diff --git a/homeassistant/components/flume/sensor.py b/homeassistant/components/flume/sensor.py new file mode 100644 index 0000000000000..21a19a3a56c0c --- /dev/null +++ b/homeassistant/components/flume/sensor.py @@ -0,0 +1,158 @@ +"""Sensor for displaying the number of result from Flume.""" +from datetime import timedelta +import logging + +from pyflume import FlumeData +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle + +from .const import ( + CONF_CLIENT_ID, + CONF_CLIENT_SECRET, + DEFAULT_NAME, + DOMAIN, + FLUME_AUTH, + FLUME_DEVICES, + FLUME_HTTP_SESSION, + FLUME_TYPE_SENSOR, + KEY_DEVICE_ID, + KEY_DEVICE_LOCATION, + KEY_DEVICE_LOCATION_NAME, + KEY_DEVICE_TYPE, +) + +_LOGGER = logging.getLogger(__name__) + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=15) +SCAN_INTERVAL = timedelta(minutes=1) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_CLIENT_ID): cv.string, + vol.Required(CONF_CLIENT_SECRET): cv.string, + vol.Optional(CONF_NAME): cv.string, + } +) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Import the platform into a config entry.""" + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) + ) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Flume sensor.""" + + flume_domain_data = hass.data[DOMAIN][config_entry.entry_id] + + flume_auth = flume_domain_data[FLUME_AUTH] + http_session = flume_domain_data[FLUME_HTTP_SESSION] + flume_devices = flume_domain_data[FLUME_DEVICES] + + config = config_entry.data + name = config.get(CONF_NAME, DEFAULT_NAME) + + flume_entity_list = [] + for device in flume_devices.device_list: + if device[KEY_DEVICE_TYPE] != FLUME_TYPE_SENSOR: + continue + + device_id = device[KEY_DEVICE_ID] + device_name = device[KEY_DEVICE_LOCATION][KEY_DEVICE_LOCATION_NAME] + device_friendly_name = f"{name} {device_name}" + flume_device = FlumeData( + flume_auth, + device_id, + SCAN_INTERVAL, + update_on_init=False, + http_session=http_session, + ) + flume_entity_list.append( + FlumeSensor(flume_device, device_friendly_name, device_id) + ) + + if flume_entity_list: + async_add_entities(flume_entity_list) + + +class FlumeSensor(Entity): + """Representation of the Flume sensor.""" + + def __init__(self, flume_device, name, device_id): + """Initialize the Flume sensor.""" + self._flume_device = flume_device + self._name = name + self._device_id = device_id + self._undo_track_sensor = None + self._available = False + self._state = None + + @property + def device_info(self): + """Device info for the flume sensor.""" + return { + "name": self._name, + "identifiers": {(DOMAIN, self._device_id)}, + "manufacturer": "Flume, Inc.", + "model": "Flume Smart Water Monitor", + } + + @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.""" + # This is in gallons per SCAN_INTERVAL + return "gal/m" + + @property + def available(self): + """Device is available.""" + return self._available + + @property + def unique_id(self): + """Device unique ID.""" + return self._device_id + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Get the latest data and updates the states.""" + _LOGGER.debug("Updating flume sensor: %s", self._name) + try: + self._flume_device.update_force() + except Exception as ex: # pylint: disable=broad-except + if self._available: + _LOGGER.error("Update of flume sensor %s failed: %s", self._name, ex) + self._available = False + return + _LOGGER.debug("Successful update of flume sensor: %s", self._name) + self._state = self._flume_device.value + self._available = True + + async def async_added_to_hass(self): + """Request an update when added.""" + # We do ask for an update with async_add_entities() + # because it will update disabled entities + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/flume/strings.json b/homeassistant/components/flume/strings.json new file mode 100644 index 0000000000000..50fa03f3e93a9 --- /dev/null +++ b/homeassistant/components/flume/strings.json @@ -0,0 +1,22 @@ +{ + "config": { + "error": { + "unknown": "Unexpected error", + "invalid_auth": "Invalid authentication", + "cannot_connect": "Failed to connect, please try again" + }, + "step": { + "user": { + "description": "In order to access the Flume Personal API, you will need to request a 'Client ID' and 'Client Secret' at https://portal.flumetech.com/settings#token", + "title": "Connect to your Flume Account", + "data": { + "username": "Username", + "client_secret": "Client Secret", + "client_id": "Client ID", + "password": "Password" + } + } + }, + "abort": { "already_configured": "This account is already configured" } + } +} diff --git a/homeassistant/components/flume/translations/ca.json b/homeassistant/components/flume/translations/ca.json new file mode 100644 index 0000000000000..71ee4fd2345e1 --- /dev/null +++ b/homeassistant/components/flume/translations/ca.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "Aquest compte ja est\u00e0 configurat" + }, + "error": { + "cannot_connect": "No s'ha pogut connectar, torna-ho a provar", + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", + "unknown": "Error inesperat" + }, + "step": { + "user": { + "data": { + "client_id": "ID de client", + "client_secret": "Secret de client", + "password": "Contrasenya", + "username": "Nom d'usuari" + }, + "description": "Per poder accedir a l'API personal de Flume, has de sol\u00b7licitar un 'ID de client' i un 'secret de client' anant a https://portal.flumetech.com/settings#token", + "title": "Connexi\u00f3 amb Flume" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flume/translations/de.json b/homeassistant/components/flume/translations/de.json new file mode 100644 index 0000000000000..ecc57551a1f21 --- /dev/null +++ b/homeassistant/components/flume/translations/de.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Dieses Konto ist bereits konfiguriert" + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen, versuchen Sie es erneut", + "invalid_auth": "Ung\u00fcltige Authentifizierung", + "unknown": "Unerwarteter Fehler" + }, + "step": { + "user": { + "data": { + "password": "Passwort", + "username": "Benutzername" + }, + "description": "Um auf die Flume Personal API zugreifen zu k\u00f6nnen, m\u00fcssen Sie unter https://portal.flumetech.com/settings#token eine 'Client ID' und 'Client Secret' anfordern", + "title": "Stellen Sie eine Verbindung zu Ihrem Flume-Konto her" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flume/translations/en.json b/homeassistant/components/flume/translations/en.json new file mode 100644 index 0000000000000..ed24c552d8ffb --- /dev/null +++ b/homeassistant/components/flume/translations/en.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "This account is already configured" + }, + "error": { + "cannot_connect": "Failed to connect, please try again", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "client_id": "Client ID", + "client_secret": "Client Secret", + "password": "Password", + "username": "Username" + }, + "description": "In order to access the Flume Personal API, you will need to request a 'Client ID' and 'Client Secret' at https://portal.flumetech.com/settings#token", + "title": "Connect to your Flume Account" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flume/translations/es.json b/homeassistant/components/flume/translations/es.json new file mode 100644 index 0000000000000..cf872da6de367 --- /dev/null +++ b/homeassistant/components/flume/translations/es.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "Esta cuenta ya est\u00e1 configurada" + }, + "error": { + "cannot_connect": "No se pudo conectar, por favor, int\u00e9ntalo de nuevo", + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", + "unknown": "Error inesperado" + }, + "step": { + "user": { + "data": { + "client_id": "Client ID", + "client_secret": "Client Secret", + "password": "Contrase\u00f1a", + "username": "Usuario" + }, + "description": "Para acceder a la API Personal de Flume, tendr\u00e1s que solicitar un 'Client ID' y un 'Client Secret' en https://portal.flumetech.com/settings#token", + "title": "Conectar con tu cuenta de Flume" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flume/translations/fr.json b/homeassistant/components/flume/translations/fr.json new file mode 100644 index 0000000000000..a1641a24fc70a --- /dev/null +++ b/homeassistant/components/flume/translations/fr.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Ce compte est d\u00e9j\u00e0 configur\u00e9." + }, + "error": { + "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer", + "invalid_auth": "Authentification non valide", + "unknown": "Erreur inattendue" + }, + "step": { + "user": { + "data": { + "password": "Mot de passe", + "username": "Nom d'utilisateur" + }, + "description": "Pour acc\u00e9der \u00e0 l'API personnel Flume, vous devez demander un \"Client ID\" et un \"Client Secret\" \u00e0 l'adresse https://portal.flumetech.com/settings#token", + "title": "Se connecter \u00e0 votre compte Flume" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flume/translations/it.json b/homeassistant/components/flume/translations/it.json new file mode 100644 index 0000000000000..6d9974f9481f9 --- /dev/null +++ b/homeassistant/components/flume/translations/it.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "Questo account \u00e8 gi\u00e0 configurato." + }, + "error": { + "cannot_connect": "Impossibile connettersi, si prega di riprovare", + "invalid_auth": "Autenticazione non valida", + "unknown": "Errore imprevisto" + }, + "step": { + "user": { + "data": { + "client_id": "Client ID", + "client_secret": "Client Secret", + "password": "Password", + "username": "Nome utente" + }, + "description": "Per accedere all'API personale di Flume, \u00e8 necessario richiedere un \"Client ID\" e un \"Client Secret\" all'indirizzo https://portal.flumetech.com/settings#token.", + "title": "Collegati al tuo account Flume" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flume/translations/ko.json b/homeassistant/components/flume/translations/ko.json new file mode 100644 index 0000000000000..faac5e9c579d3 --- /dev/null +++ b/homeassistant/components/flume/translations/ko.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "\uc774 \uacc4\uc815\uc740 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + }, + "error": { + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.", + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" + }, + "step": { + "user": { + "data": { + "client_id": "\ud074\ub77c\uc774\uc5b8\ud2b8 ID", + "client_secret": "\ud074\ub77c\uc774\uc5b8\ud2b8 \uc2dc\ud06c\ub9bf", + "password": "\ube44\ubc00\ubc88\ud638", + "username": "\uc0ac\uc6a9\uc790 \uc774\ub984" + }, + "description": "Flume Personal API \uc5d0 \uc561\uc138\uc2a4 \ud558\ub824\uba74 https://portal.flumetech.com/settings#token \uc5d0\uc11c '\ud074\ub77c\uc774\uc5b8\ud2b8 ID'\ubc0f '\ud074\ub77c\uc774\uc5b8\ud2b8 \uc2dc\ud06c\ub9bf'\uc744 \uc694\uccad\ud574\uc57c \ud569\ub2c8\ub2e4.", + "title": "Flume \uacc4\uc815\uc5d0 \uc5f0\uacb0\ud558\uae30" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flume/translations/lb.json b/homeassistant/components/flume/translations/lb.json new file mode 100644 index 0000000000000..67f18d6154730 --- /dev/null +++ b/homeassistant/components/flume/translations/lb.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "D\u00ebse Kont ass scho konfigur\u00e9iert" + }, + "error": { + "cannot_connect": "Feeler beim verbannen, prob\u00e9ier w.e.g. nach emol.", + "invalid_auth": "Ong\u00eblteg Authentifikatioun", + "unknown": "Onerwaarte Feeler" + }, + "step": { + "user": { + "data": { + "client_id": "Client ID", + "client_secret": "Client Schl\u00ebssel", + "password": "Passwuert", + "username": "Benotzernumm" + }, + "description": "Fir k\u00ebnnen op Flume Personal API z'acc\u00e9d\u00e9ieren muss du eng 'Client ID' an eng 'Client Secret' op https://portal.flumetech.com/settings#token ufroen.", + "title": "Verbann dech mat dengem Flume Kont." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flume/translations/nl.json b/homeassistant/components/flume/translations/nl.json new file mode 100644 index 0000000000000..d176eb133656c --- /dev/null +++ b/homeassistant/components/flume/translations/nl.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "Dit account is al geconfigureerd." + }, + "error": { + "cannot_connect": "Verbinding mislukt, probeer het opnieuw", + "invalid_auth": "Ongeldige authenticatie", + "unknown": "Onverwachte fout" + }, + "step": { + "user": { + "data": { + "client_id": "Client-id", + "client_secret": "Client Secret", + "password": "Wachtwoord", + "username": "Gebruikersnaam" + }, + "description": "Om toegang te krijgen tot de Flume Personal API, moet je een 'Client ID' en 'Client Secret' aanvragen op https://portal.flumetech.com/settings#token", + "title": "Verbind met uw Flume account" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flume/translations/no.json b/homeassistant/components/flume/translations/no.json new file mode 100644 index 0000000000000..1440d3d047743 --- /dev/null +++ b/homeassistant/components/flume/translations/no.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "Denne kontoen er allerede konfigurert" + }, + "error": { + "cannot_connect": "Klarte ikke \u00e5 koble til, vennligst pr\u00f8v igjen", + "invalid_auth": "Ugyldig godkjenning", + "unknown": "Uventet feil" + }, + "step": { + "user": { + "data": { + "client_id": "klient-ID", + "client_secret": "Klienthemmelighet", + "password": "Passord", + "username": "Brukernavn" + }, + "description": "For \u00e5 f\u00e5 tilgang til Flume Personal API, m\u00e5 du be om en \"Klient-ID\" og \"Client Secret\" p\u00e5 https://portal.flumetech.com/settings#token", + "title": "Koble til Flume-kontoen din" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flume/translations/pl.json b/homeassistant/components/flume/translations/pl.json new file mode 100644 index 0000000000000..55dfceac11f98 --- /dev/null +++ b/homeassistant/components/flume/translations/pl.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "To konto jest ju\u017c skonfigurowane." + }, + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia, spr\u00f3buj ponownie.", + "invalid_auth": "Niepoprawne uwierzytelnienie.", + "unknown": "Niespodziewany b\u0142\u0105d." + }, + "step": { + "user": { + "data": { + "client_id": "Identyfikator klienta", + "client_secret": "Has\u0142o klienta", + "password": "Has\u0142o", + "username": "Nazwa u\u017cytkownika" + }, + "description": "Aby uzyska\u0107 dost\u0119p do API Flume, musisz poprosi\u0107 o 'ID klienta\u201d i 'klucz tajny klienta' na stronie https://portal.flumetech.com/settings#token", + "title": "Po\u0142\u0105cz z kontem Flume" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flume/translations/pt.json b/homeassistant/components/flume/translations/pt.json new file mode 100644 index 0000000000000..b46423599731a --- /dev/null +++ b/homeassistant/components/flume/translations/pt.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "Palavra-passe", + "username": "Nome de Utilizador" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flume/translations/ru.json b/homeassistant/components/flume/translations/ru.json new file mode 100644 index 0000000000000..eb9b1261bd9d6 --- /dev/null +++ b/homeassistant/components/flume/translations/ru.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "\u0423\u0447\u0451\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0430." + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f, \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0435\u0449\u0435 \u0440\u0430\u0437.", + "invalid_auth": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "step": { + "user": { + "data": { + "client_id": "ID \u043a\u043b\u0438\u0435\u043d\u0442\u0430", + "client_secret": "\u0421\u0435\u043a\u0440\u0435\u0442 \u043a\u043b\u0438\u0435\u043d\u0442\u0430", + "password": "\u041f\u0430\u0440\u043e\u043b\u044c", + "username": "\u041b\u043e\u0433\u0438\u043d" + }, + "description": "\u0427\u0442\u043e\u0431\u044b \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u0434\u043e\u0441\u0442\u0443\u043f \u043a \u043f\u0435\u0440\u0441\u043e\u043d\u0430\u043b\u044c\u043d\u043e\u043c\u0443 API Flume, \u043d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c 'ID \u043a\u043b\u0438\u0435\u043d\u0442\u0430' \u0438 '\u0421\u0435\u043a\u0440\u0435\u0442 \u043a\u043b\u0438\u0435\u043d\u0442\u0430' \u043f\u043e \u0430\u0434\u0440\u0435\u0441\u0443 https://portal.flumetech.com/settings#token.", + "title": "Flume" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flume/translations/sl.json b/homeassistant/components/flume/translations/sl.json new file mode 100644 index 0000000000000..9673cb6c96017 --- /dev/null +++ b/homeassistant/components/flume/translations/sl.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "Ta ra\u010dun je \u017ee konfiguriran" + }, + "error": { + "cannot_connect": "Povezava ni uspela, poskusite znova", + "invalid_auth": "Neveljavna avtentikacija", + "unknown": "Nepri\u010dakovana napaka" + }, + "step": { + "user": { + "data": { + "client_id": "Client ID", + "client_secret": "Client Secret", + "password": "Geslo", + "username": "Uporabni\u0161ko ime" + }, + "description": "\u010ce \u017eelite dostopati do osebnega API-ja Flume, boste morali na https://portal.flumetech.com/settings#token zahtevati \"Client ID\" in \"Client Secret\".", + "title": "Pove\u017eite se s svojim ra\u010dunom Flume" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flume/translations/sv.json b/homeassistant/components/flume/translations/sv.json new file mode 100644 index 0000000000000..7bff51a3c831a --- /dev/null +++ b/homeassistant/components/flume/translations/sv.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Det h\u00e4r kontot har redan konfigurerats." + }, + "error": { + "cannot_connect": "Det gick inte att ansluta, f\u00f6rs\u00f6k igen", + "invalid_auth": "Ogiltig autentisering", + "unknown": "Ov\u00e4ntat fel" + }, + "step": { + "user": { + "data": { + "client_id": "Klient ID", + "password": "L\u00f6senord", + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flume/translations/zh-Hant.json b/homeassistant/components/flume/translations/zh-Hant.json new file mode 100644 index 0000000000000..cc7dea80e5224 --- /dev/null +++ b/homeassistant/components/flume/translations/zh-Hant.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "\u6b64\u5e33\u865f\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557\uff0c\u8acb\u518d\u8a66\u4e00\u6b21", + "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "step": { + "user": { + "data": { + "client_id": "\u5ba2\u6236\u7aef ID", + "client_secret": "\u5ba2\u6236\u7aef\u5bc6\u9470", + "password": "\u5bc6\u78bc", + "username": "\u4f7f\u7528\u8005\u540d\u7a31" + }, + "description": "\u6b32\u5b58\u53d6 Flume \u500b\u4eba API\u3001\u5c07\u9700\u8981\u65bc https://portal.flumetech.com/settings#token \u7372\u5f97\u5ba2\u6236\u7aef ID\uff08Client ID\u300f\u53ca\u5ba2\u6236\u7aef\u5bc6\u9470\uff08Client Secret\uff09", + "title": "\u9023\u7dda\u81f3 Flume \u5e33\u865f" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flunearyou/__init__.py b/homeassistant/components/flunearyou/__init__.py index 5657e646be509..6e1c8ddb3d2da 100644 --- a/homeassistant/components/flunearyou/__init__.py +++ b/homeassistant/components/flunearyou/__init__.py @@ -1 +1,209 @@ """The flunearyou component.""" +import asyncio +from datetime import timedelta + +from pyflunearyou import Client +from pyflunearyou.errors import FluNearYouError +import voluptuous as vol + +from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.core import callback +from homeassistant.helpers import aiohttp_client, config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import async_track_time_interval + +from .const import ( + CATEGORY_CDC_REPORT, + CATEGORY_USER_REPORT, + DATA_CLIENT, + DOMAIN, + LOGGER, + SENSORS, + TOPIC_UPDATE, +) + +DATA_LISTENER = "listener" + +DEFAULT_SCAN_INTERVAL = timedelta(minutes=30) + +CONFIG_SCHEMA = vol.Schema( + { + vol.Optional(DOMAIN): vol.Schema( + { + vol.Optional(CONF_LATITUDE): cv.latitude, + vol.Optional(CONF_LONGITUDE): cv.longitude, + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +@callback +def async_get_api_category(sensor_type): + """Get the category that a particular sensor type belongs to.""" + try: + return next( + ( + category + for category, sensors in SENSORS.items() + for sensor in sensors + if sensor[0] == sensor_type + ) + ) + except StopIteration: + raise ValueError(f"Can't find category sensor type: {sensor_type}") + + +async def async_setup(hass, config): + """Set up the Flu Near You component.""" + hass.data[DOMAIN] = {DATA_CLIENT: {}, DATA_LISTENER: {}} + + if DOMAIN not in config: + return True + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_LATITUDE: config[DOMAIN].get(CONF_LATITUDE, hass.config.latitude), + CONF_LONGITUDE: config[DOMAIN].get( + CONF_LATITUDE, hass.config.longitude + ), + }, + ) + ) + + return True + + +async def async_setup_entry(hass, config_entry): + """Set up Flu Near You as config entry.""" + websession = aiohttp_client.async_get_clientsession(hass) + + fny = FluNearYouData( + hass, + Client(websession), + config_entry.data.get(CONF_LATITUDE, hass.config.latitude), + config_entry.data.get(CONF_LONGITUDE, hass.config.longitude), + ) + await fny.async_update() + hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id] = fny + + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(config_entry, "sensor") + ) + + async def refresh(event_time): + """Refresh data from Flu Near You.""" + await fny.async_update() + + hass.data[DOMAIN][DATA_LISTENER][config_entry.entry_id] = async_track_time_interval( + hass, refresh, DEFAULT_SCAN_INTERVAL + ) + + return True + + +async def async_unload_entry(hass, config_entry): + """Unload an Flu Near You config entry.""" + hass.data[DOMAIN][DATA_CLIENT].pop(config_entry.entry_id) + + remove_listener = hass.data[DOMAIN][DATA_LISTENER].pop(config_entry.entry_id) + remove_listener() + + await hass.config_entries.async_forward_entry_unload(config_entry, "sensor") + + return True + + +class FluNearYouData: + """Define a data object to retrieve info from Flu Near You.""" + + def __init__(self, hass, client, latitude, longitude): + """Initialize.""" + self._async_cancel_time_interval_listener = None + self._client = client + self._hass = hass + self.data = {} + self.latitude = latitude + self.longitude = longitude + + self._api_category_count = { + CATEGORY_CDC_REPORT: 0, + CATEGORY_USER_REPORT: 0, + } + + self._api_category_locks = { + CATEGORY_CDC_REPORT: asyncio.Lock(), + CATEGORY_USER_REPORT: asyncio.Lock(), + } + + async def _async_get_data_from_api(self, api_category): + """Update and save data for a particular API category.""" + if self._api_category_count[api_category] == 0: + return + + if api_category == CATEGORY_CDC_REPORT: + api_coro = self._client.cdc_reports.status_by_coordinates( + self.latitude, self.longitude + ) + else: + api_coro = self._client.user_reports.status_by_coordinates( + self.latitude, self.longitude + ) + + try: + self.data[api_category] = await api_coro + except FluNearYouError as err: + LOGGER.error("Unable to get %s data: %s", api_category, err) + self.data[api_category] = None + + async def _async_update_listener_action(self, now): + """Define an async_track_time_interval action to update data.""" + await self.async_update() + + @callback + def async_deregister_api_interest(self, sensor_type): + """Decrement the number of entities with data needs from an API category.""" + # If this deregistration should leave us with no registration at all, remove the + # time interval: + if sum(self._api_category_count.values()) == 0: + if self._async_cancel_time_interval_listener: + self._async_cancel_time_interval_listener() + self._async_cancel_time_interval_listener = None + return + + api_category = async_get_api_category(sensor_type) + self._api_category_count[api_category] -= 1 + + async def async_register_api_interest(self, sensor_type): + """Increment the number of entities with data needs from an API category.""" + # If this is the first registration we have, start a time interval: + if not self._async_cancel_time_interval_listener: + self._async_cancel_time_interval_listener = async_track_time_interval( + self._hass, self._async_update_listener_action, DEFAULT_SCAN_INTERVAL, + ) + + api_category = async_get_api_category(sensor_type) + self._api_category_count[api_category] += 1 + + # If a sensor registers interest in a particular API call and the data doesn't + # exist for it yet, make the API call and grab the data: + async with self._api_category_locks[api_category]: + if api_category not in self.data: + await self._async_get_data_from_api(api_category) + + async def async_update(self): + """Update Flu Near You data.""" + tasks = [ + self._async_get_data_from_api(api_category) + for api_category in self._api_category_count + ] + + await asyncio.gather(*tasks) + + LOGGER.debug("Received new data") + async_dispatcher_send(self._hass, TOPIC_UPDATE) diff --git a/homeassistant/components/flunearyou/config_flow.py b/homeassistant/components/flunearyou/config_flow.py new file mode 100644 index 0000000000000..0a55a21f6edac --- /dev/null +++ b/homeassistant/components/flunearyou/config_flow.py @@ -0,0 +1,60 @@ +"""Define a config flow manager for flunearyou.""" +from pyflunearyou import Client +from pyflunearyou.errors import FluNearYouError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.helpers import aiohttp_client, config_validation as cv + +from .const import DOMAIN, LOGGER # pylint: disable=unused-import + + +class FluNearYouFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle an FluNearYou config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + @property + def data_schema(self): + """Return the data schema for integration.""" + return vol.Schema( + { + vol.Required( + CONF_LATITUDE, default=self.hass.config.latitude + ): cv.latitude, + vol.Required( + CONF_LONGITUDE, default=self.hass.config.longitude + ): cv.longitude, + } + ) + + async def async_step_import(self, import_config): + """Import a config entry from configuration.yaml.""" + return await self.async_step_user(import_config) + + async def async_step_user(self, user_input=None): + """Handle the start of the config flow.""" + if not user_input: + return self.async_show_form(step_id="user", data_schema=self.data_schema) + + unique_id = f"{user_input[CONF_LATITUDE]}, {user_input[CONF_LONGITUDE]}" + + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + + websession = aiohttp_client.async_get_clientsession(self.hass) + client = Client(websession) + + try: + await client.cdc_reports.status_by_coordinates( + user_input[CONF_LATITUDE], user_input[CONF_LONGITUDE] + ) + except FluNearYouError as err: + LOGGER.error("Error while configuring integration: %s", err) + return self.async_show_form( + step_id="user", errors={"base": "general_error"} + ) + + return self.async_create_entry(title=unique_id, data=user_input) diff --git a/homeassistant/components/flunearyou/const.py b/homeassistant/components/flunearyou/const.py new file mode 100644 index 0000000000000..ac8008f7f9bec --- /dev/null +++ b/homeassistant/components/flunearyou/const.py @@ -0,0 +1,38 @@ +"""Define flunearyou constants.""" +import logging + +DOMAIN = "flunearyou" +LOGGER = logging.getLogger(__package__) + +DATA_CLIENT = "client" + +CATEGORY_CDC_REPORT = "cdc_report" +CATEGORY_USER_REPORT = "user_report" + +TOPIC_UPDATE = "flunearyou_update" + +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" + +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"), + ], +} diff --git a/homeassistant/components/flunearyou/manifest.json b/homeassistant/components/flunearyou/manifest.json index 76053f7508173..f6cc6714a38a1 100644 --- a/homeassistant/components/flunearyou/manifest.json +++ b/homeassistant/components/flunearyou/manifest.json @@ -1,12 +1,8 @@ { "domain": "flunearyou", - "name": "Flunearyou", - "documentation": "https://www.home-assistant.io/components/flunearyou", - "requirements": [ - "pyflunearyou==1.0.3" - ], - "dependencies": [], - "codeowners": [ - "@bachya" - ] + "name": "Flu Near You", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/flunearyou", + "requirements": ["pyflunearyou==1.0.7"], + "codeowners": ["@bachya"] } diff --git a/homeassistant/components/flunearyou/sensor.py b/homeassistant/components/flunearyou/sensor.py index 148a3ee41592d..22c56c100389b 100644 --- a/homeassistant/components/flunearyou/sensor.py +++ b/homeassistant/components/flunearyou/sensor.py @@ -1,118 +1,74 @@ """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.const import ATTR_ATTRIBUTION, ATTR_STATE +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect 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' +from .const import ( + CATEGORY_CDC_REPORT, + CATEGORY_USER_REPORT, + DATA_CLIENT, + DOMAIN, + SENSORS, + TOPIC_UPDATE, + TYPE_USER_CHICK, + TYPE_USER_DENGUE, + TYPE_USER_FLU, + TYPE_USER_LEPTO, + TYPE_USER_NO_SYMPTOMS, + TYPE_USER_SYMPTOMS, + TYPE_USER_TOTAL, +) + +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" EXTENDED_TYPE_MAPPING = { - TYPE_USER_FLU: 'ili', - TYPE_USER_NO_SYMPTOMS: 'no_symptoms', - TYPE_USER_TOTAL: 'total_surveys', + 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 +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Flu Near You sensors based on a config entry.""" + fny = hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id] - 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) + async_add_entities( + [ + FluNearYouSensor(fny, sensor_type, name, category, icon, unit) + for category, sensors in SENSORS.items() + for sensor_type, name, icon, unit in sensors + ], + True, + ) class FluNearYouSensor(Entity): """Define a base Flu Near You sensor.""" - def __init__(self, fny, kind, name, category, icon, unit): + def __init__(self, fny, sensor_type, name, category, icon, unit): """Initialize the sensor.""" self._attrs = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} self._category = category + self._fny = fny self._icon = icon - self._kind = kind self._name = name + self._sensor_type = sensor_type 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]) + return bool(self._fny.data[self._category]) @property def device_state_attributes(self): @@ -136,83 +92,80 @@ def state(self): @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) + """Return a unique, Home Assistant friendly identifier for this entity.""" + return f"{self._fny.latitude},{self._fny.longitude}_{self._sensor_type}" @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() + async def async_added_to_hass(self): + """Register callbacks.""" - cdc_data = self.fny.data.get(CATEGORY_CDC_REPORT) - user_data = self.fny.data.get(CATEGORY_USER_REPORT) + @callback + def update(): + """Update the state.""" + self.update_from_latest_data() + self.async_write_ha_state() + + self.async_on_remove(async_dispatcher_connect(self.hass, TOPIC_UPDATE, update)) + await self._fny.async_register_api_interest(self._sensor_type) + self.update_from_latest_data() + + async def async_will_remove_from_hass(self) -> None: + """Disconnect dispatcher listener when removed.""" + self._fny.async_deregister_api_interest(self._sensor_type) + + @callback + def update_from_latest_data(self): + """Update the sensor.""" + 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] + self._attrs.update( + { + ATTR_REPORTED_DATE: cdc_data["week_date"], + ATTR_STATE: cdc_data["name"], + } + ) + self._state = cdc_data[self._sensor_type] 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._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._sensor_type in user_data["state"]["data"]: + states_key = self._sensor_type + elif self._sensor_type in EXTENDED_TYPE_MAPPING: + states_key = EXTENDED_TYPE_MAPPING[self._sensor_type] + + 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._sensor_type == 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)) + 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) + self._state = user_data["local"][self._sensor_type] diff --git a/homeassistant/components/flunearyou/strings.json b/homeassistant/components/flunearyou/strings.json new file mode 100644 index 0000000000000..2a7e59989b0bf --- /dev/null +++ b/homeassistant/components/flunearyou/strings.json @@ -0,0 +1,15 @@ +{ + "config": { + "step": { + "user": { + "title": "Configure Flu Near You", + "description": "Monitor user-based and CDC repots for a pair of coordinates.", + "data": { "latitude": "Latitude", "longitude": "Longitude" } + } + }, + "error": { "general_error": "There was an unknown error." }, + "abort": { + "already_configured": "These coordinates are already registered." + } + } +} diff --git a/homeassistant/components/flunearyou/translations/ca.json b/homeassistant/components/flunearyou/translations/ca.json new file mode 100644 index 0000000000000..c26c1f55b2cc1 --- /dev/null +++ b/homeassistant/components/flunearyou/translations/ca.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Les coordenades ja estan registrades" + }, + "error": { + "general_error": "S'ha produ\u00eft un error desconegut." + }, + "step": { + "user": { + "data": { + "latitude": "Latitud", + "longitude": "Longitud" + }, + "description": "Monitoritza informes basats en usuari i CDC per a parells de coordenades.", + "title": "Configuraci\u00f3 Flu Near You" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flunearyou/translations/de.json b/homeassistant/components/flunearyou/translations/de.json new file mode 100644 index 0000000000000..69e4fc0f4783e --- /dev/null +++ b/homeassistant/components/flunearyou/translations/de.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Diese Koordinaten sind bereits registriert." + }, + "error": { + "general_error": "Es gab einen unbekannten Fehler." + }, + "step": { + "user": { + "data": { + "latitude": "Breitengrad", + "longitude": "L\u00e4ngengrad" + }, + "title": "Konfigurieren Sie die Grippe in Ihrer N\u00e4he" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flunearyou/translations/en.json b/homeassistant/components/flunearyou/translations/en.json new file mode 100644 index 0000000000000..88997a89c90c7 --- /dev/null +++ b/homeassistant/components/flunearyou/translations/en.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "These coordinates are already registered." + }, + "error": { + "general_error": "There was an unknown error." + }, + "step": { + "user": { + "data": { + "latitude": "Latitude", + "longitude": "Longitude" + }, + "description": "Monitor user-based and CDC repots for a pair of coordinates.", + "title": "Configure Flu Near You" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flunearyou/translations/es.json b/homeassistant/components/flunearyou/translations/es.json new file mode 100644 index 0000000000000..cdaa475037d52 --- /dev/null +++ b/homeassistant/components/flunearyou/translations/es.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Estas coordenadas ya est\u00e1n registradas." + }, + "error": { + "general_error": "Se ha producido un error desconocido." + }, + "step": { + "user": { + "data": { + "latitude": "Latitud", + "longitude": "Longitud" + }, + "description": "Monitorizar reportes de usuarios y del CDC para un par de coordenadas", + "title": "Configurar Flu Near You" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flunearyou/translations/fr.json b/homeassistant/components/flunearyou/translations/fr.json new file mode 100644 index 0000000000000..dddcdd64d7bf2 --- /dev/null +++ b/homeassistant/components/flunearyou/translations/fr.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "Coordonn\u00e9es d\u00e9j\u00e0 enregistr\u00e9es" + }, + "error": { + "general_error": "Une erreur inconnue est survenue." + }, + "step": { + "user": { + "data": { + "latitude": "Latitude", + "longitude": "Longitude" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flunearyou/translations/it.json b/homeassistant/components/flunearyou/translations/it.json new file mode 100644 index 0000000000000..fc90199664eca --- /dev/null +++ b/homeassistant/components/flunearyou/translations/it.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Queste coordinate sono gi\u00e0 registrate." + }, + "error": { + "general_error": "Si \u00e8 verificato un errore sconosciuto." + }, + "step": { + "user": { + "data": { + "latitude": "Latitudine", + "longitude": "Logitudine" + }, + "description": "Monitorare i repot basati su utenti e CDC per una coppia di coordinate.", + "title": "Configurare Flu Near You" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flunearyou/translations/ko.json b/homeassistant/components/flunearyou/translations/ko.json new file mode 100644 index 0000000000000..5c5e81476ef02 --- /dev/null +++ b/homeassistant/components/flunearyou/translations/ko.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "\uc88c\ud45c\uac12\uc774 \uc774\ubbf8 \ub4f1\ub85d\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, + "error": { + "general_error": "\uc54c \uc218 \uc5c6\ub294 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" + }, + "step": { + "user": { + "data": { + "latitude": "\uc704\ub3c4", + "longitude": "\uacbd\ub3c4" + }, + "description": "\uc0ac\uc6a9\uc790 \uae30\ubc18 \ub370\uc774\ud130 \ubc0f CDC \ubcf4\uace0\uc11c\uc5d0\uc11c \uc88c\ud45c\ub97c \ubaa8\ub2c8\ud130\ub9c1\ud569\ub2c8\ub2e4.", + "title": "Flu Near You \uad6c\uc131\ud558\uae30" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flunearyou/translations/lb.json b/homeassistant/components/flunearyou/translations/lb.json new file mode 100644 index 0000000000000..2692e9219e9d5 --- /dev/null +++ b/homeassistant/components/flunearyou/translations/lb.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "D\u00ebs Koordinate si scho registr\u00e9iert" + }, + "error": { + "general_error": "Onbekannten Feeler" + }, + "step": { + "user": { + "data": { + "latitude": "Breedegrad", + "longitude": "L\u00e4ngegrad" + }, + "description": "Iwwerwach Benotzer-bas\u00e9iert an CDC Berichter fir Koordinaten.", + "title": "Flu Near You konfigur\u00e9ieren" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flunearyou/translations/nl.json b/homeassistant/components/flunearyou/translations/nl.json new file mode 100644 index 0000000000000..c3f83fc93bf22 --- /dev/null +++ b/homeassistant/components/flunearyou/translations/nl.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Deze co\u00f6rdinaten zijn al geregistreerd." + }, + "error": { + "general_error": "Er is een onbekende fout opgetreden." + }, + "step": { + "user": { + "data": { + "latitude": "Breedtegraad", + "longitude": "Lengtegraad" + }, + "description": "Bewaak op gebruikers gebaseerde en CDC-repots voor een paar co\u00f6rdinaten.", + "title": "Configureer \nFlu Near You" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flunearyou/translations/no.json b/homeassistant/components/flunearyou/translations/no.json new file mode 100644 index 0000000000000..3b8a17163dc41 --- /dev/null +++ b/homeassistant/components/flunearyou/translations/no.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Disse koordinatene er allerede registrert." + }, + "error": { + "general_error": "Det oppstod en ukjent feil." + }, + "step": { + "user": { + "data": { + "latitude": "Breddegrad", + "longitude": "Lengdegrad" + }, + "description": "Overv\u00e5k brukerbaserte og CDC-repoter for et par koordinater.", + "title": "Konfigurere influensa i n\u00e6rheten av deg" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flunearyou/translations/pl.json b/homeassistant/components/flunearyou/translations/pl.json new file mode 100644 index 0000000000000..1eb13e53f9f09 --- /dev/null +++ b/homeassistant/components/flunearyou/translations/pl.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Wsp\u00f3\u0142rz\u0119dne s\u0105 ju\u017c zarejestrowane." + }, + "error": { + "general_error": "Nieznany b\u0142\u0105d" + }, + "step": { + "user": { + "data": { + "latitude": "Szeroko\u015b\u0107 geograficzna", + "longitude": "D\u0142ugo\u015b\u0107 geograficzna" + }, + "description": "Monitoruj repoty oparte na u\u017cytkownikach i CDC dla pary wsp\u00f3\u0142rz\u0119dnych.", + "title": "Skonfiguruj Flu Near You" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flunearyou/translations/pt.json b/homeassistant/components/flunearyou/translations/pt.json new file mode 100644 index 0000000000000..c7081cd694a0a --- /dev/null +++ b/homeassistant/components/flunearyou/translations/pt.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "latitude": "Latitude", + "longitude": "Longitude" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flunearyou/translations/ru.json b/homeassistant/components/flunearyou/translations/ru.json new file mode 100644 index 0000000000000..b4ff15d404485 --- /dev/null +++ b/homeassistant/components/flunearyou/translations/ru.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "\u041a\u043e\u043e\u0440\u0434\u0438\u043d\u0430\u0442\u044b \u0443\u0436\u0435 \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u043d\u044b." + }, + "error": { + "general_error": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "step": { + "user": { + "data": { + "latitude": "\u0428\u0438\u0440\u043e\u0442\u0430", + "longitude": "\u0414\u043e\u043b\u0433\u043e\u0442\u0430" + }, + "description": "\u041c\u043e\u043d\u0438\u0442\u043e\u0440\u0438\u043d\u0433 \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u0441\u043a\u0438\u0445 \u0438 CDC \u043e\u0442\u0447\u0435\u0442\u043e\u0432 \u0434\u043b\u044f \u0443\u043a\u0430\u0437\u0430\u043d\u043d\u043e\u0433\u043e \u043c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u044f.", + "title": "Flu Near You" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flunearyou/translations/sl.json b/homeassistant/components/flunearyou/translations/sl.json new file mode 100644 index 0000000000000..843794b8a52e1 --- /dev/null +++ b/homeassistant/components/flunearyou/translations/sl.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Te koordinate so \u017ee registrirane." + }, + "error": { + "general_error": "Pri\u0161lo je do neznane napake." + }, + "step": { + "user": { + "data": { + "latitude": "Zemljepisna \u0161irina", + "longitude": "Zemljepisna dol\u017eina" + }, + "description": "Spremljajte uporabni\u0161ke in CDC obvestila za dolo\u010dene koordinate.", + "title": "Konfigurirajte Flu Near You" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flunearyou/translations/sv.json b/homeassistant/components/flunearyou/translations/sv.json new file mode 100644 index 0000000000000..adcf6008c1e15 --- /dev/null +++ b/homeassistant/components/flunearyou/translations/sv.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "Dessa koordinater \u00e4r redan registrerade." + }, + "error": { + "general_error": "Ett ok\u00e4nt fel intr\u00e4ffade." + }, + "step": { + "user": { + "data": { + "latitude": "Latitud", + "longitude": "Longitud" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flunearyou/translations/zh-Hant.json b/homeassistant/components/flunearyou/translations/zh-Hant.json new file mode 100644 index 0000000000000..b10552d0fe3c4 --- /dev/null +++ b/homeassistant/components/flunearyou/translations/zh-Hant.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "\u6b64\u4e9b\u5ea7\u6a19\u5df2\u8a3b\u518a\u3002" + }, + "error": { + "general_error": "\u767c\u751f\u672a\u77e5\u932f\u8aa4\u3002" + }, + "step": { + "user": { + "data": { + "latitude": "\u7def\u5ea6", + "longitude": "\u7d93\u5ea6" + }, + "description": "\u76e3\u6e2c\u4f7f\u7528\u8005\u8207 CDC \u56de\u5831\u5ea7\u6a19\u3002", + "title": "\u8a2d\u5b9a Flu Near You" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flux/manifest.json b/homeassistant/components/flux/manifest.json index 9bf3ba09ce713..400331f9f5fa5 100644 --- a/homeassistant/components/flux/manifest.json +++ b/homeassistant/components/flux/manifest.json @@ -1,9 +1,8 @@ { "domain": "flux", "name": "Flux", - "documentation": "https://www.home-assistant.io/components/flux", - "requirements": [], - "dependencies": [], + "documentation": "https://www.home-assistant.io/integrations/flux", "after_dependencies": ["light"], - "codeowners": [] + "codeowners": [], + "quality_scale": "internal" } diff --git a/homeassistant/components/flux/switch.py b/homeassistant/components/flux/switch.py index f0134f04d890d..8a27c99c78d3e 100644 --- a/homeassistant/components/flux/switch.py +++ b/homeassistant/components/flux/switch.py @@ -2,71 +2,93 @@ 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 + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP, + ATTR_RGB_COLOR, + ATTR_TRANSITION, + ATTR_WHITE_VALUE, + ATTR_XY_COLOR, + DOMAIN as LIGHT_DOMAIN, + VALID_TRANSITION, + is_on, +) +from homeassistant.components.switch import DOMAIN, SwitchEntity from homeassistant.const import ( - ATTR_ENTITY_ID, CONF_NAME, CONF_PLATFORM, CONF_LIGHTS, CONF_MODE, - SERVICE_TURN_ON, SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET) + ATTR_ENTITY_ID, + CONF_LIGHTS, + CONF_MODE, + CONF_NAME, + CONF_PLATFORM, + SERVICE_TURN_ON, + STATE_ON, + SUN_EVENT_SUNRISE, + SUN_EVENT_SUNSET, +) +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.restore_state import RestoreEntity 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 + color_RGB_to_xy_brightness, + color_temperature_kelvin_to_mired, + color_temperature_to_rgb, +) +from homeassistant.util.dt import as_local, utcnow as dt_utcnow _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' +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): +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): @@ -78,8 +100,7 @@ async def async_set_lights_xy(hass, lights, x_val, y_val, 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) + await hass.services.async_call(LIGHT_DOMAIN, SERVICE_TURN_ON, service_data) async def async_set_lights_temp(hass, lights, mired, brightness, transition): @@ -93,8 +114,7 @@ async def async_set_lights_temp(hass, lights, mired, brightness, transition): 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) + await hass.services.async_call(LIGHT_DOMAIN, SERVICE_TURN_ON, service_data) async def async_set_lights_rgb(hass, lights, rgb, transition): @@ -106,12 +126,10 @@ async def async_set_lights_rgb(hass, lights, rgb, transition): 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) + 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): +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) @@ -125,27 +143,50 @@ async def async_setup_platform(hass, config, async_add_entities, 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) + 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')) + service_name = slugify(f"{name} update") hass.services.async_register(DOMAIN, service_name, async_update) -class FluxSwitch(SwitchDevice): +class FluxSwitch(SwitchEntity, RestoreEntity): """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): + 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 @@ -172,6 +213,12 @@ def is_on(self): """Return true if switch is on.""" return self.unsub_tracker is not None + async def async_added_to_hass(self): + """Call when entity about to be added to hass.""" + last_state = await self.async_get_last_state() + if last_state and last_state.state == STATE_ON: + await self.async_turn_on() + async def async_turn_on(self, **kwargs): """Turn on flux.""" if self.is_on: @@ -180,12 +227,13 @@ async def async_turn_on(self, **kwargs): self.unsub_tracker = async_track_time_interval( self.hass, self.async_flux_update, - datetime.timedelta(seconds=self._interval)) + datetime.timedelta(seconds=self._interval), + ) # Make initial update await self.async_flux_update() - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def async_turn_off(self, **kwargs): """Turn off flux.""" @@ -193,7 +241,7 @@ async def async_turn_off(self, **kwargs): self.unsub_tracker() self.unsub_tracker = None - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def async_flux_update(self, utcnow=None): """Update all the lights using flux.""" @@ -217,7 +265,7 @@ async def async_flux_update(self, utcnow=None): if start_time < now < sunset: # Daytime - time_state = 'day' + 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()) @@ -229,7 +277,7 @@ async def async_flux_update(self, utcnow=None): temp = self._start_colortemp + temp_offset else: # Night time - time_state = 'night' + time_state = "night" if now < stop_time: if stop_time < start_time and stop_time.day == sunset.day: @@ -238,10 +286,8 @@ async def async_flux_update(self, utcnow=None): else: sunset_time = sunset - night_length = int(stop_time.timestamp() - - sunset_time.timestamp()) - seconds_from_sunset = int(now.timestamp() - - sunset_time.timestamp()) + 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 @@ -258,44 +304,60 @@ async def async_flux_update(self, utcnow=None): 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) + await async_set_lights_xy( + self.hass, self._lights, x_val, y_val, brightness, self._transition + ) + _LOGGER.debug( + "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) + await async_set_lights_rgb(self.hass, self._lights, rgb, self._transition) + _LOGGER.debug( + "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) + await async_set_lights_temp( + self.hass, self._lights, mired, brightness, self._transition + ) + _LOGGER.debug( + "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) + 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()) + 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) + hour=self._stop_time.hour, minute=self._stop_time.minute, second=0 + ) else: - dusk = get_astral_event_date(self.hass, 'dusk', now.date()) + dusk = get_astral_event_date(self.hass, "dusk", now.date()) return dusk diff --git a/homeassistant/components/flux_led/light.py b/homeassistant/components/flux_led/light.py index 38809e94c92c9..4bfd0c0a26c77 100644 --- a/homeassistant/components/flux_led/light.py +++ b/homeassistant/components/flux_led/light.py @@ -1,130 +1,153 @@ """Support for Flux lights.""" import logging -import socket import random -from asyncio import sleep -from functools import partial +from flux_led import BulbScanner, WifiLedBulb 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) + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP, + ATTR_EFFECT, + ATTR_HS_COLOR, + ATTR_WHITE_VALUE, + EFFECT_COLORLOOP, + EFFECT_RANDOM, + PLATFORM_SCHEMA, + SUPPORT_BRIGHTNESS, + SUPPORT_COLOR, + SUPPORT_COLOR_TEMP, + SUPPORT_EFFECT, + SUPPORT_WHITE_VALUE, + LightEntity, +) +from homeassistant.const import ATTR_MODE, CONF_DEVICES, CONF_NAME, CONF_PROTOCOL 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' +CONF_AUTOMATIC_ADD = "automatic_add" +CONF_CUSTOM_EFFECT = "custom_effect" +CONF_COLORS = "colors" +CONF_SPEED_PCT = "speed_pct" +CONF_TRANSITION = "transition" -DOMAIN = 'flux_led' +DOMAIN = "flux_led" -SUPPORT_FLUX_LED = (SUPPORT_BRIGHTNESS | SUPPORT_EFFECT | - SUPPORT_COLOR) +SUPPORT_FLUX_LED = SUPPORT_BRIGHTNESS | SUPPORT_EFFECT | SUPPORT_COLOR -MODE_RGB = 'rgb' -MODE_RGBW = 'rgbw' +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' +MODE_WHITE = "w" + +# Constant color temp values for 2 flux_led special modes +# Warm-white and Cool-white modes +COLOR_TEMP_WARM_VS_COLD_WHITE_CUT_OFF = 285 # 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_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_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' +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, -}) +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["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) @@ -137,13 +160,13 @@ def setup_platform(hass, config, add_entities, discovery_info=None): return # Find the bulbs on the LAN - scanner = flux_led.BulbScanner() + scanner = BulbScanner() scanner.scan(timeout=10) for device in scanner.getBulbInfo(): - ipaddr = device['ipaddr'] + ipaddr = device["ipaddr"] if ipaddr in light_ips: continue - device['name'] = '{} {}'.format(device['id'], ipaddr) + device["name"] = f"{device['id']} {ipaddr}" device[ATTR_MODE] = None device[CONF_PROTOCOL] = None device[CONF_CUSTOM_EFFECT] = None @@ -153,26 +176,23 @@ def setup_platform(hass, config, add_entities, discovery_info=None): add_entities(lights, True) -class FluxLight(Light): +class FluxLight(LightEntity): """Representation of a Flux light.""" def __init__(self, device): """Initialize the light.""" - self._name = device['name'] - self._ipaddr = device['ipaddr'] + 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) + self._bulb = WifiLedBulb(self._ipaddr, timeout=5) if self._protocol: self._bulb.setProtocol(self._protocol) @@ -207,20 +227,20 @@ def is_on(self): def brightness(self): """Return the brightness of this light between 0..255.""" if self._mode == MODE_WHITE: - return self._white_value + return self.white_value - return int(self._color[2] / 100 * 255) + return self._bulb.brightness @property def hs_color(self): """Return the color property.""" - return self._color[0:2] + return color_util.color_RGB_to_hs(*self._bulb.getRgb()) @property def supported_features(self): """Flag supported features.""" if self._mode == MODE_RGBW: - return SUPPORT_FLUX_LED | SUPPORT_WHITE_VALUE + return SUPPORT_FLUX_LED | SUPPORT_WHITE_VALUE | SUPPORT_COLOR_TEMP if self._mode == MODE_WHITE: return SUPPORT_BRIGHTNESS @@ -230,7 +250,7 @@ def supported_features(self): @property def white_value(self): """Return the white value of this light between 0..255.""" - return self._white_value + return self._bulb.getRgbw()[3] @property def effect_list(self): @@ -251,66 +271,86 @@ def effect(self): 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) + return None - def _turn_on(self, **kwargs): + def turn_on(self, **kwargs): """Turn the specified or all lights on.""" - self._bulb.turnOn() + if not self.is_on: + self._bulb.turnOn() hs_color = kwargs.get(ATTR_HS_COLOR) + + if hs_color: + rgb = color_util.color_hs_to_RGB(*hs_color) + else: + rgb = None + brightness = kwargs.get(ATTR_BRIGHTNESS) effect = kwargs.get(ATTR_EFFECT) white = kwargs.get(ATTR_WHITE_VALUE) + color_temp = kwargs.get(ATTR_COLOR_TEMP) + + # handle special modes + if color_temp is not None: + if brightness is None: + brightness = self.brightness + if color_temp > COLOR_TEMP_WARM_VS_COLD_WHITE_CUT_OFF: + self._bulb.setRgbw(w=brightness) + else: + self._bulb.setRgbw(w2=brightness) + return - if all(item is None for item in [hs_color, brightness, effect, white]): + # Show warning if effect set with rgb, brightness, or white level + if effect and (brightness or white or rgb): + _LOGGER.warning( + "RGB, brightness and white level are ignored when" + " an effect is specified for a flux bulb" + ) + + # Random color effect + if effect == EFFECT_RANDOM: + self._bulb.setRgb( + random.randint(0, 255), random.randint(0, 255), random.randint(0, 255) + ) 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) + if 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], + ) 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) + + # Effect selection + if 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) + if brightness is None: + brightness = self.brightness + + # Preserve color on brightness/white level change + if rgb is None: + rgb = self._bulb.getRgb() + + if white is None and self._mode == MODE_RGBW: + white = self.white_value + + # handle W only mode (use brightness instead of white value) + if self._mode == MODE_WHITE: + self._bulb.setRgbw(0, 0, 0, w=brightness) + # 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) + elif self._mode == MODE_RGBW: + self._bulb.setRgbw(*tuple(rgb), w=white, brightness=brightness) + # handle RGB mode else: - self._bulb.setRgb(*color_util.color_hsv_to_RGB(*color)) + self._bulb.setRgb(*tuple(rgb), brightness=brightness) def turn_off(self, **kwargs): """Turn the specified or all lights off.""" @@ -322,17 +362,13 @@ def update(self): try: self._connect() self._error_reported = False - except socket.error: + except OSError: self._disconnect() if not self._error_reported: - _LOGGER.warning("Failed to connect to bulb %s, %s", - self._ipaddr, self._name) + _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 index 0d00275200cab..378860229eebc 100644 --- a/homeassistant/components/flux_led/manifest.json +++ b/homeassistant/components/flux_led/manifest.json @@ -1,10 +1,7 @@ { "domain": "flux_led", - "name": "Flux led", - "documentation": "https://www.home-assistant.io/components/flux_led", - "requirements": [ - "flux_led==0.22" - ], - "dependencies": [], + "name": "Flux LED/MagicLight", + "documentation": "https://www.home-assistant.io/integrations/flux_led", + "requirements": ["flux_led==0.22"], "codeowners": [] } diff --git a/homeassistant/components/folder/manifest.json b/homeassistant/components/folder/manifest.json index 7a0bf76e0aa31..810a26bc1e054 100644 --- a/homeassistant/components/folder/manifest.json +++ b/homeassistant/components/folder/manifest.json @@ -1,8 +1,6 @@ { "domain": "folder", "name": "Folder", - "documentation": "https://www.home-assistant.io/components/folder", - "requirements": [], - "dependencies": [], + "documentation": "https://www.home-assistant.io/integrations/folder", "codeowners": [] } diff --git a/homeassistant/components/folder/sensor.py b/homeassistant/components/folder/sensor.py index d742166a192aa..19a5791d7cb5b 100644 --- a/homeassistant/components/folder/sensor.py +++ b/homeassistant/components/folder/sensor.py @@ -6,22 +6,25 @@ import voluptuous as vol -from homeassistant.helpers.entity import Entity -import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import DATA_MEGABYTES +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) -CONF_FOLDER_PATHS = 'folder' -CONF_FILTER = 'filter' -DEFAULT_FILTER = '*' +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, -}) +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): @@ -51,21 +54,23 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class Folder(Entity): """Representation of a folder.""" - ICON = 'mdi: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 + 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' + self._unit_of_measurement = DATA_MEGABYTES + self._file_list = None def update(self): """Update the sensor.""" files_list = get_files_list(self._folder_path, self._filter_term) + self._file_list = files_list self._number_of_files = len(files_list) self._size = get_size(files_list) @@ -78,7 +83,7 @@ def name(self): def state(self): """Return the state of the sensor.""" decimals = 2 - size_mb = round(self._size/1e6, decimals) + size_mb = round(self._size / 1e6, decimals) return size_mb @property @@ -90,11 +95,12 @@ def icon(self): 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, - } + "path": self._folder_path, + "filter": self._filter_term, + "number_of_files": self._number_of_files, + "bytes": self._size, + "file_list": self._file_list, + } return attr @property diff --git a/homeassistant/components/folder_watcher/__init__.py b/homeassistant/components/folder_watcher/__init__.py index 411f6b480dcb8..d99e4928cc5d5 100644 --- a/homeassistant/components/folder_watcher/__init__.py +++ b/homeassistant/components/folder_watcher/__init__.py @@ -3,25 +3,37 @@ import os import voluptuous as vol +from watchdog.events import PatternMatchingEventHandler +from watchdog.observers import Observer -from homeassistant.const import ( - EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) +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 = '*' +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) +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): @@ -40,7 +52,6 @@ def setup(hass, config): def create_event_handler(patterns, hass): """Return the Watchdog EventHandler object.""" - from watchdog.events import PatternMatchingEventHandler class EventHandler(PatternMatchingEventHandler): """Class for handling Watcher events.""" @@ -56,12 +67,14 @@ def process(self, event): if not event.is_directory: folder, file_name = os.path.split(event.src_path) self.hass.bus.fire( - DOMAIN, { + DOMAIN, + { "event_type": event.event_type, - 'path': event.src_path, - 'file': file_name, - 'folder': folder, - }) + "path": event.src_path, + "file": file_name, + "folder": folder, + }, + ) def on_modified(self, event): """File modified.""" @@ -82,17 +95,15 @@ def on_deleted(self, event): return EventHandler(patterns, hass) -class Watcher(): +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) + 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) diff --git a/homeassistant/components/folder_watcher/manifest.json b/homeassistant/components/folder_watcher/manifest.json index 1a5b547e5ff21..722b60a952dcc 100644 --- a/homeassistant/components/folder_watcher/manifest.json +++ b/homeassistant/components/folder_watcher/manifest.json @@ -1,10 +1,8 @@ { "domain": "folder_watcher", - "name": "Folder watcher", - "documentation": "https://www.home-assistant.io/components/folder_watcher", - "requirements": [ - "watchdog==0.8.3" - ], - "dependencies": [], - "codeowners": [] + "name": "Folder Watcher", + "documentation": "https://www.home-assistant.io/integrations/folder_watcher", + "requirements": ["watchdog==0.8.3"], + "codeowners": [], + "quality_scale": "internal" } diff --git a/homeassistant/components/foobot/manifest.json b/homeassistant/components/foobot/manifest.json index 9ed95597e4170..190d3e9837f24 100644 --- a/homeassistant/components/foobot/manifest.json +++ b/homeassistant/components/foobot/manifest.json @@ -1,10 +1,7 @@ { "domain": "foobot", "name": "Foobot", - "documentation": "https://www.home-assistant.io/components/foobot", - "requirements": [ - "foobot_async==0.3.1" - ], - "dependencies": [], + "documentation": "https://www.home-assistant.io/integrations/foobot", + "requirements": ["foobot_async==0.3.1"], "codeowners": [] } diff --git a/homeassistant/components/foobot/sensor.py b/homeassistant/components/foobot/sensor.py index f59392bde985b..e0322ccbab7f6 100644 --- a/homeassistant/components/foobot/sensor.py +++ b/homeassistant/components/foobot/sensor.py @@ -1,79 +1,96 @@ """Support for the Foobot indoor air quality monitor.""" import asyncio -import logging from datetime import timedelta +import logging import aiohttp +from foobot_async import FoobotClient 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) + ATTR_TEMPERATURE, + ATTR_TIME, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_PARTS_PER_BILLION, + CONCENTRATION_PARTS_PER_MILLION, + CONF_TOKEN, + CONF_USERNAME, + TEMP_CELSIUS, + TIME_SECONDS, + UNIT_PERCENTAGE, +) +from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv 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']} +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, TIME_SECONDS], + "pm": [ATTR_PM2_5, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, "mdi:cloud"], + "tmp": [ATTR_TEMPERATURE, TEMP_CELSIUS, "mdi:thermometer"], + "hum": [ATTR_HUMIDITY, UNIT_PERCENTAGE, "mdi:water-percent"], + "co2": [ + ATTR_CARBON_DIOXIDE, + CONCENTRATION_PARTS_PER_MILLION, + "mdi:periodic-table-co2", + ], + "voc": [ + ATTR_VOLATILE_ORGANIC_COMPOUNDS, + CONCENTRATION_PARTS_PER_BILLION, + "mdi:cloud", + ], + "allpollu": [ATTR_FOOBOT_INDEX, UNIT_PERCENTAGE, "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, -}) +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): +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) + 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']) + foobot_data = FoobotData(client, device["uuid"]) for sensor_type in SENSOR_TYPES: - if sensor_type == 'time': + 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.') + 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.') + _LOGGER.error("Failed to fetch data from foobot servers.") return async_add_entities(dev, True) @@ -83,10 +100,9 @@ class FoobotSensor(Entity): def __init__(self, data, device, sensor_type): """Initialize the sensor.""" - self._uuid = device['uuid'] + self._uuid = device["uuid"] self.foobot_data = data - self._name = 'Foobot {} {}'.format(device['name'], - SENSOR_TYPES[sensor_type][0]) + self._name = f"Foobot {device['name']} {SENSOR_TYPES[sensor_type][0]}" self.type = sensor_type self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] @@ -105,14 +121,14 @@ def state(self): """Return the state of the device.""" try: data = self.foobot_data.data[self.type] - except(KeyError, TypeError): + 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) + return f"{self._uuid}_{self.type}" @property def unit_of_measurement(self): @@ -138,12 +154,15 @@ 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): + 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) diff --git a/homeassistant/components/fortigate/__init__.py b/homeassistant/components/fortigate/__init__.py new file mode 100644 index 0000000000000..2dbd7ef45c0f9 --- /dev/null +++ b/homeassistant/components/fortigate/__init__.py @@ -0,0 +1,79 @@ +"""Fortigate integration.""" +import logging + +from pyFGT.fortigate import FGTConnectionError, FortiGate +import voluptuous as vol + +from homeassistant.const import ( + CONF_API_KEY, + CONF_DEVICES, + CONF_HOST, + CONF_USERNAME, + EVENT_HOMEASSISTANT_STOP, +) +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.discovery import async_load_platform + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "fortigate" + +DATA_FGT = DOMAIN + +CONFIG_SCHEMA = vol.Schema( + vol.All( + cv.deprecated(DOMAIN, invalidation_version="0.112.0"), + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_API_KEY): cv.string, + vol.Optional(CONF_DEVICES, default=[]): vol.All( + cv.ensure_list, [cv.string] + ), + } + ) + }, + ), + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass, config): + """Start the Fortigate component.""" + conf = config[DOMAIN] + + host = conf[CONF_HOST] + user = conf[CONF_USERNAME] + api_key = conf[CONF_API_KEY] + devices = conf[CONF_DEVICES] + + is_success = await async_setup_fortigate(hass, config, host, user, api_key, devices) + + return is_success + + +async def async_setup_fortigate(hass, config, host, user, api_key, devices): + """Start up the Fortigate component platforms.""" + fgt = FortiGate(host, user, apikey=api_key, disable_request_warnings=True) + + try: + fgt.login() + except FGTConnectionError: + _LOGGER.error("Failed to connect to Fortigate") + return False + + hass.data[DATA_FGT] = {"fgt": fgt, "devices": devices} + + hass.async_create_task( + async_load_platform(hass, "device_tracker", DOMAIN, {}, config) + ) + + async def close_fgt(event): + """Close Fortigate connection on HA Stop.""" + fgt.logout() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, close_fgt) + + return True diff --git a/homeassistant/components/fortigate/device_tracker.py b/homeassistant/components/fortigate/device_tracker.py new file mode 100644 index 0000000000000..b51dc6843aaf1 --- /dev/null +++ b/homeassistant/components/fortigate/device_tracker.py @@ -0,0 +1,89 @@ +"""Device tracker for Fortigate firewalls.""" +from collections import namedtuple +import logging + +from homeassistant.components.device_tracker import DeviceScanner + +from . import DATA_FGT + +_LOGGER = logging.getLogger(__name__) + +DETECTED_DEVICES = "/monitor/user/detected-device" + + +async def async_get_scanner(hass, config): + """Validate the configuration and return a Fortigate scanner.""" + scanner = FortigateDeviceScanner(hass.data[DATA_FGT]) + await scanner.async_connect() + return scanner if scanner.success_init else None + + +Device = namedtuple("Device", ["hostname", "mac"]) + + +def _build_device(device_dict): + """Return a Device from data.""" + return Device(device_dict["hostname"], device_dict["mac"]) + + +class FortigateDeviceScanner(DeviceScanner): + """Query the Fortigate firewall.""" + + def __init__(self, hass_data): + """Initialize the scanner.""" + self.last_results = {} + self.success_init = False + self.connection = hass_data["fgt"] + self.devices = hass_data["devices"] + + def get_results(self): + """Get the results from the Fortigate.""" + results = self.connection.get(DETECTED_DEVICES, "vdom=root")[1]["results"] + + ret = [] + for result in results: + if "hostname" not in result: + continue + + ret.append(result) + + return ret + + async def async_connect(self): + """Initialize connection to the router.""" + # Test if the firewall is accessible + data = self.get_results() + self.success_init = data is not None + + async def async_scan_devices(self): + """Scan for new devices and return a list with found device MACs.""" + await self.async_update_info() + return [device.mac 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.hostname for result in self.last_results if result.mac == device), + None, + ) + return name + + async def async_update_info(self): + """Ensure the information from the Fortigate firewall is up to date.""" + _LOGGER.debug("Checking devices") + + hosts = self.get_results() + + all_results = [_build_device(device) for device in hosts if device["is_online"]] + + # If the 'devices' configuration field is filled + if self.devices is not None: + last_results = [ + device for device in all_results if device.hostname in self.devices + ] + _LOGGER.debug(last_results) + # If the 'devices' configuration field is not filled + else: + last_results = all_results + + self.last_results = last_results diff --git a/homeassistant/components/fortigate/manifest.json b/homeassistant/components/fortigate/manifest.json new file mode 100644 index 0000000000000..395f8e058900c --- /dev/null +++ b/homeassistant/components/fortigate/manifest.json @@ -0,0 +1,7 @@ +{ + "domain": "fortigate", + "name": "FortiGate", + "documentation": "https://www.home-assistant.io/integrations/fortigate", + "codeowners": ["@kifeo"], + "requirements": ["pyfgt==0.5.1"] +} diff --git a/homeassistant/components/fortios/__init__.py b/homeassistant/components/fortios/__init__.py new file mode 100644 index 0000000000000..873d6c00c6559 --- /dev/null +++ b/homeassistant/components/fortios/__init__.py @@ -0,0 +1 @@ +"""Fortinet FortiOS components.""" diff --git a/homeassistant/components/fortios/device_tracker.py b/homeassistant/components/fortios/device_tracker.py new file mode 100644 index 0000000000000..2b2d14f60e04f --- /dev/null +++ b/homeassistant/components/fortios/device_tracker.py @@ -0,0 +1,100 @@ +""" +Support to use FortiOS device like FortiGate as device tracker. + +This component is part of the device_tracker platform. +""" +import logging + +from fortiosapi import FortiOSAPI +import voluptuous as vol + +from homeassistant.components.device_tracker import ( + DOMAIN, + PLATFORM_SCHEMA, + DeviceScanner, +) +from homeassistant.const import CONF_HOST, CONF_TOKEN, CONF_VERIFY_SSL +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) +DEFAULT_VERIFY_SSL = False + + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_TOKEN): cv.string, + vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, + } +) + + +def get_scanner(hass, config): + """Validate the configuration and return a FortiOSDeviceScanner.""" + host = config[DOMAIN][CONF_HOST] + verify_ssl = config[DOMAIN][CONF_VERIFY_SSL] + token = config[DOMAIN][CONF_TOKEN] + + fgt = FortiOSAPI() + + try: + fgt.tokenlogin(host, token, verify_ssl) + except ConnectionError as ex: + _LOGGER.error("ConnectionError to FortiOS API: %s", ex) + return None + except Exception as ex: # pylint: disable=broad-except + _LOGGER.error("Failed to login to FortiOS API: %s", ex) + return None + + return FortiOSDeviceScanner(fgt) + + +class FortiOSDeviceScanner(DeviceScanner): + """This class queries a FortiOS unit for connected devices.""" + + def __init__(self, fgt) -> None: + """Initialize the scanner.""" + self._clients = {} + self._clients_json = {} + self._fgt = fgt + + def update(self): + """Update clients from the device.""" + clients_json = self._fgt.monitor("user/device/select", "") + self._clients_json = clients_json + + self._clients = [] + + if clients_json: + for client in clients_json["results"]: + if client["last_seen"] < 180: + self._clients.append(client["mac"].upper()) + + def scan_devices(self): + """Scan for new devices and return a list with found device IDs.""" + self.update() + return self._clients + + def get_device_name(self, device): + """Return the name of the given device or None if we don't know.""" + _LOGGER.debug("Getting name of device %s", device) + + device = device.lower() + + data = self._clients_json + + if data == 0: + _LOGGER.error("No json results to get device names") + return None + + for client in data["results"]: + if client["mac"] == device: + try: + name = client["host"]["name"] + _LOGGER.debug("Getting device name=%s", name) + return name + except KeyError as kex: + _LOGGER.error("Name not found in client data: %s", kex) + return None + + return None diff --git a/homeassistant/components/fortios/manifest.json b/homeassistant/components/fortios/manifest.json new file mode 100644 index 0000000000000..e0ca2671b1971 --- /dev/null +++ b/homeassistant/components/fortios/manifest.json @@ -0,0 +1,7 @@ +{ + "domain": "fortios", + "name": "FortiOS", + "documentation": "https://www.home-assistant.io/integrations/fortios/", + "requirements": ["fortiosapi==0.10.8"], + "codeowners": ["@kimfrellsen"] +} diff --git a/homeassistant/components/foscam/camera.py b/homeassistant/components/foscam/camera.py index f83c3f1966ae7..1c4c6bb9c8cfb 100644 --- a/homeassistant/components/foscam/camera.py +++ b/homeassistant/components/foscam/camera.py @@ -1,71 +1,181 @@ """This component provides basic support for Foscam IP cameras.""" +import asyncio import logging +from libpyfoscam import FoscamCamera import voluptuous as vol -from homeassistant.components.camera import ( - Camera, PLATFORM_SCHEMA, SUPPORT_STREAM) +from homeassistant.components.camera import PLATFORM_SCHEMA, SUPPORT_STREAM, Camera from homeassistant.const import ( - CONF_NAME, CONF_USERNAME, CONF_PASSWORD, CONF_PORT) + ATTR_ENTITY_ID, + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, +) from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.service import async_extract_entity_ids + +from .const import ( + DATA as FOSCAM_DATA, + DOMAIN as FOSCAM_DOMAIN, + ENTITIES as FOSCAM_ENTITIES, +) _LOGGER = logging.getLogger(__name__) -CONF_IP = 'ip' -CONF_RTSP_PORT = 'rtsp_port' +CONF_IP = "ip" +CONF_RTSP_PORT = "rtsp_port" -DEFAULT_NAME = 'Foscam Camera' +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): +SERVICE_PTZ = "ptz" +ATTR_MOVEMENT = "movement" +ATTR_TRAVELTIME = "travel_time" + +DEFAULT_TRAVELTIME = 0.125 + +DIR_UP = "up" +DIR_DOWN = "down" +DIR_LEFT = "left" +DIR_RIGHT = "right" + +DIR_TOPLEFT = "top_left" +DIR_TOPRIGHT = "top_right" +DIR_BOTTOMLEFT = "bottom_left" +DIR_BOTTOMRIGHT = "bottom_right" + +MOVEMENT_ATTRS = { + DIR_UP: "ptz_move_up", + DIR_DOWN: "ptz_move_down", + DIR_LEFT: "ptz_move_left", + DIR_RIGHT: "ptz_move_right", + DIR_TOPLEFT: "ptz_move_top_left", + DIR_TOPRIGHT: "ptz_move_top_right", + DIR_BOTTOMLEFT: "ptz_move_bottom_left", + DIR_BOTTOMRIGHT: "ptz_move_bottom_right", +} + +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, + } +) + +SERVICE_PTZ_SCHEMA = vol.Schema( + { + vol.Required(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_MOVEMENT): vol.In( + [ + DIR_UP, + DIR_DOWN, + DIR_LEFT, + DIR_RIGHT, + DIR_TOPLEFT, + DIR_TOPRIGHT, + DIR_BOTTOMLEFT, + DIR_BOTTOMRIGHT, + ] + ), + vol.Optional(ATTR_TRAVELTIME, default=DEFAULT_TRAVELTIME): cv.small_float, + } +) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up a Foscam IP Camera.""" - add_entities([FoscamCam(config)]) - -class FoscamCam(Camera): + async def async_handle_ptz(service): + """Handle PTZ service call.""" + movement = service.data[ATTR_MOVEMENT] + travel_time = service.data[ATTR_TRAVELTIME] + entity_ids = await async_extract_entity_ids(hass, service) + + if not entity_ids: + return + + _LOGGER.debug("Moving '%s' camera(s): %s", movement, entity_ids) + + all_cameras = hass.data[FOSCAM_DATA][FOSCAM_ENTITIES] + target_cameras = [ + camera for camera in all_cameras if camera.entity_id in entity_ids + ] + + for camera in target_cameras: + await camera.async_perform_ptz(movement, travel_time) + + hass.services.async_register( + FOSCAM_DOMAIN, SERVICE_PTZ, async_handle_ptz, schema=SERVICE_PTZ_SCHEMA + ) + + camera = FoscamCamera( + config[CONF_IP], + config[CONF_PORT], + config[CONF_USERNAME], + config[CONF_PASSWORD], + verbose=False, + ) + + rtsp_port = config.get(CONF_RTSP_PORT) + if not rtsp_port: + ret, response = await hass.async_add_executor_job(camera.get_port_info) + + if ret == 0: + rtsp_port = response.get("rtspPort") or response.get("mediaPort") + + ret, response = await hass.async_add_executor_job(camera.get_motion_detect_config) + + motion_status = False + if ret != 0 and response == 1: + motion_status = True + + async_add_entities( + [ + HassFoscamCamera( + camera, + config[CONF_NAME], + config[CONF_USERNAME], + config[CONF_PASSWORD], + rtsp_port, + motion_status, + ) + ] + ) + + +class HassFoscamCamera(Camera): """An implementation of a Foscam IP camera.""" - def __init__(self, device_info): + def __init__(self, camera, name, username, password, rtsp_port, motion_status): """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') + super().__init__() + + self._foscam_session = camera + self._name = name + self._username = username + self._password = password + self._rtsp_port = rtsp_port + self._motion_status = motion_status + + async def async_added_to_hass(self): + """Handle entity addition to hass.""" + entities = self.hass.data.setdefault(FOSCAM_DATA, {}).setdefault( + FOSCAM_ENTITIES, [] + ) + entities.append(self) 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: + if result != 0: return None return response @@ -77,15 +187,10 @@ def supported_features(self): return SUPPORT_STREAM return 0 - @property - def stream_source(self): + 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 f"rtsp://{self._username}:{self._password}@{self._foscam_session.host}:{self._rtsp_port}/videoMain" return None @property @@ -97,19 +202,47 @@ 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 + + if ret != 0: + return + + self._motion_status = True 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 + + if ret != 0: + return + + self._motion_status = False except TypeError: _LOGGER.debug("Communication problem") - self._motion_status = False + + async def async_perform_ptz(self, movement, travel_time): + """Perform a PTZ action on the camera.""" + _LOGGER.debug("PTZ action '%s' on %s", movement, self._name) + + movement_function = getattr(self._foscam_session, MOVEMENT_ATTRS[movement]) + + ret, _ = await self.hass.async_add_executor_job(movement_function) + + if ret != 0: + _LOGGER.error("Error moving %s '%s': %s", movement, self._name, ret) + return + + await asyncio.sleep(travel_time) + + ret, _ = await self.hass.async_add_executor_job( + self._foscam_session.ptz_stop_run + ) + + if ret != 0: + _LOGGER.error("Error stopping movement on '%s': %s", self._name, ret) + return @property def name(self): diff --git a/homeassistant/components/foscam/const.py b/homeassistant/components/foscam/const.py new file mode 100644 index 0000000000000..63b4b74a76330 --- /dev/null +++ b/homeassistant/components/foscam/const.py @@ -0,0 +1,5 @@ +"""Constants for Foscam component.""" + +DOMAIN = "foscam" +DATA = "foscam" +ENTITIES = "entities" diff --git a/homeassistant/components/foscam/manifest.json b/homeassistant/components/foscam/manifest.json index b05aa956b42a8..8c7e8e7d77a66 100644 --- a/homeassistant/components/foscam/manifest.json +++ b/homeassistant/components/foscam/manifest.json @@ -1,10 +1,7 @@ { "domain": "foscam", "name": "Foscam", - "documentation": "https://www.home-assistant.io/components/foscam", - "requirements": [ - "libpyfoscam==1.0" - ], - "dependencies": [], - "codeowners": [] + "documentation": "https://www.home-assistant.io/integrations/foscam", + "requirements": ["libpyfoscam==1.0"], + "codeowners": ["@skgsergio"] } diff --git a/homeassistant/components/foscam/services.yaml b/homeassistant/components/foscam/services.yaml new file mode 100644 index 0000000000000..33ba82482f1aa --- /dev/null +++ b/homeassistant/components/foscam/services.yaml @@ -0,0 +1,12 @@ +ptz: + description: Pan/Tilt service for Foscam camera. + fields: + entity_id: + description: Name(s) of entities to move. + example: "camera.living_room_camera" + movement: + description: "Direction of the movement. Allowed values: up, down, left, right, top_left, top_right, bottom_left, bottom_right." + example: "up" + travel_time: + description: "(Optional) Travel time in seconds. Allowed values: float from 0 to 1. Default: 0.125" + example: 0.125 diff --git a/homeassistant/components/foursquare/__init__.py b/homeassistant/components/foursquare/__init__.py index dd8349998886e..bae0336a63e25 100644 --- a/homeassistant/components/foursquare/__init__.py +++ b/homeassistant/components/foursquare/__init__.py @@ -4,39 +4,46 @@ 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 +from homeassistant.const import CONF_ACCESS_TOKEN, HTTP_BAD_REQUEST, HTTP_OK +import homeassistant.helpers.config_validation as cv _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) +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): @@ -45,22 +52,22 @@ def setup(hass, config): 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]) + url = f"https://api.foursquare.com/v2/checkins/add?oauth_token={config[CONF_ACCESS_TOKEN]}&v=20160802&m=swarm" response = requests.post(url, data=call.data, timeout=10) - if response.status_code not in (200, 201): + if response.status_code not in (HTTP_OK, 201): _LOGGER.exception( "Error checking in user. Response %d: %s:", - response.status_code, response.reason) + 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.services.register( + DOMAIN, "checkin", checkin_user, schema=CHECKIN_SERVICE_SCHEMA + ) hass.http.register_view(FoursquarePushReceiver(config[CONF_PUSH_SECRET])) @@ -83,15 +90,16 @@ async def post(self, request): try: data = await request.json() except ValueError: - return self.json_message('Invalid JSON', HTTP_BAD_REQUEST) + return self.json_message("Invalid JSON", HTTP_BAD_REQUEST) - secret = data.pop('secret', None) + 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) + _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) + request.app["hass"].bus.async_fire(EVENT_PUSH, data) diff --git a/homeassistant/components/foursquare/manifest.json b/homeassistant/components/foursquare/manifest.json index 84a98ca033625..39e4f897d5fcd 100644 --- a/homeassistant/components/foursquare/manifest.json +++ b/homeassistant/components/foursquare/manifest.json @@ -1,12 +1,7 @@ { "domain": "foursquare", "name": "Foursquare", - "documentation": "https://www.home-assistant.io/components/foursquare", - "requirements": [], - "dependencies": [ - "http" - ], - "codeowners": [ - "@robbiet480" - ] + "documentation": "https://www.home-assistant.io/integrations/foursquare", + "dependencies": ["http"], + "codeowners": ["@robbiet480"] } diff --git a/homeassistant/components/foursquare/services.yaml b/homeassistant/components/foursquare/services.yaml index 3d15a9583f64f..0fcc077c7d3cc 100644 --- a/homeassistant/components/foursquare/services.yaml +++ b/homeassistant/components/foursquare/services.yaml @@ -1,29 +1,45 @@ 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 + 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 + [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 + 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} + 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/manifest.json b/homeassistant/components/free_mobile/manifest.json index b8a40c3fc1d2a..1cdef3d1162aa 100644 --- a/homeassistant/components/free_mobile/manifest.json +++ b/homeassistant/components/free_mobile/manifest.json @@ -1,10 +1,7 @@ { "domain": "free_mobile", - "name": "Free mobile", - "documentation": "https://www.home-assistant.io/components/free_mobile", - "requirements": [ - "freesms==0.1.2" - ], - "dependencies": [], + "name": "Free Mobile", + "documentation": "https://www.home-assistant.io/integrations/free_mobile", + "requirements": ["freesms==0.1.2"], "codeowners": [] } diff --git a/homeassistant/components/free_mobile/notify.py b/homeassistant/components/free_mobile/notify.py index c7dacd44019d4..a4351bfe67884 100644 --- a/homeassistant/components/free_mobile/notify.py +++ b/homeassistant/components/free_mobile/notify.py @@ -1,26 +1,29 @@ -"""Support for thr Free Mobile SMS platform.""" +"""Support for Free Mobile SMS platform.""" import logging +from freesms import FreeClient import voluptuous as vol -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_USERNAME +from homeassistant.components.notify import PLATFORM_SCHEMA, BaseNotificationService +from homeassistant.const import ( + CONF_ACCESS_TOKEN, + CONF_USERNAME, + HTTP_BAD_REQUEST, + HTTP_FORBIDDEN, + HTTP_INTERNAL_SERVER_ERROR, +) 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, -}) +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]) + return FreeSMSNotificationService(config[CONF_USERNAME], config[CONF_ACCESS_TOKEN]) class FreeSMSNotificationService(BaseNotificationService): @@ -28,18 +31,17 @@ class FreeSMSNotificationService(BaseNotificationService): 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: + if resp.status_code == HTTP_BAD_REQUEST: _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: + elif resp.status_code == HTTP_FORBIDDEN: _LOGGER.error("Wrong Username/Password") - elif resp.status_code == 500: + elif resp.status_code == HTTP_INTERNAL_SERVER_ERROR: _LOGGER.error("Server error, try later") diff --git a/homeassistant/components/freebox/__init__.py b/homeassistant/components/freebox/__init__.py index 2cd9f6b35721d..9e303c75e7af3 100644 --- a/homeassistant/components/freebox/__init__.py +++ b/homeassistant/components/freebox/__init__.py @@ -1,27 +1,28 @@ """Support for Freebox devices (Freebox v6 and Freebox mini 4K).""" +import asyncio import logging -import socket import voluptuous as vol from homeassistant.components.discovery import SERVICE_FREEBOX +from homeassistant.config_entries import SOURCE_DISCOVERY, SOURCE_IMPORT, ConfigEntry 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 +from homeassistant.helpers.typing import HomeAssistantType -_LOGGER = logging.getLogger(__name__) +from .const import DOMAIN, PLATFORMS +from .router import FreeboxRouter -DOMAIN = "freebox" -DATA_FREEBOX = DOMAIN +_LOGGER = logging.getLogger(__name__) -FREEBOX_CONFIG_FILE = 'freebox.conf' +FREEBOX_SCHEMA = vol.Schema( + {vol.Required(CONF_HOST): cv.string, vol.Required(CONF_PORT): cv.port} +) -CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_PORT): cv.port, - }) -}, extra=vol.ALLOW_EXTRA) +CONFIG_SCHEMA = vol.Schema( + {DOMAIN: vol.Schema(vol.All(cv.ensure_list, [FREEBOX_SCHEMA]))}, + extra=vol.ALLOW_EXTRA, +) async def async_setup(hass, config): @@ -30,57 +31,73 @@ async def async_setup(hass, config): 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') + 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) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DISCOVERY}, + data={CONF_HOST: host, CONF_PORT: 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) + if conf is None: + return True + + for freebox_conf in conf: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=freebox_conf, + ) + ) + + return True + + +async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry): + """Set up Freebox component.""" + router = FreeboxRouter(hass, entry) + await router.setup() + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.unique_id] = router + + for platform in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, platform) + ) + + # Services + async def async_reboot(call): + """Handle reboot service call.""" + await router.reboot() + + hass.services.async_register(DOMAIN, "reboot", async_reboot) + + async def async_close_connection(event): + """Close Freebox connection on HA Stop.""" + await router.close() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_close_connection) 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) +async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, platform) + for platform in PLATFORMS + ] + ) + ) + if unload_ok: + router = hass.data[DOMAIN].pop(entry.unique_id) + await router.close() + + return unload_ok diff --git a/homeassistant/components/freebox/config_flow.py b/homeassistant/components/freebox/config_flow.py new file mode 100644 index 0000000000000..b2d1a0ab771c8 --- /dev/null +++ b/homeassistant/components/freebox/config_flow.py @@ -0,0 +1,110 @@ +"""Config flow to configure the Freebox integration.""" +import logging + +from aiofreepybox.exceptions import AuthorizationError, HttpRequestError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST, CONF_PORT + +from .const import DOMAIN # pylint: disable=unused-import +from .router import get_api + +_LOGGER = logging.getLogger(__name__) + + +class FreeboxFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + def __init__(self): + """Initialize Freebox config flow.""" + self._host = None + self._port = None + + def _show_setup_form(self, user_input=None, errors=None): + """Show the setup form to the user.""" + + if user_input is None: + user_input = {} + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_HOST, default=user_input.get(CONF_HOST, "")): str, + vol.Required(CONF_PORT, default=user_input.get(CONF_PORT, "")): int, + } + ), + errors=errors or {}, + ) + + async def async_step_user(self, user_input=None): + """Handle a flow initiated by the user.""" + errors = {} + + if user_input is None: + return self._show_setup_form(user_input, errors) + + self._host = user_input[CONF_HOST] + self._port = user_input[CONF_PORT] + + # Check if already configured + await self.async_set_unique_id(self._host) + self._abort_if_unique_id_configured() + + return await self.async_step_link() + + async def async_step_link(self, user_input=None): + """Attempt to link with the Freebox router. + + Given a configured host, will ask the user to press the button + to connect to the router. + """ + if user_input is None: + return self.async_show_form(step_id="link") + + errors = {} + + fbx = await get_api(self.hass, self._host) + try: + # Open connection and check authentication + await fbx.open(self._host, self._port) + + # Check permissions + await fbx.system.get_config() + await fbx.lan.get_hosts_list() + await self.hass.async_block_till_done() + + # Close connection + await fbx.close() + + return self.async_create_entry( + title=self._host, data={CONF_HOST: self._host, CONF_PORT: self._port}, + ) + + except AuthorizationError as error: + _LOGGER.error(error) + errors["base"] = "register_failed" + + except HttpRequestError: + _LOGGER.error("Error connecting to the Freebox router at %s", self._host) + errors["base"] = "connection_failed" + + except Exception: # pylint: disable=broad-except + _LOGGER.exception( + "Unknown error connecting with Freebox router at %s", self._host + ) + errors["base"] = "unknown" + + return self.async_show_form(step_id="link", errors=errors) + + async def async_step_import(self, user_input=None): + """Import a config entry.""" + return await self.async_step_user(user_input) + + async def async_step_discovery(self, user_input=None): + """Initialize step from discovery.""" + return await self.async_step_user(user_input) diff --git a/homeassistant/components/freebox/const.py b/homeassistant/components/freebox/const.py new file mode 100644 index 0000000000000..0612e4e76f19a --- /dev/null +++ b/homeassistant/components/freebox/const.py @@ -0,0 +1,75 @@ +"""Freebox component constants.""" +import socket + +from homeassistant.const import ( + DATA_RATE_KILOBYTES_PER_SECOND, + DEVICE_CLASS_TEMPERATURE, + TEMP_CELSIUS, +) + +DOMAIN = "freebox" + +APP_DESC = { + "app_id": "hass", + "app_name": "Home Assistant", + "app_version": "0.106", + "device_name": socket.gethostname(), +} +API_VERSION = "v6" + +PLATFORMS = ["device_tracker", "sensor", "switch"] + +DEFAULT_DEVICE_NAME = "Unknown device" + +# to store the cookie +STORAGE_KEY = DOMAIN +STORAGE_VERSION = 1 + +# Sensor +SENSOR_NAME = "name" +SENSOR_UNIT = "unit" +SENSOR_ICON = "icon" +SENSOR_DEVICE_CLASS = "device_class" + +CONNECTION_SENSORS = { + "rate_down": { + SENSOR_NAME: "Freebox download speed", + SENSOR_UNIT: DATA_RATE_KILOBYTES_PER_SECOND, + SENSOR_ICON: "mdi:download-network", + SENSOR_DEVICE_CLASS: None, + }, + "rate_up": { + SENSOR_NAME: "Freebox upload speed", + SENSOR_UNIT: DATA_RATE_KILOBYTES_PER_SECOND, + SENSOR_ICON: "mdi:upload-network", + SENSOR_DEVICE_CLASS: None, + }, +} + +TEMPERATURE_SENSOR_TEMPLATE = { + SENSOR_NAME: None, + SENSOR_UNIT: TEMP_CELSIUS, + SENSOR_ICON: "mdi:thermometer", + SENSOR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, +} + +# Icons +DEVICE_ICONS = { + "freebox_delta": "mdi:television-guide", + "freebox_hd": "mdi:television-guide", + "freebox_mini": "mdi:television-guide", + "freebox_player": "mdi:television-guide", + "ip_camera": "mdi:cctv", + "ip_phone": "mdi:phone-voip", + "laptop": "mdi:laptop", + "multimedia_device": "mdi:play-network", + "nas": "mdi:nas", + "networking_device": "mdi:network", + "printer": "mdi:printer", + "router": "mdi:router-wireless", + "smartphone": "mdi:cellphone", + "tablet": "mdi:tablet", + "television": "mdi:television", + "vg_console": "mdi:gamepad-variant", + "workstation": "mdi:desktop-tower-monitor", +} diff --git a/homeassistant/components/freebox/device_tracker.py b/homeassistant/components/freebox/device_tracker.py index 40c1967f60f6e..ea9919f57420f 100644 --- a/homeassistant/components/freebox/device_tracker.py +++ b/homeassistant/components/freebox/device_tracker.py @@ -1,65 +1,148 @@ """Support for Freebox devices (Freebox v6 and Freebox mini 4K).""" -from collections import namedtuple +from datetime import datetime import logging +from typing import Dict -from homeassistant.components.device_tracker import DeviceScanner +from homeassistant.components.device_tracker import SOURCE_TYPE_ROUTER +from homeassistant.components.device_tracker.config_entry import ScannerEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import callback +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.typing import HomeAssistantType -from . import DATA_FREEBOX +from .const import DEFAULT_DEVICE_NAME, DEVICE_ICONS, DOMAIN +from .router import FreeboxRouter _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 +async def async_setup_entry( + hass: HomeAssistantType, entry: ConfigEntry, async_add_entities +) -> None: + """Set up device tracker for Freebox component.""" + router = hass.data[DOMAIN][entry.unique_id] + tracked = set() + + @callback + def update_router(): + """Update the values of the router.""" + add_entities(router, async_add_entities, tracked) + + router.listeners.append( + async_dispatcher_connect(hass, router.signal_device_new, update_router) + ) + + update_router() + + +@callback +def add_entities(router, async_add_entities, tracked): + """Add new tracker entities from the router.""" + new_tracked = [] + + for mac, device in router.devices.items(): + if mac in tracked: + continue + + new_tracked.append(FreeboxDevice(router, device)) + tracked.add(mac) + + if new_tracked: + async_add_entities(new_tracked, True) + + +class FreeboxDevice(ScannerEntity): + """Representation of a Freebox device.""" + + def __init__(self, router: FreeboxRouter, device: Dict[str, any]) -> None: + """Initialize a Freebox device.""" + self._router = router + self._name = device["primary_name"].strip() or DEFAULT_DEVICE_NAME + self._mac = device["l2ident"]["id"] + self._manufacturer = device["vendor_name"] + self._icon = icon_for_freebox_device(device) + self._active = False + self._attrs = {} + + self._unsub_dispatcher = None + + def update(self) -> None: + """Update the Freebox device.""" + device = self._router.devices[self._mac] + self._active = device["active"] + if device.get("attrs") is None: + # device + self._attrs = { + "last_time_reachable": datetime.fromtimestamp( + device["last_time_reachable"] + ), + "last_time_activity": datetime.fromtimestamp(device["last_activity"]), + } + else: + # router + self._attrs = device["attrs"] + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return self._mac + + @property + def name(self) -> str: + """Return the name.""" + return self._name + + @property + def is_connected(self): + """Return true if the device is connected to the network.""" + return self._active + + @property + def source_type(self) -> str: + """Return the source type.""" + return SOURCE_TYPE_ROUTER + + @property + def icon(self) -> str: + """Return the icon.""" + return self._icon + + @property + def device_state_attributes(self) -> Dict[str, any]: + """Return the attributes.""" + return self._attrs + + @property + def device_info(self) -> Dict[str, any]: + """Return the device information.""" + return { + "connections": {(CONNECTION_NETWORK_MAC, self._mac)}, + "identifiers": {(DOMAIN, self.unique_id)}, + "name": self.name, + "manufacturer": self._manufacturer, + } + + @property + def should_poll(self) -> bool: + """No polling needed.""" + return False + + async def async_on_demand_update(self): + """Update state.""" + self.async_schedule_update_ha_state(True) + + async def async_added_to_hass(self): + """Register state update callback.""" + self._unsub_dispatcher = async_dispatcher_connect( + self.hass, self._router.signal_device_update, self.async_on_demand_update + ) + + async def async_will_remove_from_hass(self): + """Clean up after entity before removal.""" + self._unsub_dispatcher() + + +def icon_for_freebox_device(device) -> str: + """Return a host icon from his type.""" + return DEVICE_ICONS.get(device["host_type"], "mdi:help-network") diff --git a/homeassistant/components/freebox/manifest.json b/homeassistant/components/freebox/manifest.json index 9ee134d41709f..ae96f7f6510c5 100644 --- a/homeassistant/components/freebox/manifest.json +++ b/homeassistant/components/freebox/manifest.json @@ -1,12 +1,9 @@ { "domain": "freebox", "name": "Freebox", - "documentation": "https://www.home-assistant.io/components/freebox", - "requirements": [ - "aiofreepybox==0.0.8" - ], - "dependencies": [], - "codeowners": [ - "@snoof85" - ] + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/freebox", + "requirements": ["aiofreepybox==0.0.8"], + "after_dependencies": ["discovery"], + "codeowners": ["@snoof85", "@Quentame"] } diff --git a/homeassistant/components/freebox/router.py b/homeassistant/components/freebox/router.py new file mode 100644 index 0000000000000..7b4784c6ca47b --- /dev/null +++ b/homeassistant/components/freebox/router.py @@ -0,0 +1,193 @@ +"""Represent the Freebox router and its devices and sensors.""" +from datetime import datetime, timedelta +import logging +from pathlib import Path +from typing import Dict, Optional + +from aiofreepybox import Freepybox +from aiofreepybox.api.wifi import Wifi +from aiofreepybox.exceptions import HttpRequestError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.util import slugify + +from .const import ( + API_VERSION, + APP_DESC, + CONNECTION_SENSORS, + DOMAIN, + STORAGE_KEY, + STORAGE_VERSION, +) + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(seconds=30) + + +class FreeboxRouter: + """Representation of a Freebox router.""" + + def __init__(self, hass: HomeAssistantType, entry: ConfigEntry) -> None: + """Initialize a Freebox router.""" + self.hass = hass + self._entry = entry + self._host = entry.data[CONF_HOST] + self._port = entry.data[CONF_PORT] + + self._api: Freepybox = None + self._name = None + self.mac = None + self._sw_v = None + self._attrs = {} + + self.devices: Dict[str, any] = {} + self.sensors_temperature: Dict[str, int] = {} + self.sensors_connection: Dict[str, float] = {} + + self.listeners = [] + + async def setup(self) -> None: + """Set up a Freebox router.""" + self._api = await get_api(self.hass, self._host) + + try: + await self._api.open(self._host, self._port) + except HttpRequestError: + _LOGGER.exception("Failed to connect to Freebox") + return ConfigEntryNotReady + + # System + fbx_config = await self._api.system.get_config() + self.mac = fbx_config["mac"] + self._name = fbx_config["model_info"]["pretty_name"] + self._sw_v = fbx_config["firmware_version"] + + # Devices & sensors + await self.update_all() + async_track_time_interval(self.hass, self.update_all, SCAN_INTERVAL) + + async def update_all(self, now: Optional[datetime] = None) -> None: + """Update all Freebox platforms.""" + await self.update_sensors() + await self.update_devices() + + async def update_devices(self) -> None: + """Update Freebox devices.""" + new_device = False + fbx_devices: Dict[str, any] = await self._api.lan.get_hosts_list() + + # Adds the Freebox itself + fbx_devices.append( + { + "primary_name": self._name, + "l2ident": {"id": self.mac}, + "vendor_name": "Freebox SAS", + "host_type": "router", + "active": True, + "attrs": self._attrs, + } + ) + + for fbx_device in fbx_devices: + device_mac = fbx_device["l2ident"]["id"] + + if self.devices.get(device_mac) is None: + new_device = True + + self.devices[device_mac] = fbx_device + + async_dispatcher_send(self.hass, self.signal_device_update) + + if new_device: + async_dispatcher_send(self.hass, self.signal_device_new) + + async def update_sensors(self) -> None: + """Update Freebox sensors.""" + # System sensors + syst_datas: Dict[str, any] = await self._api.system.get_config() + + # According to the doc `syst_datas["sensors"]` is temperature sensors in celsius degree. + # Name and id of sensors may vary under Freebox devices. + for sensor in syst_datas["sensors"]: + self.sensors_temperature[sensor["name"]] = sensor["value"] + + # Connection sensors + connection_datas: Dict[str, any] = await self._api.connection.get_status() + for sensor_key in CONNECTION_SENSORS: + self.sensors_connection[sensor_key] = connection_datas[sensor_key] + + self._attrs = { + "IPv4": connection_datas.get("ipv4"), + "IPv6": connection_datas.get("ipv6"), + "connection_type": connection_datas["media"], + "uptime": datetime.fromtimestamp( + round(datetime.now().timestamp()) - syst_datas["uptime_val"] + ), + "firmware_version": self._sw_v, + "serial": syst_datas["serial"], + } + + async_dispatcher_send(self.hass, self.signal_sensor_update) + + async def reboot(self) -> None: + """Reboot the Freebox.""" + await self._api.system.reboot() + + async def close(self) -> None: + """Close the connection.""" + if self._api is not None: + await self._api.close() + self._api = None + + @property + def device_info(self) -> Dict[str, any]: + """Return the device information.""" + return { + "connections": {(CONNECTION_NETWORK_MAC, self.mac)}, + "identifiers": {(DOMAIN, self.mac)}, + "name": self._name, + "manufacturer": "Freebox SAS", + "sw_version": self._sw_v, + } + + @property + def signal_device_new(self) -> str: + """Event specific per Freebox entry to signal new device.""" + return f"{DOMAIN}-{self._host}-device-new" + + @property + def signal_device_update(self) -> str: + """Event specific per Freebox entry to signal updates in devices.""" + return f"{DOMAIN}-{self._host}-device-update" + + @property + def signal_sensor_update(self) -> str: + """Event specific per Freebox entry to signal updates in sensors.""" + return f"{DOMAIN}-{self._host}-sensor-update" + + @property + def sensors(self) -> Wifi: + """Return the wifi.""" + return {**self.sensors_temperature, **self.sensors_connection} + + @property + def wifi(self) -> Wifi: + """Return the wifi.""" + return self._api.wifi + + +async def get_api(hass: HomeAssistantType, host: str) -> Freepybox: + """Get the Freebox API.""" + freebox_path = Path(hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY).path) + freebox_path.mkdir(exist_ok=True) + + token_file = Path(f"{freebox_path}/{slugify(host)}.conf") + + return Freepybox(APP_DESC, token_file, API_VERSION) diff --git a/homeassistant/components/freebox/sensor.py b/homeassistant/components/freebox/sensor.py index 8dcc5f54b2e67..a3c5c32901caa 100644 --- a/homeassistant/components/freebox/sensor.py +++ b/homeassistant/components/freebox/sensor.py @@ -1,77 +1,127 @@ """Support for Freebox devices (Freebox v6 and Freebox mini 4K).""" import logging +from typing import Dict +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import DATA_RATE_KILOBYTES_PER_SECOND +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity - -from . import DATA_FREEBOX +from homeassistant.helpers.typing import HomeAssistantType + +from .const import ( + CONNECTION_SENSORS, + DOMAIN, + SENSOR_DEVICE_CLASS, + SENSOR_ICON, + SENSOR_NAME, + SENSOR_UNIT, + TEMPERATURE_SENSOR_TEMPLATE, +) +from .router import FreeboxRouter _LOGGER = logging.getLogger(__name__) -async def async_setup_platform( - hass, config, async_add_entities, discovery_info=None): +async def async_setup_entry( + hass: HomeAssistantType, entry: ConfigEntry, async_add_entities +) -> None: """Set up the sensors.""" - fbx = hass.data[DATA_FREEBOX] - async_add_entities([FbxRXSensor(fbx), FbxTXSensor(fbx)], True) + router = hass.data[DOMAIN][entry.unique_id] + entities = [] + + for sensor_name in router.sensors_temperature: + entities.append( + FreeboxSensor( + router, + sensor_name, + {**TEMPERATURE_SENSOR_TEMPLATE, SENSOR_NAME: f"Freebox {sensor_name}"}, + ) + ) + for sensor_key in CONNECTION_SENSORS: + entities.append( + FreeboxSensor(router, sensor_key, CONNECTION_SENSORS[sensor_key]) + ) -class FbxSensor(Entity): - """Representation of a freebox sensor.""" + async_add_entities(entities, True) - _name = 'generic' - def __init__(self, fbx): - """Initialize the sensor.""" - self._fbx = fbx +class FreeboxSensor(Entity): + """Representation of a Freebox sensor.""" + + def __init__( + self, router: FreeboxRouter, sensor_type: str, sensor: Dict[str, any] + ) -> None: + """Initialize a Freebox sensor.""" self._state = None - self._datas = None + self._router = router + self._sensor_type = sensor_type + self._name = sensor[SENSOR_NAME] + self._unit = sensor[SENSOR_UNIT] + self._icon = sensor[SENSOR_ICON] + self._device_class = sensor[SENSOR_DEVICE_CLASS] + self._unique_id = f"{self._router.mac} {self._name}" + + self._unsub_dispatcher = None + + def update(self) -> None: + """Update the Freebox sensor.""" + state = self._router.sensors[self._sensor_type] + if self._unit == DATA_RATE_KILOBYTES_PER_SECOND: + self._state = round(state / 1000, 2) + else: + self._state = state + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return self._unique_id @property - def name(self): - """Return the name of the sensor.""" + def name(self) -> str: + """Return the name.""" return self._name @property - def state(self): - """Return the state of the sensor.""" + def state(self) -> str: + """Return the state.""" 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.""" + def unit_of_measurement(self) -> str: + """Return 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.""" + @property + def icon(self) -> str: + """Return the icon.""" + return self._icon - _name = 'Freebox upload speed' - _unit = 'KB/s' + @property + def device_class(self) -> str: + """Return the device_class.""" + return self._device_class @property - def unit_of_measurement(self): - """Define the unit.""" - return self._unit + def device_info(self) -> Dict[str, any]: + """Return the device information.""" + return self._router.device_info - 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) + @property + def should_poll(self) -> bool: + """No polling needed.""" + return False + + async def async_on_demand_update(self): + """Update state.""" + self.async_schedule_update_ha_state(True) + + async def async_added_to_hass(self): + """Register state update callback.""" + self._unsub_dispatcher = async_dispatcher_connect( + self.hass, self._router.signal_sensor_update, self.async_on_demand_update + ) + + async def async_will_remove_from_hass(self): + """Clean up after entity before removal.""" + self._unsub_dispatcher() diff --git a/homeassistant/components/freebox/services.yaml b/homeassistant/components/freebox/services.yaml new file mode 100644 index 0000000000000..be7afa60562bf --- /dev/null +++ b/homeassistant/components/freebox/services.yaml @@ -0,0 +1,5 @@ +# Freebox service entries description. + +reboot: + # Description of the service + description: Reboots the Freebox. diff --git a/homeassistant/components/freebox/strings.json b/homeassistant/components/freebox/strings.json new file mode 100644 index 0000000000000..72265a54558d0 --- /dev/null +++ b/homeassistant/components/freebox/strings.json @@ -0,0 +1,20 @@ +{ + "config": { + "step": { + "user": { + "title": "Freebox", + "data": { "host": "Host", "port": "Port" } + }, + "link": { + "title": "Link Freebox router", + "description": "Click \"Submit\", then touch the right arrow on the router to register Freebox with Home Assistant.\n\n![Location of button on the router](/static/images/config_freebox.png)" + } + }, + "error": { + "register_failed": "Failed to register, please try again", + "connection_failed": "Failed to connect, please try again", + "unknown": "Unknown error: please retry later" + }, + "abort": { "already_configured": "Host already configured" } + } +} diff --git a/homeassistant/components/freebox/switch.py b/homeassistant/components/freebox/switch.py index e0c24d2b9f9f2..7f8934d9d65f0 100644 --- a/homeassistant/components/freebox/switch.py +++ b/homeassistant/components/freebox/switch.py @@ -1,50 +1,66 @@ """Support for Freebox Delta, Revolution and Mini 4K.""" import logging +from typing import Dict -from homeassistant.components.switch import SwitchDevice +from aiofreepybox.exceptions import InsufficientPermissionsError -from . import DATA_FREEBOX +from homeassistant.components.switch import SwitchEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.typing import HomeAssistantType + +from .const import DOMAIN +from .router import FreeboxRouter _LOGGER = logging.getLogger(__name__) -async def async_setup_platform( - hass, config, async_add_entities, discovery_info=None): +async def async_setup_entry( + hass: HomeAssistantType, entry: ConfigEntry, async_add_entities +) -> None: """Set up the switch.""" - fbx = hass.data[DATA_FREEBOX] - async_add_entities([FbxWifiSwitch(fbx)], True) + router = hass.data[DOMAIN][entry.unique_id] + async_add_entities([FreeboxWifiSwitch(router)], True) -class FbxWifiSwitch(SwitchDevice): +class FreeboxWifiSwitch(SwitchEntity): """Representation of a freebox wifi switch.""" - def __init__(self, fbx): - """Initilize the Wifi switch.""" - self._name = 'Freebox WiFi' + def __init__(self, router: FreeboxRouter) -> None: + """Initialize the Wifi switch.""" + self._name = "Freebox WiFi" self._state = None - self._fbx = fbx + self._router = router + self._unique_id = f"{self._router.mac} {self._name}" + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return self._unique_id @property - def name(self): + def name(self) -> str: """Return the name of the switch.""" return self._name @property - def is_on(self): + def is_on(self) -> bool: """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 + @property + def device_info(self) -> Dict[str, any]: + """Return the device information.""" + return self._router.device_info + async def _async_set_state(self, enabled: bool): + """Turn the switch on or off.""" wifi_config = {"enabled": enabled} try: - await self._fbx.wifi.set_global_config(wifi_config) + await self._router.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.') + _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.""" @@ -56,6 +72,6 @@ async def async_turn_off(self, **kwargs): async def async_update(self): """Get the state and update it.""" - datas = await self._fbx.wifi.get_global_config() - active = datas['enabled'] + datas = await self._router.wifi.get_global_config() + active = datas["enabled"] self._state = bool(active) diff --git a/homeassistant/components/freebox/translations/ca.json b/homeassistant/components/freebox/translations/ca.json new file mode 100644 index 0000000000000..264e0ed303869 --- /dev/null +++ b/homeassistant/components/freebox/translations/ca.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "L'amfitri\u00f3 ja est\u00e0 configurat" + }, + "error": { + "connection_failed": "No s'ha pogut connectar, torna-ho a provar", + "register_failed": "No s'ha pogut registrar, torna-ho a provar", + "unknown": "Error desconegut: torna-ho a provar m\u00e9s tard" + }, + "step": { + "link": { + "description": "Prem \"Envia\", a continuaci\u00f3, toca la fletxa dreta del router per registrar Freebox amb Home Assistant.\n\n![Ubicaci\u00f3 del boto del router](/static/images/config_freebox.png)", + "title": "Enlla\u00e7 amb router Freebox" + }, + "user": { + "data": { + "host": "Amfitri\u00f3", + "port": "Port" + }, + "title": "Freebox" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/freebox/translations/de.json b/homeassistant/components/freebox/translations/de.json new file mode 100644 index 0000000000000..cf18dce087084 --- /dev/null +++ b/homeassistant/components/freebox/translations/de.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "Host bereits konfiguriert" + }, + "error": { + "connection_failed": "Verbindung fehlgeschlagen, versuchen Sie es erneut", + "register_failed": "Registrieren fehlgeschlagen, bitte versuche es erneut", + "unknown": "Unbekannter Fehler: Bitte versuchen Sie es sp\u00e4ter erneut" + }, + "step": { + "link": { + "description": "Klicken Sie auf \"Senden\" und ber\u00fchren Sie dann den Pfeil nach rechts auf dem Router, um Freebox bei Home Assistant zu registrieren. \n\n ![Position der Schaltfl\u00e4che am Router]\n (/static/images/config_freebox.png)", + "title": "Link Freebox Router" + }, + "user": { + "data": { + "host": "Host", + "port": "Port" + }, + "title": "Freebox" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/freebox/translations/en.json b/homeassistant/components/freebox/translations/en.json new file mode 100644 index 0000000000000..15e18a8982b3e --- /dev/null +++ b/homeassistant/components/freebox/translations/en.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "Host already configured" + }, + "error": { + "connection_failed": "Failed to connect, please try again", + "register_failed": "Failed to register, please try again", + "unknown": "Unknown error: please retry later" + }, + "step": { + "link": { + "description": "Click \"Submit\", then touch the right arrow on the router to register Freebox with Home Assistant.\n\n![Location of button on the router](/static/images/config_freebox.png)", + "title": "Link Freebox router" + }, + "user": { + "data": { + "host": "Host", + "port": "Port" + }, + "title": "Freebox" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/freebox/translations/es.json b/homeassistant/components/freebox/translations/es.json new file mode 100644 index 0000000000000..3c62f33c3be27 --- /dev/null +++ b/homeassistant/components/freebox/translations/es.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "El host ya est\u00e1 configurado." + }, + "error": { + "connection_failed": "No se ha podido conectar, por favor, int\u00e9ntalo de nuevo.", + "register_failed": "No se pudo registrar, int\u00e9ntalo de nuevo", + "unknown": "Error desconocido: por favor, int\u00e9ntalo de nuevo m\u00e1s" + }, + "step": { + "link": { + "description": "Pulsa \"Enviar\", despu\u00e9s pulsa en la flecha derecha en el router para registrar Freebox con Home Assistant\n\n![Localizaci\u00f3n del bot\u00f3n en el router](/static/images/config_freebox.png)", + "title": "Enlazar router Freebox" + }, + "user": { + "data": { + "host": "Host", + "port": "Puerto" + }, + "title": "Freebox" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/freebox/translations/fr.json b/homeassistant/components/freebox/translations/fr.json new file mode 100644 index 0000000000000..3854823843bc5 --- /dev/null +++ b/homeassistant/components/freebox/translations/fr.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "H\u00f4te d\u00e9j\u00e0 configur\u00e9" + }, + "error": { + "connection_failed": "Impossible de se connecter, veuillez r\u00e9essayer", + "register_failed": "\u00c9chec de l'inscription, veuillez r\u00e9essayer", + "unknown": "Erreur inconnue: veuillez r\u00e9essayer plus tard" + }, + "step": { + "link": { + "description": "Cliquez sur \u00abSoumettre\u00bb, puis appuyez sur la fl\u00e8che droite du routeur pour enregistrer Freebox avec Home Assistant. \n\n ! [Emplacement du bouton sur le routeur](/static/images/config_freebox.png)" + }, + "user": { + "data": { + "host": "H\u00f4te", + "port": "Port" + }, + "title": "Freebox" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/freebox/translations/it.json b/homeassistant/components/freebox/translations/it.json new file mode 100644 index 0000000000000..11d27eebd693c --- /dev/null +++ b/homeassistant/components/freebox/translations/it.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "Host gi\u00e0 configurato" + }, + "error": { + "connection_failed": "Impossibile connettersi, si prega di riprovare", + "register_failed": "Errore in fase di registrazione, si prega di riprovare", + "unknown": "Errore sconosciuto: riprovare pi\u00f9 tardi" + }, + "step": { + "link": { + "description": "Fare clic su \"Invia\", quindi toccare la freccia destra sul router per registrare Freebox con Home Assistant.\n\n![Posizione del pulsante sul router](/static/images/config_freebox.png)", + "title": "Collega il router Freebox" + }, + "user": { + "data": { + "host": "Host", + "port": "Porta" + }, + "title": "Freebox" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/freebox/translations/ko.json b/homeassistant/components/freebox/translations/ko.json new file mode 100644 index 0000000000000..eca391cbd9fd3 --- /dev/null +++ b/homeassistant/components/freebox/translations/ko.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "\ud638\uc2a4\ud2b8\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + }, + "error": { + "connection_failed": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.", + "register_failed": "\ub4f1\ub85d\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.", + "unknown": "\uc54c \uc218 \uc5c6\ub294 \uc624\ub958\uc785\ub2c8\ub2e4. \ub098\uc911\uc5d0 \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694" + }, + "step": { + "link": { + "description": "\"Submit\" \uc744 \ud074\ub9ad\ud55c \ub2e4\uc74c \ub77c\uc6b0\ud130\uc758 \uc624\ub978\ucabd \ud654\uc0b4\ud45c\ub97c \ud130\uce58\ud558\uc5ec Home Assistant \uc5d0 Freebox \ub97c \ub4f1\ub85d\ud574\uc8fc\uc138\uc694.\n\n![\ub77c\uc6b0\ud130\uc758 \ubc84\ud2bc \uc704\uce58](/static/images/config_freebox.png)", + "title": "Freebox \ub77c\uc6b0\ud130 \uc5f0\uacb0\ud558\uae30" + }, + "user": { + "data": { + "host": "\ud638\uc2a4\ud2b8", + "port": "\ud3ec\ud2b8" + }, + "title": "Freebox" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/freebox/translations/lb.json b/homeassistant/components/freebox/translations/lb.json new file mode 100644 index 0000000000000..eccc419c79b80 --- /dev/null +++ b/homeassistant/components/freebox/translations/lb.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "Apparat ass scho konfigur\u00e9iert" + }, + "error": { + "connection_failed": "Feeler beim verbannen, prob\u00e9ier w.e.g. nach emol.", + "register_failed": "Feeler beim registr\u00e9ieren, prob\u00e9ier w.e.g. nach emol", + "unknown": "Onbekannte Feeler: prob\u00e9iertsp\u00e9ider nach emol" + }, + "step": { + "link": { + "description": "Dr\u00e9ck \"Ofsch\u00e9cken\", dann dr\u00e9ck de rietse Feil um Router fir d'Freebox mam Home Assistant ze registr\u00e9ieren.\n\n![Location of button on the router](/static/images/config_freebox.png)", + "title": "Freebox Router verbannen" + }, + "user": { + "data": { + "host": "Apparat", + "port": "Port" + }, + "title": "Freebox" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/freebox/translations/nl.json b/homeassistant/components/freebox/translations/nl.json new file mode 100644 index 0000000000000..62c69997e1762 --- /dev/null +++ b/homeassistant/components/freebox/translations/nl.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "Host is al geconfigureerd." + }, + "error": { + "connection_failed": "Verbinding mislukt, probeer het opnieuw", + "register_failed": "Registratie is mislukt, probeer het opnieuw", + "unknown": "Onbekende fout: probeer het later nog eens" + }, + "step": { + "link": { + "description": "Klik op \"Verzenden\" en tik vervolgens op de rechterpijl op de router om Freebox te registreren bij Home Assistant. \n\n ! [Locatie van knop op de router] (/ static / images / config_freebox.png)", + "title": "Freebox-router koppelen" + }, + "user": { + "data": { + "host": "Host", + "port": "Poort" + }, + "title": "Freebox" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/freebox/translations/no.json b/homeassistant/components/freebox/translations/no.json new file mode 100644 index 0000000000000..0ec9bf70ecdaf --- /dev/null +++ b/homeassistant/components/freebox/translations/no.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "Verten er allerede konfigurert" + }, + "error": { + "connection_failed": "Klarte ikke \u00e5 koble til, vennligst pr\u00f8v igjen", + "register_failed": "Registrering feilet, vennligst pr\u00f8v igjen", + "unknown": "Ukjent feil: pr\u00f8v p\u00e5 nytt senere" + }, + "step": { + "link": { + "description": "Klikk p\u00e5 \"Submit\", deretter trykker du p\u00e5 den h\u00f8yre pilen p\u00e5 ruteren for \u00e5 registrere Freebox med Home Assistent.\n\n![Plasseringen av knappen p\u00e5 ruteren](/statisk/bilder/config_freebox.png)", + "title": "Link Freebox-ruter" + }, + "user": { + "data": { + "host": "Vert", + "port": "" + }, + "title": "" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/freebox/translations/pl.json b/homeassistant/components/freebox/translations/pl.json new file mode 100644 index 0000000000000..9df523af0fbf1 --- /dev/null +++ b/homeassistant/components/freebox/translations/pl.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "Host jest ju\u017c skonfigurowany." + }, + "error": { + "connection_failed": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia, spr\u00f3buj ponownie.", + "register_failed": "Nie uda\u0142o si\u0119 zarejestrowa\u0107. Spr\u00f3buj ponownie.", + "unknown": "Nieznany b\u0142\u0105d, spr\u00f3buj ponownie p\u00f3\u017aniej." + }, + "step": { + "link": { + "description": "Kliknij \"Zatwierd\u017a\", a nast\u0119pnie naci\u015bnij przycisk strza\u0142ki w prawo na routerze, aby zarejestrowa\u0107 Freebox w Home Assistan'cie. \n\n ![Lokalizacja przycisku na routerze] (/static/images/config_freebox.png)", + "title": "Po\u0142\u0105cz z routerem Freebox" + }, + "user": { + "data": { + "host": "Nazwa hosta lub adres IP", + "port": "Port" + }, + "title": "Freebox" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/freebox/translations/ru.json b/homeassistant/components/freebox/translations/ru.json new file mode 100644 index 0000000000000..1bef863e15f61 --- /dev/null +++ b/homeassistant/components/freebox/translations/ru.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u044d\u0442\u043e\u0433\u043e \u0445\u043e\u0441\u0442\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430." + }, + "error": { + "connection_failed": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f, \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0435\u0449\u0435 \u0440\u0430\u0437.", + "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.", + "unknown": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430: \u043f\u043e\u0432\u0442\u043e\u0440\u0438\u0442\u0435 \u043f\u043e\u043f\u044b\u0442\u043a\u0443 \u043f\u043e\u0437\u0436\u0435." + }, + "step": { + "link": { + "description": "\u041d\u0430\u0436\u043c\u0438\u0442\u0435 '\u041f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u0442\u044c', \u0437\u0430\u0442\u0435\u043c \u043d\u0430\u0436\u043c\u0438\u0442\u0435 \u043a\u043d\u043e\u043f\u043a\u0443 \u0441\u043e \u0441\u0442\u0440\u0435\u043b\u043a\u043e\u0439 \u043d\u0430 \u043c\u0430\u0440\u0448\u0440\u0443\u0442\u0438\u0437\u0430\u0442\u043e\u0440\u0435, \u0447\u0442\u043e\u0431\u044b \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u0442\u044c Freebox \u0432 Home Assistant. \n\n ![\u0420\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u043a\u043d\u043e\u043f\u043a\u0438 \u043d\u0430 \u0440\u043e\u0443\u0442\u0435\u0440\u0435](/static/images/config_freebox.png)", + "title": "Freebox" + }, + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442", + "port": "\u041f\u043e\u0440\u0442" + }, + "title": "Freebox" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/freebox/translations/sl.json b/homeassistant/components/freebox/translations/sl.json new file mode 100644 index 0000000000000..0a36450501b4c --- /dev/null +++ b/homeassistant/components/freebox/translations/sl.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "Gostitelj je \u017ee konfiguriran" + }, + "error": { + "connection_failed": "Povezava ni uspela, poskusite znova", + "register_failed": "Registracija ni uspela, poskusite znova", + "unknown": "Neznana napaka: poskusite pozneje" + }, + "step": { + "link": { + "description": "Kliknite \u00bbPo\u0161lji\u00ab, nato pa se dotaknite desne pu\u0161\u010dice na usmerjevalniku, \u010de \u017eelite registrirati Freebox pri programu Home Assistant. \n\n ! [Lokacija gumba na usmerjevalniku] (/static/images/config_freebox.png)", + "title": "Povezava usmerjevalnika Freebox" + }, + "user": { + "data": { + "host": "Gostitelj", + "port": "Vrata" + }, + "title": "Freebox" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/freebox/translations/sv.json b/homeassistant/components/freebox/translations/sv.json new file mode 100644 index 0000000000000..6c6cc5c64ecee --- /dev/null +++ b/homeassistant/components/freebox/translations/sv.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "V\u00e4rden \u00e4r redan konfigurerad." + }, + "error": { + "connection_failed": "Det gick inte att ansluta, f\u00f6rs\u00f6k igen", + "register_failed": "Misslyckades med att registrera, v\u00e4nligen f\u00f6rs\u00f6k igen", + "unknown": "Ok\u00e4nt fel: f\u00f6rs\u00f6k igen senare" + }, + "step": { + "user": { + "data": { + "host": "V\u00e4rd", + "port": "Port" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/freebox/translations/zh-Hant.json b/homeassistant/components/freebox/translations/zh-Hant.json new file mode 100644 index 0000000000000..be643ab9fd9ae --- /dev/null +++ b/homeassistant/components/freebox/translations/zh-Hant.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "\u4e3b\u6a5f\u7aef\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + }, + "error": { + "connection_failed": "\u9023\u7dda\u5931\u6557\uff0c\u8acb\u518d\u8a66\u4e00\u6b21", + "register_failed": "\u8a3b\u518a\u5931\u6557\uff0c\u8acb\u7a0d\u5f8c\u518d\u8a66", + "unknown": "\u672a\u77e5\u932f\u8aa4\uff1a\u8acb\u7a0d\u5f8c\u518d\u8a66" + }, + "step": { + "link": { + "description": "\u6309\u4e0b\u50b3\u9001 \"Submit\"\u3001\u63a5\u8457\u6309\u4e0b\u8def\u7531\u5668\u4e0a\u7684\u53f3\u7bad\u982d\u4ee5\u5c07 Freebox \u8a3b\u518a\u81f3 Home Assistant\u3002\n\n![Location of button on the router](/static/images/config_freebox.png)", + "title": "\u9023\u7d50 Freebox \u8def\u7531\u5668" + }, + "user": { + "data": { + "host": "\u4e3b\u6a5f\u7aef", + "port": "\u901a\u8a0a\u57e0" + }, + "title": "Freebox" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/freedns/__init__.py b/homeassistant/components/freedns/__init__.py index 1986c932e22cb..7aa34c8780e88 100644 --- a/homeassistant/components/freedns/__init__.py +++ b/homeassistant/components/freedns/__init__.py @@ -1,34 +1,38 @@ """Integrate with FreeDNS Dynamic DNS service at freedns.afraid.org.""" import asyncio -import logging from datetime import timedelta +import logging import aiohttp import async_timeout import voluptuous as vol +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_SCAN_INTERVAL, CONF_URL 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' +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) +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): @@ -40,8 +44,7 @@ async def async_setup(hass, config): session = hass.helpers.aiohttp_client.async_get_clientsession() - result = await _update_freedns( - hass, session, url, auth_token) + result = await _update_freedns(hass, session, url, auth_token) if result is False: return False @@ -51,7 +54,8 @@ async def update_domain_callback(now): await _update_freedns(hass, session, url, auth_token) hass.helpers.event.async_track_time_interval( - update_domain_callback, update_interval) + update_domain_callback, update_interval + ) return True @@ -68,7 +72,7 @@ async def _update_freedns(hass, session, url, auth_token): params[auth_token] = "" try: - with async_timeout.timeout(TIMEOUT, loop=hass.loop): + with async_timeout.timeout(TIMEOUT): resp = await session.get(url, params=params) body = await resp.text() diff --git a/homeassistant/components/freedns/manifest.json b/homeassistant/components/freedns/manifest.json index 63f929754db60..58e8e9fdaf822 100644 --- a/homeassistant/components/freedns/manifest.json +++ b/homeassistant/components/freedns/manifest.json @@ -1,8 +1,6 @@ { "domain": "freedns", - "name": "Freedns", - "documentation": "https://www.home-assistant.io/components/freedns", - "requirements": [], - "dependencies": [], + "name": "FreeDNS", + "documentation": "https://www.home-assistant.io/integrations/freedns", "codeowners": [] } diff --git a/homeassistant/components/fritz/device_tracker.py b/homeassistant/components/fritz/device_tracker.py index fc9f65633ff86..908cfd98a6ee7 100644 --- a/homeassistant/components/fritz/device_tracker.py +++ b/homeassistant/components/fritz/device_tracker.py @@ -1,22 +1,28 @@ """Support for FRITZ!Box routers.""" import logging +from fritzconnection.lib.fritzhosts import FritzHosts import voluptuous as vol -import homeassistant.helpers.config_validation as cv from homeassistant.components.device_tracker import ( - DOMAIN, PLATFORM_SCHEMA, DeviceScanner) + 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_DEFAULT_IP = '169.254.1.1' # This IP is valid for all FRITZ!Box routers. +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 -}) +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): @@ -36,12 +42,11 @@ def __init__(self, config): 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) + self.fritz_box = FritzHosts( + address=self.host, user=self.username, password=self.password + ) except (ValueError, TypeError): self.fritz_box = None @@ -51,36 +56,42 @@ def __init__(self, config): self.success_init = False if self.success_init: - _LOGGER.info("Successfully connected to %s", - self.fritz_box.modelname) + _LOGGER.info("Successfully connected to %s", self.fritz_box.modelname) self._update_info() else: - _LOGGER.error("Failed to establish connection to FRITZ!Box " - "with IP: %s", self.host) + _LOGGER.error( + "Failed to establish connection to FRITZ!Box with IP: %s", self.host + ) def scan_devices(self): """Scan for new devices and return a list of found device ids.""" self._update_info() active_hosts = [] for known_host in self.last_results: - if known_host['status'] == '1' and known_host.get('mac'): - active_hosts.append(known_host['mac']) + if known_host["status"] 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' - ) + ret = self.fritz_box.get_specific_host_entry(device).get("NewHostName") if ret == {}: return None return ret + def get_extra_attributes(self, device): + """Return the attributes (ip, mac) of the given device or None if is not known.""" + ip_device = self.fritz_box.get_specific_host_entry(device).get("NewIPAddress") + + if not ip_device: + return {} + return {"ip": ip_device, "mac": device} + def _update_info(self): """Retrieve latest information from the FRITZ!Box.""" if not self.success_init: return False - _LOGGER.info("Scanning") + _LOGGER.debug("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 index b2aacbd48ad79..3723bd7885ae2 100644 --- a/homeassistant/components/fritz/manifest.json +++ b/homeassistant/components/fritz/manifest.json @@ -1,10 +1,7 @@ { "domain": "fritz", - "name": "Fritz", - "documentation": "https://www.home-assistant.io/components/fritz", - "requirements": [ - "fritzconnection==0.6.5" - ], - "dependencies": [], + "name": "AVM FRITZ!Box", + "documentation": "https://www.home-assistant.io/integrations/fritz", + "requirements": ["fritzconnection==1.2.0"], "codeowners": [] } diff --git a/homeassistant/components/fritzbox/__init__.py b/homeassistant/components/fritzbox/__init__.py index 610c68741405b..7297f514f9665 100644 --- a/homeassistant/components/fritzbox/__init__.py +++ b/homeassistant/components/fritzbox/__init__.py @@ -1,79 +1,114 @@ """Support for AVM Fritz!Box smarthome devices.""" -import logging +import asyncio +import socket +from pyfritzhome import Fritzhome 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' + CONF_DEVICES, + CONF_HOST, + CONF_PASSWORD, + CONF_USERNAME, + EVENT_HOMEASSISTANT_STOP, +) +import homeassistant.helpers.config_validation as cv +from .const import CONF_CONNECTIONS, DEFAULT_HOST, DEFAULT_USERNAME, DOMAIN, PLATFORMS + + +def ensure_unique_hosts(value): + """Validate that all configs have a unique host.""" + vol.Schema(vol.Unique("duplicate host entries found"))( + [socket.gethostbyname(entry[CONF_HOST]) for entry in value] + ) + return value + + +CONFIG_SCHEMA = vol.Schema( + vol.All( + cv.deprecated(DOMAIN), + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_DEVICES): vol.All( + cv.ensure_list, + [ + vol.Schema( + { + vol.Required( + CONF_HOST, default=DEFAULT_HOST + ): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required( + CONF_USERNAME, default=DEFAULT_USERNAME + ): cv.string, + } + ) + ], + ensure_unique_hosts, + ) + } + ) + }, + ), + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass, config): + """Set up the AVM Fritz!Box integration.""" + if DOMAIN in config: + for entry_config in config[DOMAIN][CONF_DEVICES]: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": "import"}, data=entry_config + ) + ) -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) + return True -def setup(hass, config): - """Set up the fritzbox component.""" - from pyfritzhome import Fritzhome, LoginError +async def async_setup_entry(hass, entry): + """Set up the AVM Fritz!Box platforms.""" + fritz = Fritzhome( + host=entry.data[CONF_HOST], + user=entry.data[CONF_USERNAME], + password=entry.data[CONF_PASSWORD], + ) + await hass.async_add_executor_job(fritz.login) - fritz_list = [] + hass.data.setdefault(DOMAIN, {CONF_CONNECTIONS: {}, CONF_DEVICES: set()}) + hass.data[DOMAIN][CONF_CONNECTIONS][entry.entry_id] = fritz - 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 + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) - fritz_list.append(fritzbox) + def logout_fritzbox(event): + """Close connections to this fritzbox.""" + fritz.logout() - if not fritz_list: - _LOGGER.info("No fritzboxes configured") - return False + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, logout_fritzbox) - hass.data[DOMAIN] = fritz_list + return True - 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) +async def async_unload_entry(hass, entry): + """Unloading the AVM Fritz!Box platforms.""" + fritz = hass.data[DOMAIN][CONF_CONNECTIONS][entry.entry_id] + await hass.async_add_executor_job(fritz.logout) - for domain in SUPPORTED_DOMAINS: - discovery.load_platform(hass, domain, DOMAIN, {}, config) + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ] + ) + ) + if unload_ok: + hass.data[DOMAIN][CONF_CONNECTIONS].pop(entry.entry_id) - return True + return unload_ok diff --git a/homeassistant/components/fritzbox/binary_sensor.py b/homeassistant/components/fritzbox/binary_sensor.py index a763a3b3b0e4c..7db216c32e107 100644 --- a/homeassistant/components/fritzbox/binary_sensor.py +++ b/homeassistant/components/fritzbox/binary_sensor.py @@ -1,30 +1,27 @@ """Support for Fritzbox binary sensors.""" -import logging - import requests -from homeassistant.components.binary_sensor import BinarySensorDevice - -from . import DOMAIN as FRITZBOX_DOMAIN +from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.const import CONF_DEVICES -_LOGGER = logging.getLogger(__name__) +from .const import CONF_CONNECTIONS, DOMAIN as FRITZBOX_DOMAIN, LOGGER -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Fritzbox binary sensor platform.""" - devices = [] - fritz_list = hass.data[FRITZBOX_DOMAIN] +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Fritzbox binary sensor from config_entry.""" + entities = [] + devices = hass.data[FRITZBOX_DOMAIN][CONF_DEVICES] + fritz = hass.data[FRITZBOX_DOMAIN][CONF_CONNECTIONS][config_entry.entry_id] - for fritz in fritz_list: - device_list = fritz.get_devices() - for device in device_list: - if device.has_alarm: - devices.append(FritzboxBinarySensor(device, fritz)) + for device in await hass.async_add_executor_job(fritz.get_devices): + if device.has_alarm and device.ain not in devices: + entities.append(FritzboxBinarySensor(device, fritz)) + devices.add(device.ain) - add_entities(devices, True) + async_add_entities(entities, True) -class FritzboxBinarySensor(BinarySensorDevice): +class FritzboxBinarySensor(BinarySensorEntity): """Representation of a binary Fritzbox device.""" def __init__(self, device, fritz): @@ -32,6 +29,22 @@ def __init__(self, device, fritz): self._device = device self._fritz = fritz + @property + def device_info(self): + """Return device specific attributes.""" + return { + "name": self.name, + "identifiers": {(FRITZBOX_DOMAIN, self._device.ain)}, + "manufacturer": self._device.manufacturer, + "model": self._device.productname, + "sw_version": self._device.fw_version, + } + + @property + def unique_id(self): + """Return the unique ID of the device.""" + return self._device.ain + @property def name(self): """Return the name of the entity.""" @@ -40,7 +53,7 @@ def name(self): @property def device_class(self): """Return the class of this sensor.""" - return 'window' + return "window" @property def is_on(self): @@ -54,5 +67,5 @@ def update(self): try: self._device.update() except requests.exceptions.HTTPError as ex: - _LOGGER.warning("Connection error: %s", ex) + LOGGER.warning("Connection error: %s", ex) self._fritz.login() diff --git a/homeassistant/components/fritzbox/climate.py b/homeassistant/components/fritzbox/climate.py index 4dfa09c49fa96..4abe82776a9cc 100644 --- a/homeassistant/components/fritzbox/climate.py +++ b/homeassistant/components/fritzbox/climate.py @@ -1,30 +1,45 @@ """Support for AVM Fritz!Box smarthome thermostate devices.""" -import logging - import requests -from homeassistant.components.climate import ClimateDevice +from homeassistant.components.climate import ClimateEntity from homeassistant.components.climate.const import ( - ATTR_OPERATION_MODE, STATE_ECO, STATE_HEAT, STATE_MANUAL, - SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE) + ATTR_HVAC_MODE, + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + PRESET_COMFORT, + PRESET_ECO, + SUPPORT_PRESET_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] + ATTR_BATTERY_LEVEL, + ATTR_TEMPERATURE, + CONF_DEVICES, + PRECISION_HALVES, + TEMP_CELSIUS, +) + +from .const import ( + ATTR_STATE_BATTERY_LOW, + ATTR_STATE_DEVICE_LOCKED, + ATTR_STATE_HOLIDAY_MODE, + ATTR_STATE_LOCKED, + ATTR_STATE_SUMMER_MODE, + ATTR_STATE_WINDOW_OPEN, + CONF_CONNECTIONS, + DOMAIN as FRITZBOX_DOMAIN, + LOGGER, +) + +SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_PRESET_MODE + +OPERATION_LIST = [HVAC_MODE_HEAT, HVAC_MODE_OFF] MIN_TEMPERATURE = 8 MAX_TEMPERATURE = 28 +PRESET_MANUAL = "manual" + # special temperatures for on/off in Fritz!Box API (modified by pyfritzhome) ON_API_TEMPERATURE = 127.0 OFF_API_TEMPERATURE = 126.5 @@ -32,21 +47,21 @@ 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] +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Fritzbox smarthome thermostat from config_entry.""" + entities = [] + devices = hass.data[FRITZBOX_DOMAIN][CONF_DEVICES] + fritz = hass.data[FRITZBOX_DOMAIN][CONF_CONNECTIONS][config_entry.entry_id] - for fritz in fritz_list: - device_list = fritz.get_devices() - for device in device_list: - if device.has_thermostat: - devices.append(FritzboxThermostat(device, fritz)) + for device in await hass.async_add_executor_job(fritz.get_devices): + if device.has_thermostat and device.ain not in devices: + entities.append(FritzboxThermostat(device, fritz)) + devices.add(device.ain) - add_entities(devices) + async_add_entities(entities) -class FritzboxThermostat(ClimateDevice): +class FritzboxThermostat(ClimateEntity): """The thermostat class for Fritzbox smarthome thermostates.""" def __init__(self, device, fritz): @@ -58,6 +73,22 @@ def __init__(self, device, fritz): self._comfort_temperature = self._device.comfort_temperature self._eco_temperature = self._device.eco_temperature + @property + def device_info(self): + """Return device specific attributes.""" + return { + "name": self.name, + "identifiers": {(FRITZBOX_DOMAIN, self._device.ain)}, + "manufacturer": self._device.manufacturer, + "model": self._device.productname, + "sw_version": self._device.fw_version, + } + + @property + def unique_id(self): + """Return the unique ID of the device.""" + return self._device.ain + @property def supported_features(self): """Return the list of supported features.""" @@ -91,48 +122,63 @@ def current_temperature(self): @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 + if self._target_temperature == ON_API_TEMPERATURE: + return ON_REPORT_SET_TEMPERATURE + if self._target_temperature == OFF_API_TEMPERATURE: + return OFF_REPORT_SET_TEMPERATURE 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) + if ATTR_HVAC_MODE in kwargs: + hvac_mode = kwargs.get(ATTR_HVAC_MODE) + self.set_hvac_mode(hvac_mode) elif ATTR_TEMPERATURE in kwargs: temperature = kwargs.get(ATTR_TEMPERATURE) self._device.set_target_temperature(temperature) @property - def current_operation(self): + def hvac_mode(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 + if ( + self._target_temperature == OFF_REPORT_SET_TEMPERATURE + or self._target_temperature == OFF_API_TEMPERATURE + ): + return HVAC_MODE_OFF + + return HVAC_MODE_HEAT @property - def operation_list(self): + def hvac_modes(self): """Return the list of available operation modes.""" return OPERATION_LIST - def set_operation_mode(self, operation_mode): + def set_hvac_mode(self, hvac_mode): """Set new operation mode.""" - if operation_mode == STATE_HEAT: + if hvac_mode == HVAC_MODE_OFF: + self.set_temperature(temperature=OFF_REPORT_SET_TEMPERATURE) + else: + self.set_temperature(temperature=self._comfort_temperature) + + @property + def preset_mode(self): + """Return current preset mode.""" + if self._target_temperature == self._comfort_temperature: + return PRESET_COMFORT + if self._target_temperature == self._eco_temperature: + return PRESET_ECO + + @property + def preset_modes(self): + """Return supported preset modes.""" + return [PRESET_ECO, PRESET_COMFORT] + + def set_preset_mode(self, preset_mode): + """Set preset mode.""" + if preset_mode == PRESET_COMFORT: self.set_temperature(temperature=self._comfort_temperature) - elif operation_mode == STATE_ECO: + elif preset_mode == PRESET_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): @@ -174,5 +220,5 @@ def update(self): 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) + LOGGER.warning("Fritzbox connection error: %s", ex) self._fritz.login() diff --git a/homeassistant/components/fritzbox/config_flow.py b/homeassistant/components/fritzbox/config_flow.py new file mode 100644 index 0000000000000..25a81333bd67b --- /dev/null +++ b/homeassistant/components/fritzbox/config_flow.py @@ -0,0 +1,162 @@ +"""Config flow for AVM Fritz!Box.""" +from urllib.parse import urlparse + +from pyfritzhome import Fritzhome, LoginError +from requests.exceptions import HTTPError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components.ssdp import ( + ATTR_SSDP_LOCATION, + ATTR_UPNP_FRIENDLY_NAME, + ATTR_UPNP_UDN, +) +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME + +# pylint:disable=unused-import +from .const import DEFAULT_HOST, DEFAULT_USERNAME, DOMAIN + +DATA_SCHEMA_USER = vol.Schema( + { + vol.Required(CONF_HOST, default=DEFAULT_HOST): str, + vol.Required(CONF_USERNAME, default=DEFAULT_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } +) + +DATA_SCHEMA_CONFIRM = vol.Schema( + { + vol.Required(CONF_USERNAME, default=DEFAULT_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } +) + +RESULT_AUTH_FAILED = "auth_failed" +RESULT_NOT_FOUND = "not_found" +RESULT_NOT_SUPPORTED = "not_supported" +RESULT_SUCCESS = "success" + + +class FritzboxConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a AVM Fritz!Box config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 + + def __init__(self): + """Initialize flow.""" + self._host = None + self._name = None + self._password = None + self._username = None + + def _get_entry(self): + return self.async_create_entry( + title=self._name, + data={ + CONF_HOST: self._host, + CONF_PASSWORD: self._password, + CONF_USERNAME: self._username, + }, + ) + + def _try_connect(self): + """Try to connect and check auth.""" + fritzbox = Fritzhome( + host=self._host, user=self._username, password=self._password + ) + try: + fritzbox.login() + fritzbox.get_device_elements() + fritzbox.logout() + return RESULT_SUCCESS + except LoginError: + return RESULT_AUTH_FAILED + except HTTPError: + return RESULT_NOT_SUPPORTED + except OSError: + return RESULT_NOT_FOUND + + async def async_step_import(self, user_input=None): + """Handle configuration by yaml file.""" + return await self.async_step_user(user_input) + + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + errors = {} + + if user_input is not None: + + for entry in self.hass.config_entries.async_entries(DOMAIN): + if entry.data[CONF_HOST] == user_input[CONF_HOST]: + return self.async_abort(reason="already_configured") + + self._host = user_input[CONF_HOST] + self._name = user_input[CONF_HOST] + self._password = user_input[CONF_PASSWORD] + self._username = user_input[CONF_USERNAME] + + result = await self.hass.async_add_executor_job(self._try_connect) + + if result == RESULT_SUCCESS: + return self._get_entry() + if result != RESULT_AUTH_FAILED: + return self.async_abort(reason=result) + errors["base"] = result + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA_USER, errors=errors + ) + + async def async_step_ssdp(self, user_input): + """Handle a flow initialized by discovery.""" + host = urlparse(user_input[ATTR_SSDP_LOCATION]).hostname + self.context[CONF_HOST] = host + + uuid = user_input.get(ATTR_UPNP_UDN) + if uuid: + if uuid.startswith("uuid:"): + uuid = uuid[5:] + await self.async_set_unique_id(uuid) + self._abort_if_unique_id_configured({CONF_HOST: host}) + + for progress in self._async_in_progress(): + if progress.get("context", {}).get(CONF_HOST) == host: + return self.async_abort(reason="already_in_progress") + + # update old and user-configured config entries + for entry in self.hass.config_entries.async_entries(DOMAIN): + if entry.data[CONF_HOST] == host: + if uuid and not entry.unique_id: + self.hass.config_entries.async_update_entry(entry, unique_id=uuid) + return self.async_abort(reason="already_configured") + + self._host = host + self._name = user_input.get(ATTR_UPNP_FRIENDLY_NAME) or host + + self.context["title_placeholders"] = {"name": self._name} + return await self.async_step_confirm() + + async def async_step_confirm(self, user_input=None): + """Handle user-confirmation of discovered node.""" + errors = {} + + if user_input is not None: + self._password = user_input[CONF_PASSWORD] + self._username = user_input[CONF_USERNAME] + result = await self.hass.async_add_executor_job(self._try_connect) + + if result == RESULT_SUCCESS: + return self._get_entry() + if result != RESULT_AUTH_FAILED: + return self.async_abort(reason=result) + errors["base"] = result + + return self.async_show_form( + step_id="confirm", + data_schema=DATA_SCHEMA_CONFIRM, + description_placeholders={"name": self._name}, + errors=errors, + ) diff --git a/homeassistant/components/fritzbox/const.py b/homeassistant/components/fritzbox/const.py new file mode 100644 index 0000000000000..32a72e8e7a6a3 --- /dev/null +++ b/homeassistant/components/fritzbox/const.py @@ -0,0 +1,25 @@ +"""Constants for the AVM Fritz!Box integration.""" +import logging + +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" + +ATTR_TEMPERATURE_UNIT = "temperature_unit" + +ATTR_TOTAL_CONSUMPTION = "total_consumption" +ATTR_TOTAL_CONSUMPTION_UNIT = "total_consumption_unit" + +CONF_CONNECTIONS = "connections" + +DEFAULT_HOST = "fritz.box" +DEFAULT_USERNAME = "admin" + +DOMAIN = "fritzbox" + +LOGGER = logging.getLogger(__package__) + +PLATFORMS = ["binary_sensor", "climate", "switch", "sensor"] diff --git a/homeassistant/components/fritzbox/manifest.json b/homeassistant/components/fritzbox/manifest.json index 1ed18140bd284..1905311a9f65f 100644 --- a/homeassistant/components/fritzbox/manifest.json +++ b/homeassistant/components/fritzbox/manifest.json @@ -1,10 +1,14 @@ { "domain": "fritzbox", - "name": "Fritzbox", - "documentation": "https://www.home-assistant.io/components/fritzbox", - "requirements": [ - "pyfritzhome==0.4.0" + "name": "AVM FRITZ!Box", + "documentation": "https://www.home-assistant.io/integrations/fritzbox", + "requirements": ["pyfritzhome==0.4.2"], + "ssdp": [ + { + "st": "urn:schemas-upnp-org:device:fritzbox:1" + } ], "dependencies": [], - "codeowners": [] + "codeowners": [], + "config_flow": true } diff --git a/homeassistant/components/fritzbox/sensor.py b/homeassistant/components/fritzbox/sensor.py index 123d883531816..85238d80f2769 100644 --- a/homeassistant/components/fritzbox/sensor.py +++ b/homeassistant/components/fritzbox/sensor.py @@ -1,32 +1,35 @@ """Support for AVM Fritz!Box smarthome temperature sensor only devices.""" -import logging - import requests -from homeassistant.const import TEMP_CELSIUS +from homeassistant.const import CONF_DEVICES, 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__) +from .const import ( + ATTR_STATE_DEVICE_LOCKED, + ATTR_STATE_LOCKED, + CONF_CONNECTIONS, + DOMAIN as FRITZBOX_DOMAIN, + LOGGER, +) -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] +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Fritzbox smarthome sensor from config_entry.""" + entities = [] + devices = hass.data[FRITZBOX_DOMAIN][CONF_DEVICES] + fritz = hass.data[FRITZBOX_DOMAIN][CONF_CONNECTIONS][config_entry.entry_id] - 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)) + for device in await hass.async_add_executor_job(fritz.get_devices): + if ( + device.has_temperature_sensor + and not device.has_switch + and not device.has_thermostat + and device.ain not in devices + ): + entities.append(FritzBoxTempSensor(device, fritz)) + devices.add(device.ain) - add_entities(devices) + async_add_entities(entities) class FritzBoxTempSensor(Entity): @@ -37,6 +40,22 @@ def __init__(self, device, fritz): self._device = device self._fritz = fritz + @property + def device_info(self): + """Return device specific attributes.""" + return { + "name": self.name, + "identifiers": {(FRITZBOX_DOMAIN, self._device.ain)}, + "manufacturer": self._device.manufacturer, + "model": self._device.productname, + "sw_version": self._device.fw_version, + } + + @property + def unique_id(self): + """Return the unique ID of the device.""" + return self._device.ain + @property def name(self): """Return the name of the device.""" @@ -57,7 +76,7 @@ def update(self): try: self._device.update() except requests.exceptions.HTTPError as ex: - _LOGGER.warning("Fritzhome connection error: %s", ex) + LOGGER.warning("Fritzhome connection error: %s", ex) self._fritz.login() @property diff --git a/homeassistant/components/fritzbox/strings.json b/homeassistant/components/fritzbox/strings.json new file mode 100644 index 0000000000000..227aeedf84d49 --- /dev/null +++ b/homeassistant/components/fritzbox/strings.json @@ -0,0 +1,31 @@ +{ + "config": { + "flow_title": "AVM FRITZ!Box: {name}", + "step": { + "user": { + "description": "Enter your AVM FRITZ!Box information.", + "data": { + "host": "Host or IP address", + "username": "Username", + "password": "Password" + } + }, + "confirm": { + "description": "Do you want to set up {name}?", + "data": { + "username": "Username", + "password": "Password" + } + } + }, + "abort": { + "already_in_progress": "AVM FRITZ!Box configuration is already in progress.", + "already_configured": "This AVM FRITZ!Box is already configured.", + "not_found": "No supported AVM FRITZ!Box found on the network.", + "not_supported": "Connected to AVM FRITZ!Box but it's unable to control Smart Home devices." + }, + "error": { + "auth_failed": "Username and/or password are incorrect." + } + } +} diff --git a/homeassistant/components/fritzbox/switch.py b/homeassistant/components/fritzbox/switch.py index ae1219cefda3c..b179464182ff2 100644 --- a/homeassistant/components/fritzbox/switch.py +++ b/homeassistant/components/fritzbox/switch.py @@ -1,39 +1,43 @@ """Support for AVM Fritz!Box smarthome switch devices.""" -import logging - import requests -from homeassistant.components.switch import SwitchDevice +from homeassistant.components.switch import SwitchEntity 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) + ATTR_TEMPERATURE, + CONF_DEVICES, + ENERGY_KILO_WATT_HOUR, + TEMP_CELSIUS, +) + +from .const import ( + ATTR_STATE_DEVICE_LOCKED, + ATTR_STATE_LOCKED, + ATTR_TEMPERATURE_UNIT, + ATTR_TOTAL_CONSUMPTION, + ATTR_TOTAL_CONSUMPTION_UNIT, + CONF_CONNECTIONS, + DOMAIN as FRITZBOX_DOMAIN, + LOGGER, +) -_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] +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Fritzbox smarthome switch from config_entry.""" + entities = [] + devices = hass.data[FRITZBOX_DOMAIN][CONF_DEVICES] + fritz = hass.data[FRITZBOX_DOMAIN][CONF_CONNECTIONS][config_entry.entry_id] - for fritz in fritz_list: - device_list = fritz.get_devices() - for device in device_list: - if device.has_switch: - devices.append(FritzboxSwitch(device, fritz)) + for device in await hass.async_add_executor_job(fritz.get_devices): + if device.has_switch and device.ain not in devices: + entities.append(FritzboxSwitch(device, fritz)) + devices.add(device.ain) - add_entities(devices) + async_add_entities(entities) -class FritzboxSwitch(SwitchDevice): +class FritzboxSwitch(SwitchEntity): """The switch class for Fritzbox switches.""" def __init__(self, device, fritz): @@ -41,6 +45,22 @@ def __init__(self, device, fritz): self._device = device self._fritz = fritz + @property + def device_info(self): + """Return device specific attributes.""" + return { + "name": self.name, + "identifiers": {(FRITZBOX_DOMAIN, self._device.ain)}, + "manufacturer": self._device.manufacturer, + "model": self._device.productname, + "sw_version": self._device.fw_version, + } + + @property + def unique_id(self): + """Return the unique ID of the device.""" + return self._device.ain + @property def available(self): """Return if switch is available.""" @@ -69,7 +89,7 @@ def update(self): try: self._device.update() except requests.exceptions.HTTPError as ex: - _LOGGER.warning("Fritzhome connection error: %s", ex) + LOGGER.warning("Fritzhome connection error: %s", ex) self._fritz.login() @property @@ -80,16 +100,17 @@ def device_state_attributes(self): 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 + attrs[ + ATTR_TOTAL_CONSUMPTION + ] = f"{((self._device.energy or 0.0) / 1000):.3f}" + 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 + 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 diff --git a/homeassistant/components/fritzbox/translations/ca.json b/homeassistant/components/fritzbox/translations/ca.json new file mode 100644 index 0000000000000..70c2173c64174 --- /dev/null +++ b/homeassistant/components/fritzbox/translations/ca.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "Aquest AVM FRITZ!Box ja est\u00e0 configurat.", + "already_in_progress": "La configuraci\u00f3 de l'AVM FRITZ!Box ja est\u00e0 en curs.", + "not_found": "No s'ha trobat cap AVM FRITZ!Box compatible a la xarxa.", + "not_supported": "Connectat a AVM FRITZ!Box per\u00f2 no es poden controlar dispositius Smart Home." + }, + "error": { + "auth_failed": "Nom d'usuari i/o contrasenya incorrectes." + }, + "flow_title": "AVM FRITZ!Box: {name}", + "step": { + "confirm": { + "data": { + "password": "Contrasenya", + "username": "Nom d'usuari" + }, + "description": "Vols configurar {name}?", + "title": "AVM FRITZ!Box" + }, + "user": { + "data": { + "host": "Amfitri\u00f3 o adre\u00e7a IP", + "password": "Contrasenya", + "username": "Nom d'usuari" + }, + "description": "Introdueix la teva informaci\u00f3 de AVM FRITZ!Box.", + "title": "AVM FRITZ!Box" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fritzbox/translations/de.json b/homeassistant/components/fritzbox/translations/de.json new file mode 100644 index 0000000000000..1a2e046d9333c --- /dev/null +++ b/homeassistant/components/fritzbox/translations/de.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "Diese AVM FRITZ! Box ist bereits konfiguriert.", + "already_in_progress": "Die Konfiguration der AVM FRITZ! Box ist bereits in Bearbeitung.", + "not_found": "Keine unterst\u00fctzte AVM FRITZ! Box im Netzwerk gefunden.", + "not_supported": "Verbunden mit AVM FRITZ! Box, kann jedoch keine Smart Home-Ger\u00e4te steuern." + }, + "error": { + "auth_failed": "Benutzername und/oder Passwort sind falsch." + }, + "flow_title": "AVM FRITZ! Box: {name}", + "step": { + "confirm": { + "data": { + "password": "Passwort", + "username": "Benutzername" + }, + "description": "M\u00f6chten Sie {name} einrichten?", + "title": "AVM FRITZ! Box" + }, + "user": { + "data": { + "host": "Host oder IP-Adresse", + "password": "Passwort", + "username": "Benutzername" + }, + "description": "Geben Sie Ihre AVM FRITZ! Box-Informationen ein.", + "title": "AVM FRITZ! Box" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fritzbox/translations/en.json b/homeassistant/components/fritzbox/translations/en.json new file mode 100644 index 0000000000000..d5d889a9083e2 --- /dev/null +++ b/homeassistant/components/fritzbox/translations/en.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "This AVM FRITZ!Box is already configured.", + "already_in_progress": "AVM FRITZ!Box configuration is already in progress.", + "not_found": "No supported AVM FRITZ!Box found on the network.", + "not_supported": "Connected to AVM FRITZ!Box but it's unable to control Smart Home devices." + }, + "error": { + "auth_failed": "Username and/or password are incorrect." + }, + "flow_title": "AVM FRITZ!Box: {name}", + "step": { + "confirm": { + "data": { + "password": "Password", + "username": "Username" + }, + "description": "Do you want to set up {name}?", + "title": "AVM FRITZ!Box" + }, + "user": { + "data": { + "host": "Host or IP address", + "password": "Password", + "username": "Username" + }, + "description": "Enter your AVM FRITZ!Box information.", + "title": "AVM FRITZ!Box" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fritzbox/translations/es.json b/homeassistant/components/fritzbox/translations/es.json new file mode 100644 index 0000000000000..05f956d238220 --- /dev/null +++ b/homeassistant/components/fritzbox/translations/es.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "Este AVM FRITZ!Box ya est\u00e1 configurado.", + "already_in_progress": "La configuraci\u00f3n del AVM FRITZ!Box ya est\u00e1 en progreso.", + "not_found": "No se encontr\u00f3 ning\u00fan AVM FRITZ!Box compatible en la red.", + "not_supported": "Conectado a AVM FRITZ!Box pero no es capaz de controlar dispositivos Smart Home." + }, + "error": { + "auth_failed": "Usuario y/o contrase\u00f1a incorrectos." + }, + "flow_title": "AVM FRITZ!Box: {name}", + "step": { + "confirm": { + "data": { + "password": "Contrase\u00f1a", + "username": "Usuario" + }, + "description": "\u00bfQuieres configurar {name}?", + "title": "AVM FRITZ! Box" + }, + "user": { + "data": { + "host": "Host o direcci\u00f3n IP", + "password": "Contrase\u00f1a", + "username": "Usuario" + }, + "description": "Introduce tu informaci\u00f3n de AVM FRITZ!Box.", + "title": "AVM FRITZ! Box" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fritzbox/translations/fi.json b/homeassistant/components/fritzbox/translations/fi.json new file mode 100644 index 0000000000000..bb4fb818a6779 --- /dev/null +++ b/homeassistant/components/fritzbox/translations/fi.json @@ -0,0 +1,19 @@ +{ + "config": { + "step": { + "confirm": { + "data": { + "password": "Salasana", + "username": "K\u00e4ytt\u00e4j\u00e4tunnus" + } + }, + "user": { + "data": { + "password": "Salasana", + "username": "K\u00e4ytt\u00e4j\u00e4tunnus" + }, + "title": "AVM FRITZ!Box" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fritzbox/translations/fr.json b/homeassistant/components/fritzbox/translations/fr.json new file mode 100644 index 0000000000000..5072d62374bdb --- /dev/null +++ b/homeassistant/components/fritzbox/translations/fr.json @@ -0,0 +1,23 @@ +{ + "config": { + "error": { + "auth_failed": "Le nom d'utilisateur et / ou le mot de passe sont incorrects." + }, + "step": { + "confirm": { + "data": { + "password": "Mot de passe", + "username": "Nom d'utilisateur" + }, + "description": "Voulez-vous configurer {name} ?" + }, + "user": { + "data": { + "host": "H\u00f4te ou adresse IP", + "password": "Mot de passe", + "username": "Nom d'utilisateur" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fritzbox/translations/hi.json b/homeassistant/components/fritzbox/translations/hi.json new file mode 100644 index 0000000000000..f95cac200e962 --- /dev/null +++ b/homeassistant/components/fritzbox/translations/hi.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "not_found": "\u0915\u094b\u0908 \u0938\u092e\u0930\u094d\u0925\u093f\u0924 AVM FRITZ! \u092c\u0949\u0915\u094d\u0938 \u0928\u0947\u091f\u0935\u0930\u094d\u0915 \u092a\u0930 \u0928\u0939\u0940\u0902 \u092e\u093f\u0932\u093e\u0964" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fritzbox/translations/it.json b/homeassistant/components/fritzbox/translations/it.json new file mode 100644 index 0000000000000..f51868956bcd7 --- /dev/null +++ b/homeassistant/components/fritzbox/translations/it.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "Questo AVM FRITZ!Box \u00e8 gi\u00e0 configurato.", + "already_in_progress": "La configurazione di AVM FRITZ!Box \u00e8 gi\u00e0 in corso.", + "not_found": "Nessun AVM FRITZ!Box supportato trovato sulla rete.", + "not_supported": "Collegato a AVM FRITZ!Box ma non \u00e8 in grado di controllare i dispositivi Smart Home." + }, + "error": { + "auth_failed": "Nome utente e/o password non sono corretti." + }, + "flow_title": "AVM FRITZ!Box: {name}", + "step": { + "confirm": { + "data": { + "password": "Password", + "username": "Nome utente" + }, + "description": "Vuoi impostare {name}?", + "title": "AVM FRITZ!Box" + }, + "user": { + "data": { + "host": "Host o indirizzo IP", + "password": "Password", + "username": "Nome utente" + }, + "description": "Inserisci le informazioni del tuo AVM FRITZ!Box .", + "title": "AVM FRITZ!Box" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fritzbox/translations/ko.json b/homeassistant/components/fritzbox/translations/ko.json new file mode 100644 index 0000000000000..fc22c88969534 --- /dev/null +++ b/homeassistant/components/fritzbox/translations/ko.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "\uc774 AVM FRITZ!Box \ub294 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", + "already_in_progress": "AVM FRITZ!Box \uad6c\uc131\uc774 \uc774\ubbf8 \uc9c4\ud589 \uc911\uc785\ub2c8\ub2e4.", + "not_found": "\uc9c0\uc6d0\ub418\ub294 AVM FRITZ!Box \uac00 \ub124\ud2b8\uc6cc\ud06c\uc5d0\uc11c \ubc1c\uacac\ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4.", + "not_supported": "AVM FRITZ!Box \uc5d0 \uc5f0\uacb0\ub418\uc5c8\uc9c0\ub9cc \uc2a4\ub9c8\ud2b8 \ud648 \uae30\uae30\ub97c \uc81c\uc5b4\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4." + }, + "error": { + "auth_failed": "\uc0ac\uc6a9\uc790 \uc774\ub984 \ub610\ub294 \ube44\ubc00\ubc88\ud638\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, + "flow_title": "AVM FRITZ!Box: {name}", + "step": { + "confirm": { + "data": { + "password": "\ube44\ubc00\ubc88\ud638", + "username": "\uc0ac\uc6a9\uc790 \uc774\ub984" + }, + "description": "{name} \uc744(\ub97c) \uc124\uc815\ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?", + "title": "AVM FRITZ!Box" + }, + "user": { + "data": { + "host": "\ud638\uc2a4\ud2b8 \ub610\ub294 IP \uc8fc\uc18c", + "password": "\ube44\ubc00\ubc88\ud638", + "username": "\uc0ac\uc6a9\uc790 \uc774\ub984" + }, + "description": "AVM FRITZ!Box \uc815\ubcf4\ub97c \uc785\ub825\ud574\uc8fc\uc138\uc694.", + "title": "AVM FRITZ!Box" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fritzbox/translations/lb.json b/homeassistant/components/fritzbox/translations/lb.json new file mode 100644 index 0000000000000..4f82a03859cc4 --- /dev/null +++ b/homeassistant/components/fritzbox/translations/lb.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "already_configured": "D\u00ebs AVM FRITZ!Box ass scho konfigur\u00e9iert", + "already_in_progress": "AVM FRITZ!Box Konfiguratioun ass schonn am gaang.", + "not_found": "Keng \u00ebnnerst\u00ebtzte AVM FRITZ!Box am Netzwierk fonnt." + }, + "error": { + "auth_failed": "Benotzernumm an/oder Passwuert inkorrekt" + }, + "flow_title": "AVM FRITZ!Box: {name}", + "step": { + "confirm": { + "data": { + "password": "Passwuert", + "username": "Benotzernumm" + }, + "description": "Soll {name} konfigur\u00e9iert ginn?", + "title": "AVM FRITZ!Box" + }, + "user": { + "data": { + "host": "Numm oder IP Adresse", + "password": "Passwuert", + "username": "Benotzernumm" + }, + "description": "F\u00ebll d\u00e9ng AVM FRITZ!Box Informatiounen aus.", + "title": "AVM FRITZ!Box" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fritzbox/translations/nl.json b/homeassistant/components/fritzbox/translations/nl.json new file mode 100644 index 0000000000000..874f7add95e97 --- /dev/null +++ b/homeassistant/components/fritzbox/translations/nl.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "Deze AVM FRITZ!Box is al geconfigureerd.", + "already_in_progress": "AVM FRITZ!Box configuratie is al bezig.", + "not_found": "Geen ondersteunde AVM FRITZ!Box gevonden op het netwerk.", + "not_supported": "Verbonden met AVM FRITZ! Box, maar het kan geen Smart Home-apparaten bedienen." + }, + "error": { + "auth_failed": "Ongeldige gebruikersnaam of wachtwoord" + }, + "flow_title": "AVM FRITZ!Box: {name}", + "step": { + "confirm": { + "data": { + "password": "Wachtwoord", + "username": "Gebruikersnaam" + }, + "description": "Wilt u {name} instellen?", + "title": "AVM FRITZ!Box" + }, + "user": { + "data": { + "host": "Host- of IP-adres", + "password": "Wachtwoord", + "username": "Gebruikersnaam" + }, + "description": "Voer uw AVM FRITZ!Box informatie in.", + "title": "AVM FRITZ!Box" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fritzbox/translations/no.json b/homeassistant/components/fritzbox/translations/no.json new file mode 100644 index 0000000000000..85027c50af946 --- /dev/null +++ b/homeassistant/components/fritzbox/translations/no.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "Denne AVM FRITZ!Box er allerede konfigurert.", + "already_in_progress": "AVM FRITZ!Box-konfigurasjon p\u00e5g\u00e5r allerede.", + "not_found": "Ingen st\u00f8ttet AVM FRITZ!Box funnet p\u00e5 nettverket.", + "not_supported": "Tilkoblet AVM FRITZ! Box, men den klarer ikke \u00e5 kontrollere Smart Home-enheter." + }, + "error": { + "auth_failed": "Brukernavn og/eller passord er feil." + }, + "flow_title": "", + "step": { + "confirm": { + "data": { + "password": "Passord", + "username": "Brukernavn" + }, + "description": "Vil du sette opp {name} ?", + "title": "" + }, + "user": { + "data": { + "host": "Vert eller IP-adresse", + "password": "Passord", + "username": "Brukernavn" + }, + "description": "Skriv inn AVM FRITZ!Box informasjonen.", + "title": "" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fritzbox/translations/pl.json b/homeassistant/components/fritzbox/translations/pl.json new file mode 100644 index 0000000000000..8c9d58c3c42d5 --- /dev/null +++ b/homeassistant/components/fritzbox/translations/pl.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "Ten AVM FRITZ!Box jest ju\u017c skonfigurowany.", + "already_in_progress": "Konfiguracja AVM FRITZ!Box jest ju\u017c w toku.", + "not_found": "W sieci nie znaleziono obs\u0142ugiwanego urz\u0105dzenia AVM FRITZ!Box.", + "not_supported": "Po\u0142\u0105czony z AVM FRITZ! Box, ale nie jest w stanie kontrolowa\u0107 urz\u0105dze\u0144 Smart Home." + }, + "error": { + "auth_failed": "Nazwa u\u017cytkownika i/lub has\u0142o s\u0105 nieprawid\u0142owe." + }, + "flow_title": "AVM FRITZ!Box: {name}", + "step": { + "confirm": { + "data": { + "password": "Has\u0142o", + "username": "Nazwa u\u017cytkownika" + }, + "description": "Czy chcesz skonfigurowa\u0107 {name}?", + "title": "AVM FRITZ! Box" + }, + "user": { + "data": { + "host": "Nazwa hosta lub adres IP", + "password": "Has\u0142o", + "username": "Nazwa u\u017cytkownika" + }, + "description": "Wprowad\u017a informacje o urz\u0105dzeniu AVM FRITZ! Box.", + "title": "AVM FRITZ! Box" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fritzbox/translations/pt.json b/homeassistant/components/fritzbox/translations/pt.json new file mode 100644 index 0000000000000..bd20deada0622 --- /dev/null +++ b/homeassistant/components/fritzbox/translations/pt.json @@ -0,0 +1,21 @@ +{ + "config": { + "error": { + "auth_failed": "Nome de utilizador ou palavra passe incorretos" + }, + "step": { + "confirm": { + "data": { + "password": "Palavra-passe", + "username": "Nome de Utilizador" + } + }, + "user": { + "data": { + "password": "Palavra-passe", + "username": "Nome de Utilizador" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fritzbox/translations/ru.json b/homeassistant/components/fritzbox/translations/ru.json new file mode 100644 index 0000000000000..9f97ef05a34c0 --- /dev/null +++ b/homeassistant/components/fritzbox/translations/ru.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u044d\u0442\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\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 \u044d\u0442\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u044f\u0435\u0442\u0441\u044f.", + "not_found": "\u0412 \u0441\u0435\u0442\u0438 \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u043e \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u043c\u044b\u0445 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432.", + "not_supported": "\u041f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435 \u043a AVM FRITZ! Box \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u043e, \u043d\u043e \u0443\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430\u043c\u0438 Smart Home \u043d\u0435\u0432\u043e\u0437\u043c\u043e\u0436\u043d\u043e." + }, + "error": { + "auth_failed": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043b\u043e\u0433\u0438\u043d \u0438\u043b\u0438 \u043f\u0430\u0440\u043e\u043b\u044c." + }, + "flow_title": "AVM FRITZ!Box: {name}", + "step": { + "confirm": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u044c", + "username": "\u041b\u043e\u0433\u0438\u043d" + }, + "description": "\u0425\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c {name}?", + "title": "AVM FRITZ!Box" + }, + "user": { + "data": { + "host": "\u0414\u043e\u043c\u0435\u043d\u043d\u043e\u0435 \u0438\u043c\u044f \u0438\u043b\u0438 IP-\u0430\u0434\u0440\u0435\u0441", + "password": "\u041f\u0430\u0440\u043e\u043b\u044c", + "username": "\u041b\u043e\u0433\u0438\u043d" + }, + "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044e \u043e \u0412\u0430\u0448\u0435\u043c AVM FRITZ! Box.", + "title": "AVM FRITZ!Box" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fritzbox/translations/sv.json b/homeassistant/components/fritzbox/translations/sv.json new file mode 100644 index 0000000000000..1ed4e4fc3d832 --- /dev/null +++ b/homeassistant/components/fritzbox/translations/sv.json @@ -0,0 +1,23 @@ +{ + "config": { + "error": { + "auth_failed": "Anv\u00e4ndarnamn och/eller l\u00f6senord \u00e4r fel." + }, + "step": { + "confirm": { + "data": { + "password": "L\u00f6senord", + "username": "Anv\u00e4ndarnamn" + }, + "description": "Do vill du konfigurera {name}?" + }, + "user": { + "data": { + "host": "V\u00e4rd eller IP-adress", + "password": "L\u00f6senord", + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fritzbox/translations/zh-Hant.json b/homeassistant/components/fritzbox/translations/zh-Hant.json new file mode 100644 index 0000000000000..cf30a3b307f89 --- /dev/null +++ b/homeassistant/components/fritzbox/translations/zh-Hant.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "\u6b64 AVM FRITZ!Box \u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "already_in_progress": "AVM FRITZ!Box \u8a2d\u5b9a\u5df2\u7d93\u9032\u884c\u4e2d\u3002", + "not_found": "\u5728\u7db2\u8def\u4e0a\u627e\u4e0d\u5230\u652f\u63f4\u7684 AVM FRITZ!Box\u3002", + "not_supported": "\u5df2\u9023\u7dda\u81f3 AVM FRITZ!Box \u4f46\u7121\u6cd5\u63a7\u5236\u667a\u80fd\u5bb6\u5ead\u8a2d\u5099\u3002" + }, + "error": { + "auth_failed": "\u4f7f\u7528\u8005\u53ca/\u6216\u5bc6\u78bc\u932f\u8aa4\u3002" + }, + "flow_title": "AVM FRITZ!Box\uff1a{name}", + "step": { + "confirm": { + "data": { + "password": "\u5bc6\u78bc", + "username": "\u4f7f\u7528\u8005\u540d\u7a31" + }, + "description": "\u662f\u5426\u8981\u8a2d\u5b9a {name}\uff1f", + "title": "AVM FRITZ!Box" + }, + "user": { + "data": { + "host": "\u4e3b\u6a5f\u6216 IP \u4f4d\u5740", + "password": "\u5bc6\u78bc", + "username": "\u4f7f\u7528\u8005\u540d\u7a31" + }, + "description": "\u8f38\u5165 AVM FRITZ!Box \u8cc7\u8a0a\u3002", + "title": "AVM FRITZ!Box" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fritzbox_callmonitor/manifest.json b/homeassistant/components/fritzbox_callmonitor/manifest.json index 19f232ed6677c..b5fa26c096ba2 100644 --- a/homeassistant/components/fritzbox_callmonitor/manifest.json +++ b/homeassistant/components/fritzbox_callmonitor/manifest.json @@ -1,10 +1,7 @@ { "domain": "fritzbox_callmonitor", - "name": "Fritzbox callmonitor", - "documentation": "https://www.home-assistant.io/components/fritzbox_callmonitor", - "requirements": [ - "fritzconnection==0.6.5" - ], - "dependencies": [], + "name": "AVM FRITZ!Box Call Monitor", + "documentation": "https://www.home-assistant.io/integrations/fritzbox_callmonitor", + "requirements": ["fritzconnection==1.2.0"], "codeowners": [] } diff --git a/homeassistant/components/fritzbox_callmonitor/sensor.py b/homeassistant/components/fritzbox_callmonitor/sensor.py index 95c0879996f5e..40791458505c4 100644 --- a/homeassistant/components/fritzbox_callmonitor/sensor.py +++ b/homeassistant/components/fritzbox_callmonitor/sensor.py @@ -1,58 +1,71 @@ """Sensor to monitor incoming/outgoing phone calls on a Fritz!Box router.""" +import datetime import logging +import re import socket import threading -import datetime import time -import re +from fritzconnection.lib.fritzphonebook import FritzPhonebook 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 +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + EVENT_HOMEASSISTANT_STOP, +) import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle _LOGGER = logging.getLogger(__name__) -CONF_PHONEBOOK = 'phonebook' -CONF_PREFIXES = 'prefixes' +CONF_PHONEBOOK = "phonebook" +CONF_PREFIXES = "prefixes" -DEFAULT_HOST = '169.254.1.1' # IP valid for all Fritz!Box routers -DEFAULT_NAME = 'Phone' +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' +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]) -}) +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) + # Try to resolve a hostname; if it is already an IP, it will be returned as-is + try: + host = socket.gethostbyname(host) + except OSError: + _LOGGER.error("Could not resolve hostname %s", host) + return port = config.get(CONF_PORT) username = config.get(CONF_USERNAME) password = config.get(CONF_PASSWORD) @@ -61,12 +74,16 @@ def setup_platform(hass, config, add_entities, discovery_info=None): try: phonebook = FritzBoxPhonebook( - host=host, port=port, username=username, password=password, - phonebook_id=phonebook_id, prefixes=prefixes) + 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) + _LOGGER.warning("Phonebook with ID %s not found on Fritz!Box", phonebook_id) sensor = FritzBoxCallSensor(name=name, phonebook=phonebook) @@ -78,10 +95,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): def _stop_listener(_event): monitor.stopped.set() - hass.bus.listen_once( - EVENT_HOMEASSISTANT_STOP, - _stop_listener - ) + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, _stop_listener) return monitor.sock is not None @@ -127,7 +141,7 @@ def device_state_attributes(self): def number_to_name(self, number): """Return a name for a given phone number.""" if self.phonebook is None: - return 'unknown' + return "unknown" return self.phonebook.get_name(number) def update(self): @@ -149,21 +163,22 @@ def __init__(self, host, port, sensor): def connect(self): """Connect to the Fritz!Box.""" - _LOGGER.debug('Setting up socket...') + _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: + except OSError as err: self.sock = None - _LOGGER.error("Cannot connect to %s on port %s: %s", - self.host, self.port, err) + _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...') + _LOGGER.debug("Connection established, waiting for response...") while not self.stopped.isSet(): try: response = self.sock.recv(2048) @@ -171,12 +186,12 @@ def _listen(self): # if no response after 10 seconds, just recv again continue response = str(response, "utf-8") - _LOGGER.debug('Received %s', response) + _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...') + _LOGGER.warning("Connection lost, reconnecting...") self.sock = None while self.sock is None: self.connect() @@ -194,20 +209,24 @@ def _parse(self, line): 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 = { + "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 = { + "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": @@ -225,8 +244,7 @@ def _parse(self, line): 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): + def __init__(self, host, port, username, password, phonebook_id=0, prefixes=None): """Initialize the class.""" self.host = host self.username = username @@ -237,10 +255,10 @@ def __init__(self, host, port, username, password, 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) + self.fph = 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.") @@ -251,16 +269,18 @@ def __init__(self, host, port, username, password, 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} + 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)) + number = re.sub(r"[^\d\+]", "", str(number)) if self.number_dict is None: - return 'unknown' + return "unknown" try: return self.number_dict[number] except KeyError: @@ -272,7 +292,7 @@ def get_name(self, number): except KeyError: pass try: - return self.number_dict[prefix + number.lstrip('0')] + return self.number_dict[prefix + number.lstrip("0")] except KeyError: pass - return 'unknown' + return "unknown" diff --git a/homeassistant/components/fritzbox_netmonitor/manifest.json b/homeassistant/components/fritzbox_netmonitor/manifest.json index ac1ce2893e488..dde4d6348679e 100644 --- a/homeassistant/components/fritzbox_netmonitor/manifest.json +++ b/homeassistant/components/fritzbox_netmonitor/manifest.json @@ -1,10 +1,7 @@ { "domain": "fritzbox_netmonitor", - "name": "Fritzbox netmonitor", - "documentation": "https://www.home-assistant.io/components/fritzbox_netmonitor", - "requirements": [ - "fritzconnection==0.6.5" - ], - "dependencies": [], + "name": "AVM FRITZ!Box Net Monitor", + "documentation": "https://www.home-assistant.io/integrations/fritzbox_netmonitor", + "requirements": ["fritzconnection==1.2.0"], "codeowners": [] } diff --git a/homeassistant/components/fritzbox_netmonitor/sensor.py b/homeassistant/components/fritzbox_netmonitor/sensor.py index ec8e38bb24ba5..c0d010cf37e96 100644 --- a/homeassistant/components/fritzbox_netmonitor/sensor.py +++ b/homeassistant/components/fritzbox_netmonitor/sensor.py @@ -1,57 +1,56 @@ """Support for monitoring an AVM Fritz!Box router.""" -import logging from datetime import timedelta -from requests.exceptions import RequestException +import logging +from fritzconnection.core.exceptions import FritzConnectionException +from fritzconnection.lib.fritzstatus import FritzStatus +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 +from homeassistant.const import CONF_HOST, CONF_NAME, STATE_UNAVAILABLE import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity 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' +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" MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=5) -STATE_ONLINE = 'online' -STATE_OFFLINE = 'offline' +STATE_ONLINE = "online" +STATE_OFFLINE = "offline" -ICON = 'mdi:web' +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, -}) +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) + fstatus = FritzStatus(address=host) except (ValueError, TypeError, FritzConnectionException): fstatus = None @@ -71,7 +70,7 @@ def __init__(self, name, fstatus): self._name = name self._fstatus = fstatus self._state = STATE_UNAVAILABLE - self._is_linked = self._is_connected = self._wan_access_type = None + self._is_linked = self._is_connected = None self._external_ip = self._uptime = None self._bytes_sent = self._bytes_received = None self._transmission_rate_up = None @@ -102,7 +101,6 @@ def state_attributes(self): 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, @@ -120,7 +118,6 @@ def update(self): 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 diff --git a/homeassistant/components/fritzdect/__init__.py b/homeassistant/components/fritzdect/__init__.py deleted file mode 100644 index d64990bc3f0c6..0000000000000 --- a/homeassistant/components/fritzdect/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""The fritzdect component.""" diff --git a/homeassistant/components/fritzdect/manifest.json b/homeassistant/components/fritzdect/manifest.json deleted file mode 100644 index 98d628fe078aa..0000000000000 --- a/homeassistant/components/fritzdect/manifest.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "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 deleted file mode 100644 index d3cd00a73f576..0000000000000 --- a/homeassistant/components/fritzdect/switch.py +++ /dev/null @@ -1,214 +0,0 @@ -"""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/fronius/__init__.py b/homeassistant/components/fronius/__init__.py new file mode 100644 index 0000000000000..2b4d968fecaa9 --- /dev/null +++ b/homeassistant/components/fronius/__init__.py @@ -0,0 +1 @@ +"""The Fronius component.""" diff --git a/homeassistant/components/fronius/manifest.json b/homeassistant/components/fronius/manifest.json new file mode 100644 index 0000000000000..8f94e816505c7 --- /dev/null +++ b/homeassistant/components/fronius/manifest.json @@ -0,0 +1,7 @@ +{ + "domain": "fronius", + "name": "Fronius", + "documentation": "https://www.home-assistant.io/integrations/fronius", + "requirements": ["pyfronius==0.4.6"], + "codeowners": ["@nielstron"] +} diff --git a/homeassistant/components/fronius/sensor.py b/homeassistant/components/fronius/sensor.py new file mode 100644 index 0000000000000..7c966a6fa4ae1 --- /dev/null +++ b/homeassistant/components/fronius/sensor.py @@ -0,0 +1,285 @@ +"""Support for Fronius devices.""" +import copy +from datetime import timedelta +import logging + +from pyfronius import Fronius +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_DEVICE, + CONF_MONITORED_CONDITIONS, + CONF_RESOURCE, + CONF_SCAN_INTERVAL, + CONF_SENSOR_TYPE, +) +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_time_interval + +_LOGGER = logging.getLogger(__name__) + +CONF_SCOPE = "scope" + +TYPE_INVERTER = "inverter" +TYPE_STORAGE = "storage" +TYPE_METER = "meter" +TYPE_POWER_FLOW = "power_flow" +SCOPE_DEVICE = "device" +SCOPE_SYSTEM = "system" + +DEFAULT_SCOPE = SCOPE_DEVICE +DEFAULT_DEVICE = 0 +DEFAULT_INVERTER = 1 +DEFAULT_SCAN_INTERVAL = timedelta(seconds=60) + +SENSOR_TYPES = [TYPE_INVERTER, TYPE_STORAGE, TYPE_METER, TYPE_POWER_FLOW] +SCOPE_TYPES = [SCOPE_DEVICE, SCOPE_SYSTEM] + + +def _device_id_validator(config): + """Ensure that inverters have default id 1 and other devices 0.""" + config = copy.deepcopy(config) + for cond in config[CONF_MONITORED_CONDITIONS]: + if CONF_DEVICE not in cond: + if cond[CONF_SENSOR_TYPE] == TYPE_INVERTER: + cond[CONF_DEVICE] = DEFAULT_INVERTER + else: + cond[CONF_DEVICE] = DEFAULT_DEVICE + return config + + +PLATFORM_SCHEMA = vol.Schema( + vol.All( + PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_RESOURCE): cv.url, + vol.Required(CONF_MONITORED_CONDITIONS): vol.All( + cv.ensure_list, + [ + { + vol.Required(CONF_SENSOR_TYPE): vol.In(SENSOR_TYPES), + vol.Optional(CONF_SCOPE, default=DEFAULT_SCOPE): vol.In( + SCOPE_TYPES + ), + vol.Optional(CONF_DEVICE): vol.All( + vol.Coerce(int), vol.Range(min=0) + ), + } + ], + ), + } + ), + _device_id_validator, + ) +) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up of Fronius platform.""" + session = async_get_clientsession(hass) + fronius = Fronius(session, config[CONF_RESOURCE]) + + scan_interval = config.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + adapters = [] + # Creates all adapters for monitored conditions + for condition in config[CONF_MONITORED_CONDITIONS]: + + device = condition[CONF_DEVICE] + sensor_type = condition[CONF_SENSOR_TYPE] + scope = condition[CONF_SCOPE] + name = f"Fronius {condition[CONF_SENSOR_TYPE].replace('_', ' ').capitalize()} {device if scope == SCOPE_DEVICE else SCOPE_SYSTEM} {config[CONF_RESOURCE]}" + if sensor_type == TYPE_INVERTER: + if scope == SCOPE_SYSTEM: + adapter_cls = FroniusInverterSystem + else: + adapter_cls = FroniusInverterDevice + elif sensor_type == TYPE_METER: + if scope == SCOPE_SYSTEM: + adapter_cls = FroniusMeterSystem + else: + adapter_cls = FroniusMeterDevice + elif sensor_type == TYPE_POWER_FLOW: + adapter_cls = FroniusPowerFlow + else: + adapter_cls = FroniusStorage + + adapters.append(adapter_cls(fronius, name, device, async_add_entities)) + + # Creates a lamdba that fetches an update when called + def adapter_data_fetcher(data_adapter): + async def fetch_data(*_): + await data_adapter.async_update() + + return fetch_data + + # Set up the fetching in a fixed interval for each adapter + for adapter in adapters: + fetch = adapter_data_fetcher(adapter) + # fetch data once at set-up + await fetch() + async_track_time_interval(hass, fetch, scan_interval) + + +class FroniusAdapter: + """The Fronius sensor fetching component.""" + + def __init__(self, bridge, name, device, add_entities): + """Initialize the sensor.""" + self.bridge = bridge + self._name = name + self._device = device + self._fetched = {} + + self.sensors = set() + self._registered_sensors = set() + self._add_entities = add_entities + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def data(self): + """Return the state attributes.""" + return self._fetched + + async def async_update(self): + """Retrieve and update latest state.""" + values = {} + try: + values = await self._update() + except ConnectionError: + _LOGGER.error("Failed to update: connection error") + except ValueError: + _LOGGER.error( + "Failed to update: invalid response returned." + "Maybe the configured device is not supported" + ) + + if not values: + return + attributes = self._fetched + # Copy data of current fronius device + for key, entry in values.items(): + # If the data is directly a sensor + if "value" in entry: + attributes[key] = entry + self._fetched = attributes + + # Add discovered value fields as sensors + # because some fields are only sent temporarily + new_sensors = [] + for key in attributes: + if key not in self.sensors: + self.sensors.add(key) + _LOGGER.info("Discovered %s, adding as sensor", key) + new_sensors.append(FroniusTemplateSensor(self, key)) + self._add_entities(new_sensors, True) + + # Schedule an update for all included sensors + for sensor in self._registered_sensors: + sensor.async_schedule_update_ha_state(True) + + async def _update(self): + """Return values of interest.""" + + async def register(self, sensor): + """Register child sensor for update subscriptions.""" + self._registered_sensors.add(sensor) + + +class FroniusInverterSystem(FroniusAdapter): + """Adapter for the fronius inverter with system scope.""" + + async def _update(self): + """Get the values for the current state.""" + return await self.bridge.current_system_inverter_data() + + +class FroniusInverterDevice(FroniusAdapter): + """Adapter for the fronius inverter with device scope.""" + + async def _update(self): + """Get the values for the current state.""" + return await self.bridge.current_inverter_data(self._device) + + +class FroniusStorage(FroniusAdapter): + """Adapter for the fronius battery storage.""" + + async def _update(self): + """Get the values for the current state.""" + return await self.bridge.current_storage_data(self._device) + + +class FroniusMeterSystem(FroniusAdapter): + """Adapter for the fronius meter with system scope.""" + + async def _update(self): + """Get the values for the current state.""" + return await self.bridge.current_system_meter_data() + + +class FroniusMeterDevice(FroniusAdapter): + """Adapter for the fronius meter with device scope.""" + + async def _update(self): + """Get the values for the current state.""" + return await self.bridge.current_meter_data(self._device) + + +class FroniusPowerFlow(FroniusAdapter): + """Adapter for the fronius power flow.""" + + async def _update(self): + """Get the values for the current state.""" + return await self.bridge.current_power_flow() + + +class FroniusTemplateSensor(Entity): + """Sensor for the single values (e.g. pv power, ac power).""" + + def __init__(self, parent: FroniusAdapter, name): + """Initialize a singular value sensor.""" + self._name = name + self.parent = parent + self._state = None + self._unit = None + + @property + def name(self): + """Return the name of the sensor.""" + return f"{self._name.replace('_', ' ').capitalize()} {self.parent.name}" + + @property + def state(self): + """Return the current state.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self._unit + + @property + def should_poll(self): + """Device should not be polled, returns False.""" + return False + + async def async_update(self): + """Update the internal state.""" + state = self.parent.data.get(self._name) + self._state = state.get("value") + self._unit = state.get("unit") + + async def async_added_to_hass(self): + """Register at parent component for updates.""" + await self.parent.register(self) + + def __hash__(self): + """Hash sensor by hashing its name.""" + return hash(self.name) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 7ef031a90cbe7..e5b93399c4321 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -1,125 +1,145 @@ """Handle the frontend for Home Assistant.""" -import asyncio import json import logging +import mimetypes import os import pathlib +from typing import Any, Dict, Optional, Set, Tuple -from aiohttp import web -import voluptuous as vol +from aiohttp import hdrs, web, web_urldispatcher import jinja2 +import voluptuous as vol +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.components.http.view import HomeAssistantView +from homeassistant.config import async_hass_config_yaml from homeassistant.const import CONF_NAME, EVENT_THEMES_UPDATED from homeassistant.core import callback +from homeassistant.helpers import service +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.translation import async_get_translations -from homeassistant.loader import bind_hass +from homeassistant.loader import async_get_integration, bind_hass from .storage import async_setup_frontend_storage -DOMAIN = 'frontend' -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' +# mypy: allow-untyped-defs, no-check-untyped-defs + +# Fix mimetypes for borked Windows machines +# https://github.com/home-assistant/home-assistant-polymer/issues/3336 +mimetypes.add_type("text/css", ".css") +mimetypes.add_type("application/javascript", ".js") + + +DOMAIN = "frontend" +CONF_THEMES = "themes" +CONF_EXTRA_HTML_URL = "extra_html_url" +CONF_EXTRA_HTML_URL_ES5 = "extra_html_url_es5" +CONF_EXTRA_MODULE_URL = "extra_module_url" +CONF_EXTRA_JS_URL_ES5 = "extra_js_url_es5" +CONF_FRONTEND_REPO = "development_repo" +CONF_JS_VERSION = "javascript_version" +EVENT_PANELS_UPDATED = "panels_updated" -DEFAULT_THEME_COLOR = '#03A9F4' +DEFAULT_THEME_COLOR = "#03A9F4" MANIFEST_JSON = { - '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 + "background_color": "#FFFFFF", + "description": "Home automation platform that puts local control and privacy first.", + "dir": "ltr", + "display": "standalone", + "icons": [ + { + "src": f"/static/icons/favicon-{size}x{size}.png", + "sizes": f"{size}x{size}", + "type": "image/png", + "purpose": "maskable any", + } + for size in (192, 384, 512, 1024) + ], + "lang": "en-US", + "name": "Home Assistant", + "short_name": "Assistant", + "start_url": "/?homescreen=1", + "theme_color": DEFAULT_THEME_COLOR, + "prefer_related_applications": True, + "related_applications": [ + {"platform": "play", "id": "io.homeassistant.companion.android"} + ], } -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_FINALIZE_PANEL = 'frontend_finalize_panel' -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' +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_EXTRA_MODULE_URL = "frontend_extra_module_url" +DATA_EXTRA_JS_URL_ES5 = "frontend_extra_js_url_es5" +DATA_THEMES = "frontend_themes" +DATA_DEFAULT_THEME = "frontend_default_theme" +DEFAULT_THEME = "default" -PRIMARY_COLOR = 'primary-color' +PRIMARY_COLOR = "primary-color" _LOGGER = logging.getLogger(__name__) -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, -}) +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]), + vol.Optional(CONF_EXTRA_MODULE_URL): vol.All( + cv.ensure_list, [cv.string] + ), + vol.Optional(CONF_EXTRA_JS_URL_ES5): 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" class Panel: """Abstract class for panels.""" # Name of the webcomponent - component_name = None + component_name: Optional[str] = None - # Icon to show in the sidebar (optional) - sidebar_icon = None + # Icon to show in the sidebar + sidebar_icon: Optional[str] = None - # Title to show in the sidebar (optional) - sidebar_title = None + # Title to show in the sidebar + sidebar_title: Optional[str] = None # Url to show the panel in the frontend - frontend_url_path = None + frontend_url_path: Optional[str] = None # Config to pass to the webcomponent - config = None + config: Optional[Dict[str, Any]] = 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): + 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 @@ -128,49 +148,63 @@ def __init__(self, component_name, sidebar_title, sidebar_icon, self.config = config self.require_admin = require_admin - @callback - def async_register_index_routes(self, router, index_view): - """Register routes for panel to be served by index view.""" - router.add_route( - 'get', '/{}'.format(self.frontend_url_path), index_view.get) - router.add_route( - 'get', '/{}/{{extra:.+}}'.format(self.frontend_url_path), - index_view.get) - @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, + "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 -async def async_register_built_in_panel(hass, component_name, - sidebar_title=None, sidebar_icon=None, - frontend_url_path=None, config=None, - require_admin=False): +@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, + *, + update=False, +): """Register a built-in panel.""" - panel = Panel(component_name, sidebar_title, sidebar_icon, - frontend_url_path, config, require_admin) - - panels = hass.data.get(DATA_PANELS) - if panels is None: - panels = hass.data[DATA_PANELS] = {} + panel = Panel( + component_name, + sidebar_title, + sidebar_icon, + frontend_url_path, + config, + require_admin, + ) - if panel.frontend_url_path in panels: - _LOGGER.warning("Overwriting component %s", panel.frontend_url_path) + panels = hass.data.setdefault(DATA_PANELS, {}) - if DATA_FINALIZE_PANEL in hass.data: - hass.data[DATA_FINALIZE_PANEL](panel) + if not update and panel.frontend_url_path in panels: + raise ValueError(f"Overwriting panel {panel.frontend_url_path}") panels[panel.frontend_url_path] = panel + hass.bus.async_fire(EVENT_PANELS_UPDATED) + + +@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 panel is None: + _LOGGER.warning("Removing unknown panel %s", frontend_url_path) + + hass.bus.async_fire(EVENT_PANELS_UPDATED) + @bind_hass @callback @@ -183,6 +217,15 @@ def add_extra_html_url(hass, url, es5=False): url_set.add(url) +def add_extra_js_url(hass, url, es5=False): + """Register extra js or module url to load.""" + key = DATA_EXTRA_JS_URL_ES5 if es5 else DATA_EXTRA_MODULE_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): """Add a keyval to the manifest.json.""" MANIFEST_JSON[key] = val @@ -191,22 +234,21 @@ def add_manifest_json_key(key, val): 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' - + return pathlib.Path(dev_repo_path) / "hass_frontend" + # Keep import here so that we can import frontend without installing reqs + # pylint: disable=import-outside-toplevel 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.components.websocket_api.async_register_command(websocket_get_panels) + hass.components.websocket_api.async_register_command(websocket_get_themes) + hass.components.websocket_api.async_register_command(websocket_get_translations) + hass.components.websocket_api.async_register_command(websocket_get_version) hass.http.register_view(ManifestJSONView) conf = config.get(DOMAIN, {}) @@ -216,45 +258,39 @@ async def async_setup(hass, config): 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), + ("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(f"/{path}", str(root_path / path), should_cache) hass.http.register_static_path( - "/auth/authorize", str(root_path / "authorize.html"), False) + "/auth/authorize", str(root_path / "authorize.html"), False + ) - local = hass.config.path('www') + local = hass.config.path("www") if os.path.isdir(local): hass.http.register_static_path("/local", local, not is_dev) - index_view = IndexView(repo_path) - hass.http.register_view(index_view) - - @callback - def async_finalize_panel(panel): - """Finalize setup of a panel.""" - panel.async_register_index_routes(hass.http.app.router, index_view) + hass.http.app.router.register_resource(IndexView(repo_path, hass)) - await asyncio.wait( - [async_register_built_in_panel(hass, panel) for panel in ( - 'kiosk', 'states', 'profile')], loop=hass.loop) - await asyncio.wait( - [async_register_built_in_panel(hass, panel, require_admin=True) - for panel in ('dev-event', 'dev-info', 'dev-service', 'dev-state', - 'dev-template', 'dev-mqtt')], loop=hass.loop) + async_register_built_in_panel(hass, "profile") - hass.data[DATA_FINALIZE_PANEL] = async_finalize_panel + # To smooth transition to new urls, add redirects to new urls of dev tools + # Added June 27, 2019. Can be removed in 2021. + for panel in ("event", "info", "service", "state", "template", "mqtt"): + hass.http.register_redirect(f"/dev-{panel}", f"/developer-tools/{panel}") - # Finalize registration of panels that registered before frontend was setup - # This includes the built-in panels from line above. - for panel in hass.data[DATA_PANELS].values(): - async_finalize_panel(panel) + async_register_built_in_panel( + hass, + "developer-tools", + require_admin=True, + sidebar_title="developer_tools", + sidebar_icon="hass:hammer", + ) if DATA_EXTRA_HTML_URL not in hass.data: hass.data[DATA_EXTRA_HTML_URL] = set() @@ -262,6 +298,18 @@ def async_finalize_panel(panel): for url in conf.get(CONF_EXTRA_HTML_URL, []): add_extra_html_url(hass, url, False) + if DATA_EXTRA_MODULE_URL not in hass.data: + hass.data[DATA_EXTRA_MODULE_URL] = set() + + for url in conf.get(CONF_EXTRA_MODULE_URL, []): + add_extra_js_url(hass, url) + + if DATA_EXTRA_JS_URL_ES5 not in hass.data: + hass.data[DATA_EXTRA_JS_URL_ES5] = set() + + for url in conf.get(CONF_EXTRA_JS_URL_ES5, []): + add_extra_js_url(hass, url, True) + _async_setup_themes(hass, conf.get(CONF_THEMES)) return True @@ -271,25 +319,20 @@ def async_finalize_panel(panel): 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 - - hass.data[DATA_THEMES] = themes + hass.data[DATA_THEMES] = themes or {} @callback 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, - }) + MANIFEST_JSON["theme_color"] = DEFAULT_THEME_COLOR + if name != DEFAULT_THEME: + MANIFEST_JSON["theme_color"] = themes[name].get( + "app-header-background-color", + themes[name].get(PRIMARY_COLOR, DEFAULT_THEME_COLOR), + ) + hass.bus.async_fire(EVENT_THEMES_UPDATED) @callback def set_theme(call): @@ -303,40 +346,91 @@ def set_theme(call): else: _LOGGER.warning("Theme %s is not defined.", name) - @callback - def reload_themes(_): + async def reload_themes(_): """Reload themes.""" - path = find_config_file(hass.config.config_dir) - new_themes = load_yaml_config_file(path)[DOMAIN].get(CONF_THEMES, {}) + config = await async_hass_config_yaml(hass) + new_themes = config[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() - 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) + service.async_register_admin_service( + hass, + DOMAIN, + SERVICE_SET_THEME, + set_theme, + vol.Schema({vol.Required(CONF_NAME): cv.string}), + ) + service.async_register_admin_service( + hass, DOMAIN, SERVICE_RELOAD_THEMES, reload_themes + ) -class IndexView(HomeAssistantView): - """Serve the frontend.""" - url = '/' - name = 'frontend:index' - requires_auth = False +class IndexView(web_urldispatcher.AbstractResource): + """Serve the frontend.""" - def __init__(self, repo_path): + def __init__(self, repo_path, hass): """Initialize the frontend view.""" + super().__init__(name="frontend:index") self.repo_path = repo_path + self.hass = hass self._template_cache = None + @property + def canonical(self) -> str: + """Return resource's canonical path.""" + return "/" + + @property + def _route(self): + """Return the index route.""" + return web_urldispatcher.ResourceRoute("GET", self.get, self) + + def url_for(self, **kwargs: str) -> URL: + """Construct url for resource with additional params.""" + return URL("/") + + async def resolve( + self, request: web.Request + ) -> Tuple[Optional[web_urldispatcher.UrlMappingMatchInfo], Set[str]]: + """Resolve resource. + + 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.method != hdrs.METH_GET: + return None, {"GET"} + + return web_urldispatcher.UrlMappingMatchInfo({}, self._route), {"GET"} + + def add_prefix(self, prefix: str) -> None: + """Add a prefix to processed URLs. + + Required for subapplications support. + """ + + 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.""" + + def raw_match(self, path: str) -> bool: + """Perform a raw match against path.""" + 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: + with open(str(_frontend_root(self.repo_path) / "index.html")) as file: tpl = jinja2.Template(file.read()) # Cache template if not running from repository @@ -345,14 +439,12 @@ def get_template(self): return tpl - async def get(self, request, extra=None): - """Serve the index view.""" - hass = request.app['hass'] + async def get(self, request: web.Request) -> web.Response: + """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' - }) + return web.Response(status=302, headers={"location": "/onboarding.html"}) template = self._template_cache @@ -361,64 +453,120 @@ async def get(self, request, extra=None): return web.Response( text=template.render( - theme_color=MANIFEST_JSON['theme_color'], + theme_color=MANIFEST_JSON["theme_color"], extra_urls=hass.data[DATA_EXTRA_HTML_URL], + extra_modules=hass.data[DATA_EXTRA_MODULE_URL], + extra_js_es5=hass.data[DATA_EXTRA_JS_URL_ES5], ), - content_type='text/html' + content_type="text/html", ) + def __len__(self) -> int: + """Return length of resource.""" + return 1 + + 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" @callback - def get(self, request): # pylint: disable=no-self-use + def get(self, request): # pylint: disable=no-self-use """Return the manifest.json.""" msg = json.dumps(MANIFEST_JSON, sort_keys=True) return web.Response(text=msg, content_type="application/manifest+json") @callback +@websocket_api.websocket_command({"type": "get_panels"}) def websocket_get_panels(hass, connection, msg): - """Handle get panels command. - - Async friendly. - """ + """Handle get panels command.""" 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} + if user_is_admin or not panel.require_admin + } - connection.send_message(websocket_api.result_message( - msg['id'], panels)) + connection.send_message(websocket_api.result_message(msg["id"], panels)) @callback +@websocket_api.websocket_command({"type": "frontend/get_themes"}) 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], - })) - + """Handle get themes command.""" + if hass.config.safe_mode: + connection.send_message( + websocket_api.result_message( + msg["id"], + { + "themes": { + "safe_mode": { + "primary-color": "#db4437", + "accent-color": "#eeee02", + } + }, + "default_theme": "safe_mode", + }, + ) + ) + return + connection.send_message( + websocket_api.result_message( + msg["id"], + { + "themes": hass.data[DATA_THEMES], + "default_theme": hass.data[DATA_DEFAULT_THEME], + }, + ) + ) + + +@websocket_api.websocket_command( + { + "type": "frontend/get_translations", + vol.Required("language"): str, + vol.Required("category"): str, + vol.Optional("integration"): str, + vol.Optional("config_flow"): bool, + } +) @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, - } - )) + """Handle get translations command.""" + resources = await async_get_translations( + hass, + msg["language"], + msg["category"], + msg.get("integration"), + msg.get("config_flow"), + ) + connection.send_message( + websocket_api.result_message(msg["id"], {"resources": resources}) + ) + + +@websocket_api.websocket_command({"type": "frontend/get_version"}) +@websocket_api.async_response +async def websocket_get_version(hass, connection, msg): + """Handle get version command.""" + integration = await async_get_integration(hass, "frontend") + + frontend = None + + for req in integration.requirements: + if req.startswith("home-assistant-frontend=="): + frontend = req.split("==", 1)[1] + + if frontend is None: + connection.send_error(msg["id"], "unknown_version", "Version not found") + else: + connection.send_result(msg["id"], {"version": frontend}) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 559469c63aca9..9759c38af7db3 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -1,20 +1,19 @@ { "domain": "frontend", "name": "Home Assistant Frontend", - "documentation": "https://www.home-assistant.io/components/frontend", - "requirements": [ - "home-assistant-frontend==20190509.0" - ], + "documentation": "https://www.home-assistant.io/integrations/frontend", + "requirements": ["home-assistant-frontend==20200427.1"], "dependencies": [ "api", "auth", + "device_automation", "http", "lovelace", "onboarding", + "search", "system_log", "websocket_api" ], - "codeowners": [ - "@home-assistant/core" - ] + "codeowners": ["@home-assistant/frontend"], + "quality_scale": "internal" } diff --git a/homeassistant/components/frontend/services.yaml b/homeassistant/components/frontend/services.yaml index dc1fb40be4841..489164ce7bd90 100644 --- a/homeassistant/components/frontend/services.yaml +++ b/homeassistant/components/frontend/services.yaml @@ -5,7 +5,7 @@ set_theme: fields: name: description: Name of a predefined theme or 'default'. - example: 'light' + example: "light" reload_themes: description: Reload themes from yaml configuration. diff --git a/homeassistant/components/frontend/storage.py b/homeassistant/components/frontend/storage.py index 17aae14c820c8..b37945b5e072f 100644 --- a/homeassistant/components/frontend/storage.py +++ b/homeassistant/components/frontend/storage.py @@ -1,27 +1,26 @@ """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' +# mypy: allow-untyped-calls, allow-untyped-defs + +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 - ) + 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.""" @@ -31,26 +30,24 @@ async def with_store_func(hass, connection, msg): if store is None: store = stores[user_id] = hass.helpers.storage.Store( - STORAGE_VERSION_USER_DATA, - STORAGE_KEY_USER_DATA.format(connection.user.id) + STORAGE_VERSION_USER_DATA, f"frontend.user_data_{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], - ) + 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.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): @@ -58,17 +55,14 @@ async def websocket_set_user_data(hass, connection, msg, store, data): Async friendly. """ - data[msg['key']] = msg['value'] + data[msg["key"]] = msg["value"] await store.async_save(data) - connection.send_message(websocket_api.result_message( - msg['id'], - )) + 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.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): @@ -76,8 +70,8 @@ async def websocket_get_user_data(hass, connection, msg, store, data): Async friendly. """ - connection.send_message(websocket_api.result_message( - msg['id'], { - 'value': data.get(msg['key']) if 'key' in msg else data - } - )) + 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/frontier_silicon/manifest.json b/homeassistant/components/frontier_silicon/manifest.json index 0e20a509d1f22..4e52eee995495 100644 --- a/homeassistant/components/frontier_silicon/manifest.json +++ b/homeassistant/components/frontier_silicon/manifest.json @@ -1,10 +1,7 @@ { "domain": "frontier_silicon", - "name": "Frontier silicon", - "documentation": "https://www.home-assistant.io/components/frontier_silicon", - "requirements": [ - "afsapi==0.0.4" - ], - "dependencies": [], + "name": "Frontier Silicon", + "documentation": "https://www.home-assistant.io/integrations/frontier_silicon", + "requirements": ["afsapi==0.0.4"], "codeowners": [] } diff --git a/homeassistant/components/frontier_silicon/media_player.py b/homeassistant/components/frontier_silicon/media_player.py index 64aa1d3a01261..852528fb3a520 100644 --- a/homeassistant/components/frontier_silicon/media_player.py +++ b/homeassistant/components/frontier_silicon/media_player.py @@ -1,76 +1,109 @@ """Support for Frontier Silicon Devices (Medion, Hama, Auna,...).""" import logging +from afsapi import AFSAPI +import requests import voluptuous as vol -from homeassistant.components.media_player import ( - MediaPlayerDevice, PLATFORM_SCHEMA) +from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerEntity 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) + 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) + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + STATE_IDLE, + 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 +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' +DEFAULT_PASSWORD = "1234" -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, -}) +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, + vol.Optional(CONF_NAME): cv.string, + } +) -async def async_setup_platform( - hass, config, async_add_entities, discovery_info=None): +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) + [AFSAPIDevice(discovery_info["ssdp_description"], DEFAULT_PASSWORD, None)], + True, + ) return True host = config.get(CONF_HOST) port = config.get(CONF_PORT) password = config.get(CONF_PASSWORD) + name = config.get(CONF_NAME) try: async_add_entities( - [AFSAPIDevice(DEVICE_URL.format(host, port), password)], True) + [AFSAPIDevice(f"http://{host}:{port}/device", password, name)], 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) + _LOGGER.error( + "Could not add the FSAPI device at %s:%s -> %s", host, port, password + ) return False -class AFSAPIDevice(MediaPlayerDevice): +class AFSAPIDevice(MediaPlayerEntity): """Representation of a Frontier Silicon device on the network.""" - def __init__(self, device_url, password): + def __init__(self, device_url, password, name): """Initialize the Frontier Silicon API device.""" self._device_url = device_url self._password = password self._state = None - self._name = None + self._name = name self._title = None self._artist = None self._album_name = None @@ -78,6 +111,8 @@ def __init__(self, device_url, password): self._source = None self._source_list = None self._media_image_url = None + self._max_volume = None + self._volume_level = None # Properties @property @@ -89,8 +124,6 @@ def fs_device(self): 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 @@ -149,6 +182,11 @@ def media_image_url(self): """Image url of current playing media.""" return self._media_image_url + @property + def volume_level(self): + """Volume level of the media player (0..1).""" + return self._volume_level + async def async_update(self): """Get the latest date and update device state.""" fs_device = self.fs_device @@ -159,26 +197,40 @@ async def async_update(self): 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) + # The API seems to include 'zero' in the number of steps (e.g. if the range is + # 0-40 then get_volume_steps returns 41) subtract one to get the max volume. + # If call to get_volume fails set to 0 and try again next time. + if not self._max_volume: + self._max_volume = int(await fs_device.get_volume_steps() or 1) - 1 + + if await fs_device.get_power(): + status = await fs_device.get_play_status() + self._state = { + "playing": STATE_PLAYING, + "paused": STATE_PAUSED, + "stopped": STATE_IDLE, + "unknown": STATE_UNKNOWN, + None: STATE_IDLE, + }.get(status, STATE_UNKNOWN) + else: + self._state = STATE_OFF 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._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() + + volume = await self.fs_device.get_volume() + + # Prevent division by zero if max_volume not known yet + self._volume_level = float(volume or 0) / (self._max_volume or 1) else: self._title = None self._artist = None @@ -188,6 +240,8 @@ async def async_update(self): self._mute = None self._media_image_url = None + self._volume_level = None + # Management actions # power control async def async_turn_on(self): @@ -208,7 +262,7 @@ async def async_media_pause(self): async def async_media_play_pause(self): """Send play/pause command.""" - if 'playing' in self._state: + if "playing" in self._state: await self.fs_device.pause() else: await self.fs_device.play() @@ -239,16 +293,20 @@ async def async_mute_volume(self, mute): 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) + volume = int(volume or 0) + 1 + await self.fs_device.set_volume(min(volume, self._max_volume)) 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) + volume = int(volume or 0) - 1 + await self.fs_device.set_volume(max(volume, 0)) async def async_set_volume_level(self, volume): """Set volume command.""" - await self.fs_device.set_volume(volume) + if self._max_volume: # Can't do anything sensible if not set + volume = int(volume * self._max_volume) + await self.fs_device.set_volume(volume) async def async_select_source(self, source): """Select input source.""" diff --git a/homeassistant/components/futurenow/light.py b/homeassistant/components/futurenow/light.py index 91ec8b0794d54..2bccd38688a50 100644 --- a/homeassistant/components/futurenow/light.py +++ b/homeassistant/components/futurenow/light.py @@ -2,33 +2,40 @@ import logging +import pyfnip 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) + ATTR_BRIGHTNESS, + PLATFORM_SCHEMA, + SUPPORT_BRIGHTNESS, + LightEntity, +) +from homeassistant.const import CONF_DEVICES, CONF_HOST, CONF_NAME, CONF_PORT import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -CONF_DRIVER = 'driver' -CONF_DRIVER_FNIP6X10AD = 'FNIP6x10ad' -CONF_DRIVER_FNIP8X10A = 'FNIP8x10a' +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, -}) +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}, -}) +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): @@ -36,49 +43,47 @@ def setup_platform(hass, config, add_entities, discovery_info=None): 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] + 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).""" + """Convert the given Home Assistant 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).""" + """Convert the given FutureNow (0-100) light level to Home Assistant (0-255).""" return int((level * 255) / 100) -class FutureNowLight(Light): +class FutureNowLight(LightEntity): """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._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) + 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): diff --git a/homeassistant/components/futurenow/manifest.json b/homeassistant/components/futurenow/manifest.json index 5191ab611acf2..c8f07a106e2de 100644 --- a/homeassistant/components/futurenow/manifest.json +++ b/homeassistant/components/futurenow/manifest.json @@ -1,10 +1,7 @@ { "domain": "futurenow", - "name": "Futurenow", - "documentation": "https://www.home-assistant.io/components/futurenow", - "requirements": [ - "pyfnip==0.2" - ], - "dependencies": [], + "name": "P5 FutureNow", + "documentation": "https://www.home-assistant.io/integrations/futurenow", + "requirements": ["pyfnip==0.2"], "codeowners": [] } diff --git a/homeassistant/components/garadget/cover.py b/homeassistant/components/garadget/cover.py index b3c7c7c121575..34a9a13b8d988 100644 --- a/homeassistant/components/garadget/cover.py +++ b/homeassistant/components/garadget/cover.py @@ -4,46 +4,55 @@ import requests import voluptuous as vol +from homeassistant.components.cover import PLATFORM_SCHEMA, CoverEntity +from homeassistant.const import ( + CONF_ACCESS_TOKEN, + CONF_COVERS, + CONF_DEVICE, + CONF_NAME, + CONF_PASSWORD, + CONF_USERNAME, + STATE_CLOSED, + STATE_OPEN, +) 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' +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' +DEFAULT_NAME = "Garadget" -STATE_CLOSING = 'closing' -STATE_OFFLINE = 'offline' -STATE_OPENING = 'opening' -STATE_STOPPED = 'stopped' +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 + "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, -}) +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), -}) +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): @@ -53,11 +62,11 @@ def setup_platform(hass, config, add_entities, discovery_info=None): 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) + "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)) @@ -65,19 +74,19 @@ def setup_platform(hass, config, add_entities, discovery_info=None): add_entities(covers) -class GaradgetCover(CoverDevice): +class GaradgetCover(CoverEntity): """Representation of a Garadget cover.""" def __init__(self, hass, args): """Initialize the cover.""" - self.particle_url = 'https://api.particle.io' + 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._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._username = args["username"] + self._password = args["password"] self._state = None self.time_in_state = None self.signal = None @@ -91,19 +100,20 @@ def __init__(self, hass, args): try: if self._name is None: - doorconfig = self._get_variable('doorConfig') - if doorconfig['nme'] is not None: - self._name = doorconfig['nme'] + 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)) + _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)) + _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 @@ -158,30 +168,27 @@ def is_closed(self): @property def device_class(self): """Return the class of this device, from component DEVICE_CLASSES.""" - return 'garage' + return "garage" def get_token(self): """Get new token for usage during this session.""" args = { - 'grant_type': 'password', - 'username': self._username, - 'password': self._password + "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) + url = f"{self.particle_url}/oauth/token" + ret = requests.post(url, auth=("particle", "particle"), data=args, timeout=10) try: - return ret.json()['access_token'] + 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) + url = f"{self.particle_url}/v1/access_tokens/{self.access_token}" + ret = requests.delete(url, auth=(self._username, self._password), timeout=10) return ret.text def _start_watcher(self, command): @@ -189,7 +196,8 @@ def _start_watcher(self, command): _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) + self.hass, self._check_state + ) def _check_state(self, now): """Check the state of the service during an operation.""" @@ -197,42 +205,43 @@ def _check_state(self, now): 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 + 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 + 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 + 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'] + status = self._get_variable("doorStatus") + _LOGGER.debug("Current Status: %s", status["status"]) + self._state = STATES_MAP.get(status["status"]) + 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)) + _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)) + _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]: @@ -242,21 +251,19 @@ def update(self): def _get_variable(self, var): """Get latest status.""" - url = '{}/v1/devices/{}/{}?access_token={}'.format( - self.particle_url, self.device_id, var, self.access_token) + url = f"{self.particle_url}/v1/devices/{self.device_id}/{var}?access_token={self.access_token}" ret = requests.get(url, timeout=10) result = {} - for pairs in ret.json()['result'].split('|'): - key = pairs.split('=') + 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} + params = {"access_token": self.access_token} if arg: - params['command'] = arg - url = '{}/v1/devices/{}/{}'.format( - self.particle_url, self.device_id, func) + params["command"] = arg + url = f"{self.particle_url}/v1/devices/{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 index d3781f81d046a..21d33405c843e 100644 --- a/homeassistant/components/garadget/manifest.json +++ b/homeassistant/components/garadget/manifest.json @@ -1,8 +1,6 @@ { "domain": "garadget", "name": "Garadget", - "documentation": "https://www.home-assistant.io/components/garadget", - "requirements": [], - "dependencies": [], + "documentation": "https://www.home-assistant.io/integrations/garadget", "codeowners": [] } diff --git a/homeassistant/components/garmin_connect/__init__.py b/homeassistant/components/garmin_connect/__init__.py new file mode 100644 index 0000000000000..85e8132bf0278 --- /dev/null +++ b/homeassistant/components/garmin_connect/__init__.py @@ -0,0 +1,115 @@ +"""The Garmin Connect integration.""" +import asyncio +from datetime import date, timedelta +import logging + +from garminconnect import ( + Garmin, + GarminConnectAuthenticationError, + GarminConnectConnectionError, + GarminConnectTooManyRequestsError, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.util import Throttle + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = ["sensor"] +MIN_SCAN_INTERVAL = timedelta(minutes=10) + + +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the Garmin Connect component.""" + hass.data[DOMAIN] = {} + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up Garmin Connect from a config entry.""" + username = entry.data[CONF_USERNAME] + password = entry.data[CONF_PASSWORD] + + garmin_client = Garmin(username, password) + + try: + await hass.async_add_executor_job(garmin_client.login) + except ( + GarminConnectAuthenticationError, + GarminConnectTooManyRequestsError, + ) as err: + _LOGGER.error("Error occurred during Garmin Connect login request: %s", err) + return False + except (GarminConnectConnectionError) as err: + _LOGGER.error( + "Connection error occurred during Garmin Connect login request: %s", err + ) + raise ConfigEntryNotReady + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unknown error occurred during Garmin Connect login request") + return False + + garmin_data = GarminConnectData(hass, garmin_client) + hass.data[DOMAIN][entry.entry_id] = garmin_data + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ] + ) + ) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +class GarminConnectData: + """Define an object to hold sensor data.""" + + def __init__(self, hass, client): + """Initialize.""" + self.hass = hass + self.client = client + self.data = None + + @Throttle(MIN_SCAN_INTERVAL) + async def async_update(self): + """Update data via library.""" + today = date.today() + + try: + self.data = await self.hass.async_add_executor_job( + self.client.get_stats_and_body, today.isoformat() + ) + except ( + GarminConnectAuthenticationError, + GarminConnectTooManyRequestsError, + GarminConnectConnectionError, + ) as err: + _LOGGER.error( + "Error occurred during Garmin Connect get activity request: %s", err + ) + return + except Exception: # pylint: disable=broad-except + _LOGGER.exception( + "Unknown error occurred during Garmin Connect get activity request" + ) + return diff --git a/homeassistant/components/garmin_connect/config_flow.py b/homeassistant/components/garmin_connect/config_flow.py new file mode 100644 index 0000000000000..e0b50fa371bc3 --- /dev/null +++ b/homeassistant/components/garmin_connect/config_flow.py @@ -0,0 +1,72 @@ +"""Config flow for Garmin Connect integration.""" +import logging + +from garminconnect import ( + Garmin, + GarminConnectAuthenticationError, + GarminConnectConnectionError, + GarminConnectTooManyRequestsError, +) +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_USERNAME + +from .const import DOMAIN # pylint: disable=unused-import + +_LOGGER = logging.getLogger(__name__) + + +class GarminConnectConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Garmin Connect.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + 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_USERNAME): str, vol.Required(CONF_PASSWORD): str} + ), + errors=errors or {}, + ) + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + if user_input is None: + return await self._show_setup_form() + + garmin_client = Garmin(user_input[CONF_USERNAME], user_input[CONF_PASSWORD]) + + errors = {} + try: + await self.hass.async_add_executor_job(garmin_client.login) + except GarminConnectConnectionError: + errors["base"] = "cannot_connect" + return await self._show_setup_form(errors) + except GarminConnectAuthenticationError: + errors["base"] = "invalid_auth" + return await self._show_setup_form(errors) + except GarminConnectTooManyRequestsError: + errors["base"] = "too_many_requests" + return await self._show_setup_form(errors) + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + return await self._show_setup_form(errors) + + unique_id = garmin_client.get_full_name() + + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=unique_id, + data={ + CONF_ID: unique_id, + CONF_USERNAME: user_input[CONF_USERNAME], + CONF_PASSWORD: user_input[CONF_PASSWORD], + }, + ) diff --git a/homeassistant/components/garmin_connect/const.py b/homeassistant/components/garmin_connect/const.py new file mode 100644 index 0000000000000..3c30340fc61ef --- /dev/null +++ b/homeassistant/components/garmin_connect/const.py @@ -0,0 +1,351 @@ +"""Constants for the Garmin Connect integration.""" +from homeassistant.const import ( + DEVICE_CLASS_TIMESTAMP, + LENGTH_METERS, + MASS_KILOGRAMS, + TIME_MINUTES, + UNIT_PERCENTAGE, +) + +DOMAIN = "garmin_connect" +ATTRIBUTION = "Data provided by garmin.com" + +GARMIN_ENTITY_LIST = { + "totalSteps": ["Total Steps", "steps", "mdi:walk", None, True], + "dailyStepGoal": ["Daily Step Goal", "steps", "mdi:walk", None, True], + "totalKilocalories": ["Total KiloCalories", "kcal", "mdi:food", None, True], + "activeKilocalories": ["Active KiloCalories", "kcal", "mdi:food", None, True], + "bmrKilocalories": ["BMR KiloCalories", "kcal", "mdi:food", None, True], + "consumedKilocalories": ["Consumed KiloCalories", "kcal", "mdi:food", None, False], + "burnedKilocalories": ["Burned KiloCalories", "kcal", "mdi:food", None, True], + "remainingKilocalories": [ + "Remaining KiloCalories", + "kcal", + "mdi:food", + None, + False, + ], + "netRemainingKilocalories": [ + "Net Remaining KiloCalories", + "kcal", + "mdi:food", + None, + False, + ], + "netCalorieGoal": ["Net Calorie Goal", "cal", "mdi:food", None, False], + "totalDistanceMeters": [ + "Total Distance Mtr", + LENGTH_METERS, + "mdi:walk", + None, + True, + ], + "wellnessStartTimeLocal": [ + "Wellness Start Time", + "", + "mdi:clock", + DEVICE_CLASS_TIMESTAMP, + False, + ], + "wellnessEndTimeLocal": [ + "Wellness End Time", + "", + "mdi:clock", + DEVICE_CLASS_TIMESTAMP, + False, + ], + "wellnessDescription": ["Wellness Description", "", "mdi:clock", None, False], + "wellnessDistanceMeters": [ + "Wellness Distance Mtr", + LENGTH_METERS, + "mdi:walk", + None, + False, + ], + "wellnessActiveKilocalories": [ + "Wellness Active KiloCalories", + "kcal", + "mdi:food", + None, + False, + ], + "wellnessKilocalories": ["Wellness KiloCalories", "kcal", "mdi:food", None, False], + "highlyActiveSeconds": [ + "Highly Active Time", + TIME_MINUTES, + "mdi:fire", + None, + False, + ], + "activeSeconds": ["Active Time", TIME_MINUTES, "mdi:fire", None, True], + "sedentarySeconds": ["Sedentary Time", TIME_MINUTES, "mdi:seat", None, True], + "sleepingSeconds": ["Sleeping Time", TIME_MINUTES, "mdi:sleep", None, True], + "measurableAwakeDuration": [ + "Awake Duration", + TIME_MINUTES, + "mdi:sleep", + None, + True, + ], + "measurableAsleepDuration": [ + "Sleep Duration", + TIME_MINUTES, + "mdi:sleep", + None, + True, + ], + "floorsAscendedInMeters": [ + "Floors Ascended Mtr", + LENGTH_METERS, + "mdi:stairs", + None, + False, + ], + "floorsDescendedInMeters": [ + "Floors Descended Mtr", + LENGTH_METERS, + "mdi:stairs", + None, + False, + ], + "floorsAscended": ["Floors Ascended", "floors", "mdi:stairs", None, True], + "floorsDescended": ["Floors Descended", "floors", "mdi:stairs", None, True], + "userFloorsAscendedGoal": [ + "Floors Ascended Goal", + "floors", + "mdi:stairs", + None, + True, + ], + "minHeartRate": ["Min Heart Rate", "bpm", "mdi:heart-pulse", None, True], + "maxHeartRate": ["Max Heart Rate", "bpm", "mdi:heart-pulse", None, True], + "restingHeartRate": ["Resting Heart Rate", "bpm", "mdi:heart-pulse", None, True], + "minAvgHeartRate": ["Min Avg Heart Rate", "bpm", "mdi:heart-pulse", None, False], + "maxAvgHeartRate": ["Max Avg Heart Rate", "bpm", "mdi:heart-pulse", None, False], + "abnormalHeartRateAlertsCount": [ + "Abnormal HR Counts", + "", + "mdi:heart-pulse", + None, + False, + ], + "lastSevenDaysAvgRestingHeartRate": [ + "Last 7 Days Avg Heart Rate", + "bpm", + "mdi:heart-pulse", + None, + False, + ], + "averageStressLevel": ["Avg Stress Level", "", "mdi:flash-alert", None, True], + "maxStressLevel": ["Max Stress Level", "", "mdi:flash-alert", None, True], + "stressQualifier": ["Stress Qualifier", "", "mdi:flash-alert", None, False], + "stressDuration": ["Stress Duration", TIME_MINUTES, "mdi:flash-alert", None, False], + "restStressDuration": [ + "Rest Stress Duration", + TIME_MINUTES, + "mdi:flash-alert", + None, + True, + ], + "activityStressDuration": [ + "Activity Stress Duration", + TIME_MINUTES, + "mdi:flash-alert", + None, + True, + ], + "uncategorizedStressDuration": [ + "Uncat. Stress Duration", + TIME_MINUTES, + "mdi:flash-alert", + None, + True, + ], + "totalStressDuration": [ + "Total Stress Duration", + TIME_MINUTES, + "mdi:flash-alert", + None, + True, + ], + "lowStressDuration": [ + "Low Stress Duration", + TIME_MINUTES, + "mdi:flash-alert", + None, + True, + ], + "mediumStressDuration": [ + "Medium Stress Duration", + TIME_MINUTES, + "mdi:flash-alert", + None, + True, + ], + "highStressDuration": [ + "High Stress Duration", + TIME_MINUTES, + "mdi:flash-alert", + None, + True, + ], + "stressPercentage": [ + "Stress Percentage", + UNIT_PERCENTAGE, + "mdi:flash-alert", + None, + False, + ], + "restStressPercentage": [ + "Rest Stress Percentage", + UNIT_PERCENTAGE, + "mdi:flash-alert", + None, + False, + ], + "activityStressPercentage": [ + "Activity Stress Percentage", + UNIT_PERCENTAGE, + "mdi:flash-alert", + None, + False, + ], + "uncategorizedStressPercentage": [ + "Uncat. Stress Percentage", + UNIT_PERCENTAGE, + "mdi:flash-alert", + None, + False, + ], + "lowStressPercentage": [ + "Low Stress Percentage", + UNIT_PERCENTAGE, + "mdi:flash-alert", + None, + False, + ], + "mediumStressPercentage": [ + "Medium Stress Percentage", + UNIT_PERCENTAGE, + "mdi:flash-alert", + None, + False, + ], + "highStressPercentage": [ + "High Stress Percentage", + UNIT_PERCENTAGE, + "mdi:flash-alert", + None, + False, + ], + "moderateIntensityMinutes": [ + "Moderate Intensity", + TIME_MINUTES, + "mdi:flash-alert", + None, + False, + ], + "vigorousIntensityMinutes": [ + "Vigorous Intensity", + TIME_MINUTES, + "mdi:run-fast", + None, + False, + ], + "intensityMinutesGoal": [ + "Intensity Goal", + TIME_MINUTES, + "mdi:run-fast", + None, + False, + ], + "bodyBatteryChargedValue": [ + "Body Battery Charged", + UNIT_PERCENTAGE, + "mdi:battery-charging-100", + None, + True, + ], + "bodyBatteryDrainedValue": [ + "Body Battery Drained", + UNIT_PERCENTAGE, + "mdi:battery-alert-variant-outline", + None, + True, + ], + "bodyBatteryHighestValue": [ + "Body Battery Highest", + UNIT_PERCENTAGE, + "mdi:battery-heart", + None, + True, + ], + "bodyBatteryLowestValue": [ + "Body Battery Lowest", + UNIT_PERCENTAGE, + "mdi:battery-heart-outline", + None, + True, + ], + "bodyBatteryMostRecentValue": [ + "Body Battery Most Recent", + UNIT_PERCENTAGE, + "mdi:battery-positive", + None, + True, + ], + "averageSpo2": ["Average SPO2", UNIT_PERCENTAGE, "mdi:diabetes", None, True], + "lowestSpo2": ["Lowest SPO2", UNIT_PERCENTAGE, "mdi:diabetes", None, True], + "latestSpo2": ["Latest SPO2", UNIT_PERCENTAGE, "mdi:diabetes", None, True], + "latestSpo2ReadingTimeLocal": [ + "Latest SPO2 Time", + "", + "mdi:diabetes", + DEVICE_CLASS_TIMESTAMP, + False, + ], + "averageMonitoringEnvironmentAltitude": [ + "Average Altitude", + UNIT_PERCENTAGE, + "mdi:image-filter-hdr", + None, + False, + ], + "highestRespirationValue": [ + "Highest Respiration", + "brpm", + "mdi:progress-clock", + None, + False, + ], + "lowestRespirationValue": [ + "Lowest Respiration", + "brpm", + "mdi:progress-clock", + None, + False, + ], + "latestRespirationValue": [ + "Latest Respiration", + "brpm", + "mdi:progress-clock", + None, + False, + ], + "latestRespirationTimeGMT": [ + "Latest Respiration Update", + "", + "mdi:progress-clock", + DEVICE_CLASS_TIMESTAMP, + False, + ], + "weight": ["Weight", MASS_KILOGRAMS, "mdi:weight-kilogram", None, False], + "bmi": ["BMI", "", "mdi:food", None, False], + "bodyFat": ["Body Fat", UNIT_PERCENTAGE, "mdi:food", None, False], + "bodyWater": ["Body Water", UNIT_PERCENTAGE, "mdi:water-percent", None, False], + "bodyMass": ["Body Mass", MASS_KILOGRAMS, "mdi:food", None, False], + "muscleMass": ["Muscle Mass", MASS_KILOGRAMS, "mdi:dumbbell", None, False], + "physiqueRating": ["Physique Rating", "", "mdi:numeric", None, False], + "visceralFat": ["Visceral Fat", "", "mdi:food", None, False], + "metabolicAge": ["Metabolic Age", "", "mdi:calendar-heart", None, False], +} diff --git a/homeassistant/components/garmin_connect/manifest.json b/homeassistant/components/garmin_connect/manifest.json new file mode 100644 index 0000000000000..09c916104df6b --- /dev/null +++ b/homeassistant/components/garmin_connect/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "garmin_connect", + "name": "Garmin Connect", + "documentation": "https://www.home-assistant.io/integrations/garmin_connect", + "requirements": ["garminconnect==0.1.10"], + "codeowners": ["@cyberjunky"], + "config_flow": true +} diff --git a/homeassistant/components/garmin_connect/sensor.py b/homeassistant/components/garmin_connect/sensor.py new file mode 100644 index 0000000000000..78bf248c51b12 --- /dev/null +++ b/homeassistant/components/garmin_connect/sensor.py @@ -0,0 +1,186 @@ +"""Platform for Garmin Connect integration.""" +import logging +from typing import Any, Dict + +from garminconnect import ( + GarminConnectAuthenticationError, + GarminConnectConnectionError, + GarminConnectTooManyRequestsError, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_ATTRIBUTION, CONF_ID +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.typing import HomeAssistantType + +from .const import ATTRIBUTION, DOMAIN, GARMIN_ENTITY_LIST + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistantType, entry: ConfigEntry, async_add_entities +) -> None: + """Set up Garmin Connect sensor based on a config entry.""" + garmin_data = hass.data[DOMAIN][entry.entry_id] + unique_id = entry.data[CONF_ID] + + try: + await garmin_data.async_update() + except ( + GarminConnectConnectionError, + GarminConnectAuthenticationError, + GarminConnectTooManyRequestsError, + ) as err: + _LOGGER.error("Error occurred during Garmin Connect Client update: %s", err) + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unknown error occurred during Garmin Connect Client update.") + + entities = [] + for ( + sensor_type, + (name, unit, icon, device_class, enabled_by_default), + ) in GARMIN_ENTITY_LIST.items(): + + _LOGGER.debug( + "Registering entity: %s, %s, %s, %s, %s, %s", + sensor_type, + name, + unit, + icon, + device_class, + enabled_by_default, + ) + entities.append( + GarminConnectSensor( + garmin_data, + unique_id, + sensor_type, + name, + unit, + icon, + device_class, + enabled_by_default, + ) + ) + + async_add_entities(entities, True) + + +class GarminConnectSensor(Entity): + """Representation of a Garmin Connect Sensor.""" + + def __init__( + self, + data, + unique_id, + sensor_type, + name, + unit, + icon, + device_class, + enabled_default: bool = True, + ): + """Initialize.""" + self._data = data + self._unique_id = unique_id + self._type = sensor_type + self._name = name + self._unit = unit + self._icon = icon + self._device_class = device_class + self._enabled_default = enabled_default + self._available = True + self._state = 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.""" + return self._icon + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def unique_id(self) -> str: + """Return the unique ID for this sensor.""" + return f"{self._unique_id}_{self._type}" + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self._unit + + @property + def device_state_attributes(self): + """Return attributes for sensor.""" + attributes = {} + if self._data.data: + attributes = { + "source": self._data.data["source"], + "last_synced": self._data.data["lastSyncTimestampGMT"], + ATTR_ATTRIBUTION: ATTRIBUTION, + } + return attributes + + @property + def device_info(self) -> Dict[str, Any]: + """Return device information.""" + return { + "identifiers": {(DOMAIN, self._unique_id)}, + "name": "Garmin Connect", + "manufacturer": "Garmin Connect", + } + + @property + def entity_registry_enabled_default(self) -> bool: + """Return if the entity should be enabled when first added to the entity registry.""" + return self._enabled_default + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._available + + @property + def device_class(self): + """Return the device class of the sensor.""" + return self._device_class + + async def async_update(self): + """Update the data from Garmin Connect.""" + if not self.enabled: + return + + await self._data.async_update() + data = self._data.data + if not data: + _LOGGER.error("Didn't receive data from Garmin Connect") + return + if data.get(self._type) is None: + _LOGGER.debug("Entity type %s not set in fetched data", self._type) + self._available = False + return + self._available = True + + if "Duration" in self._type or "Seconds" in self._type: + self._state = data[self._type] // 60 + elif "Mass" in self._type or self._type == "weight": + self._state = round((data[self._type] / 1000), 2) + elif ( + self._type == "bodyFat" or self._type == "bodyWater" or self._type == "bmi" + ): + self._state = round(data[self._type], 2) + else: + self._state = data[self._type] + + _LOGGER.debug( + "Entity %s set to state %s %s", self._type, self._state, self._unit + ) diff --git a/homeassistant/components/garmin_connect/strings.json b/homeassistant/components/garmin_connect/strings.json new file mode 100644 index 0000000000000..1f14d91e04aef --- /dev/null +++ b/homeassistant/components/garmin_connect/strings.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { "already_configured": "This account is already configured." }, + "error": { + "cannot_connect": "Failed to connect, please try again.", + "invalid_auth": "Invalid authentication.", + "too_many_requests": "Too many requests, retry later.", + "unknown": "Unexpected error." + }, + "step": { + "user": { + "data": { "password": "Password", "username": "Username" }, + "description": "Enter your credentials.", + "title": "Garmin Connect" + } + } + } +} diff --git a/homeassistant/components/garmin_connect/translations/ca.json b/homeassistant/components/garmin_connect/translations/ca.json new file mode 100644 index 0000000000000..d128e1d2e25d8 --- /dev/null +++ b/homeassistant/components/garmin_connect/translations/ca.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Aquest compte ja est\u00e0 configurat." + }, + "error": { + "cannot_connect": "No s'ha pogut connectar, torna-ho a provar.", + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida.", + "too_many_requests": "Massa sol\u00b7licituds, torna-ho a intentar m\u00e9s tard.", + "unknown": "Error inesperat." + }, + "step": { + "user": { + "data": { + "password": "Contrasenya", + "username": "Nom d'usuari" + }, + "description": "Introdueix les teves credencials.", + "title": "Garmin Connect" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/translations/cs.json b/homeassistant/components/garmin_connect/translations/cs.json new file mode 100644 index 0000000000000..504c95290e1a2 --- /dev/null +++ b/homeassistant/components/garmin_connect/translations/cs.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Tento \u00fa\u010det je ji\u017e nakonfigurov\u00e1n." + }, + "error": { + "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit, zkuste to znovu.", + "invalid_auth": "Neplatn\u00e9 ov\u011b\u0159en\u00ed.", + "too_many_requests": "P\u0159\u00edli\u0161 mnoho po\u017eadavk\u016f, opakujte to pozd\u011bji.", + "unknown": "Neo\u010dek\u00e1van\u00e1 chyba" + }, + "step": { + "user": { + "data": { + "password": "Heslo", + "username": "U\u017eivatelsk\u00e9 jm\u00e9no" + }, + "description": "Zadejte sv\u00e9 p\u0159ihla\u0161ovac\u00ed \u00fadaje.", + "title": "Garmin Connect" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/translations/da.json b/homeassistant/components/garmin_connect/translations/da.json new file mode 100644 index 0000000000000..f664ad0e1f449 --- /dev/null +++ b/homeassistant/components/garmin_connect/translations/da.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Denne konto er allerede konfigureret." + }, + "error": { + "cannot_connect": "Kunne ikke oprette forbindelse - pr\u00f8v igen.", + "invalid_auth": "Ugyldig godkendelse.", + "too_many_requests": "For mange anmodninger - pr\u00f8v igen senere.", + "unknown": "Uventet fejl." + }, + "step": { + "user": { + "data": { + "password": "Adgangskode", + "username": "Brugernavn" + }, + "description": "Indtast dine legitimationsoplysninger.", + "title": "Garmin Connect" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/translations/de.json b/homeassistant/components/garmin_connect/translations/de.json new file mode 100644 index 0000000000000..54d27e9956e0a --- /dev/null +++ b/homeassistant/components/garmin_connect/translations/de.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Dieses Konto ist bereits konfiguriert." + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen. Bitte versuchen Sie es erneut.", + "invalid_auth": "Ung\u00fcltige Authentifizierung.", + "too_many_requests": "Zu viele Anfragen, wiederholen Sie es sp\u00e4ter.", + "unknown": "Unerwarteter Fehler." + }, + "step": { + "user": { + "data": { + "password": "Passwort", + "username": "Benutzername" + }, + "description": "Geben Sie Ihre Zugangsdaten ein.", + "title": "Garmin Connect" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/translations/en.json b/homeassistant/components/garmin_connect/translations/en.json new file mode 100644 index 0000000000000..52ae403cca388 --- /dev/null +++ b/homeassistant/components/garmin_connect/translations/en.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "This account is already configured." + }, + "error": { + "cannot_connect": "Failed to connect, please try again.", + "invalid_auth": "Invalid authentication.", + "too_many_requests": "Too many requests, retry later.", + "unknown": "Unexpected error." + }, + "step": { + "user": { + "data": { + "password": "Password", + "username": "Username" + }, + "description": "Enter your credentials.", + "title": "Garmin Connect" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/translations/es-419.json b/homeassistant/components/garmin_connect/translations/es-419.json new file mode 100644 index 0000000000000..6e20b4cd2cc18 --- /dev/null +++ b/homeassistant/components/garmin_connect/translations/es-419.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Esta cuenta ya est\u00e1 configurada." + }, + "error": { + "cannot_connect": "No se pudo conectar, intente nuevamente.", + "invalid_auth": "Autenticaci\u00f3n inv\u00e1lida", + "too_many_requests": "Demasiadas solicitudes, vuelva a intentarlo m\u00e1s tarde.", + "unknown": "Error inesperado." + }, + "step": { + "user": { + "data": { + "password": "Contrase\u00f1a", + "username": "Nombre de usuario" + }, + "description": "Ingrese sus credenciales." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/translations/es.json b/homeassistant/components/garmin_connect/translations/es.json new file mode 100644 index 0000000000000..e14769bc67050 --- /dev/null +++ b/homeassistant/components/garmin_connect/translations/es.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Esta cuenta ya est\u00e1 configurada." + }, + "error": { + "cannot_connect": "No se pudo conectar, por favor, int\u00e9ntelo de nuevo.", + "invalid_auth": "Autenticaci\u00f3n inv\u00e1lida", + "too_many_requests": "Demasiadas solicitudes, vuelva a intentarlo m\u00e1s tarde.", + "unknown": "Error inesperado" + }, + "step": { + "user": { + "data": { + "password": "Contrase\u00f1a", + "username": "Usuario" + }, + "description": "Introduzca sus credenciales.", + "title": "Garmin Connect" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/translations/fr.json b/homeassistant/components/garmin_connect/translations/fr.json new file mode 100644 index 0000000000000..ce97ccccf1bed --- /dev/null +++ b/homeassistant/components/garmin_connect/translations/fr.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Ce compte est d\u00e9j\u00e0 configur\u00e9." + }, + "error": { + "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer.", + "invalid_auth": "Authentification non valide.", + "too_many_requests": "Trop de demandes, r\u00e9essayez plus tard.", + "unknown": "Erreur inattendue." + }, + "step": { + "user": { + "data": { + "password": "Mot de passe", + "username": "Nom d'utilisateur" + }, + "description": "Entrez vos informations d'identification.", + "title": "Garmin Connect" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/translations/hu.json b/homeassistant/components/garmin_connect/translations/hu.json new file mode 100644 index 0000000000000..fd805fc4e4d57 --- /dev/null +++ b/homeassistant/components/garmin_connect/translations/hu.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Ez a fi\u00f3k m\u00e1r konfigur\u00e1lva van." + }, + "error": { + "cannot_connect": "Nem siker\u00fclt csatlakozni, pr\u00f3b\u00e1lkozzon \u00fajra.", + "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s.", + "too_many_requests": "T\u00fal sok k\u00e9r\u00e9s, pr\u00f3b\u00e1lkozzon k\u00e9s\u0151bb \u00fajra.", + "unknown": "V\u00e1ratlan hiba." + }, + "step": { + "user": { + "data": { + "password": "Jelsz\u00f3", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + }, + "description": "Adja meg a hiteles\u00edt\u0151 adatait.", + "title": "Garmin Csatlakoz\u00e1s" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/translations/it.json b/homeassistant/components/garmin_connect/translations/it.json new file mode 100644 index 0000000000000..70f4bf8401763 --- /dev/null +++ b/homeassistant/components/garmin_connect/translations/it.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Questo account \u00e8 gi\u00e0 configurato." + }, + "error": { + "cannot_connect": "Impossibile connettersi, si prega di riprovare.", + "invalid_auth": "Autenticazione non valida.", + "too_many_requests": "Troppe richieste, riprovare pi\u00f9 tardi.", + "unknown": "Errore imprevisto." + }, + "step": { + "user": { + "data": { + "password": "Password", + "username": "Nome utente" + }, + "description": "Inserisci le tue credenziali", + "title": "Garmin Connect" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/translations/ko.json b/homeassistant/components/garmin_connect/translations/ko.json new file mode 100644 index 0000000000000..4727a21db6a8e --- /dev/null +++ b/homeassistant/components/garmin_connect/translations/ko.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "\uc774 \uacc4\uc815\uc740 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + }, + "error": { + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.", + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "too_many_requests": "\uc694\uccad\uc774 \ub108\ubb34 \ub9ce\uc2b5\ub2c8\ub2e4. \ub098\uc911\uc5d0 \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.", + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" + }, + "step": { + "user": { + "data": { + "password": "\ube44\ubc00\ubc88\ud638", + "username": "\uc0ac\uc6a9\uc790 \uc774\ub984" + }, + "description": "\uc790\uaca9 \uc99d\uba85\uc744 \uc785\ub825\ud574\uc8fc\uc138\uc694", + "title": "Garmin Connect" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/translations/lb.json b/homeassistant/components/garmin_connect/translations/lb.json new file mode 100644 index 0000000000000..6a5b859c3e159 --- /dev/null +++ b/homeassistant/components/garmin_connect/translations/lb.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "D\u00ebse Kont ass scho konfigur\u00e9iert" + }, + "error": { + "cannot_connect": "Feeler beim verbannen, prob\u00e9iert w.e.g. nach emol.", + "invalid_auth": "Ong\u00eblteg Authentifikatioun.", + "too_many_requests": "Ze vill Ufroen, prob\u00e9iert sp\u00e9ider nach emol.", + "unknown": "Onerwaarte Feeler." + }, + "step": { + "user": { + "data": { + "password": "Passwuert", + "username": "Benotzernumm" + }, + "description": "F\u00ebllt \u00e4r Umeldungs Informatiounen aus.", + "title": "Garmin Connect" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/translations/lv.json b/homeassistant/components/garmin_connect/translations/lv.json new file mode 100644 index 0000000000000..2c205bdd324b1 --- /dev/null +++ b/homeassistant/components/garmin_connect/translations/lv.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "Parole", + "username": "Lietot\u0101jv\u0101rds" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/translations/nl.json b/homeassistant/components/garmin_connect/translations/nl.json new file mode 100644 index 0000000000000..e9b71c49c7176 --- /dev/null +++ b/homeassistant/components/garmin_connect/translations/nl.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Dit account is al geconfigureerd." + }, + "error": { + "cannot_connect": "Verbinding mislukt, probeer het opnieuw.", + "invalid_auth": "Ongeldige authenticatie", + "too_many_requests": "Te veel aanvragen, probeer het later opnieuw.", + "unknown": "Onverwachte fout" + }, + "step": { + "user": { + "data": { + "password": "Wachtwoord", + "username": "Gebruikersnaam" + }, + "description": "Voer uw gegevens in", + "title": "Garmin Connect" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/translations/no.json b/homeassistant/components/garmin_connect/translations/no.json new file mode 100644 index 0000000000000..ae678abfe05dc --- /dev/null +++ b/homeassistant/components/garmin_connect/translations/no.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Denne kontoen er allerede konfigurert." + }, + "error": { + "cannot_connect": "Kunne ikke koble til, pr\u00f8v igjen.", + "invalid_auth": "Ugyldig godkjenning.", + "too_many_requests": "For mange foresp\u00f8rsler, pr\u00f8v p\u00e5 nytt senere.", + "unknown": "Uventet feil." + }, + "step": { + "user": { + "data": { + "password": "Passord", + "username": "Brukernavn" + }, + "description": "Angi brukeropplysninger.", + "title": "" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/translations/pl.json b/homeassistant/components/garmin_connect/translations/pl.json new file mode 100644 index 0000000000000..e144ac994f77a --- /dev/null +++ b/homeassistant/components/garmin_connect/translations/pl.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "To konto jest ju\u017c skonfigurowane." + }, + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia, spr\u00f3buj ponownie.", + "invalid_auth": "Niepoprawne uwierzytelnienie.", + "too_many_requests": "Zbyt wiele \u017c\u0105da\u0144, spr\u00f3buj ponownie p\u00f3\u017aniej.", + "unknown": "Niespodziewany b\u0142\u0105d." + }, + "step": { + "user": { + "data": { + "password": "Has\u0142o", + "username": "Nazwa u\u017cytkownika" + }, + "description": "Wprowad\u017a dane uwierzytelniaj\u0105ce", + "title": "Garmin Connect" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/translations/pt.json b/homeassistant/components/garmin_connect/translations/pt.json new file mode 100644 index 0000000000000..b46423599731a --- /dev/null +++ b/homeassistant/components/garmin_connect/translations/pt.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "Palavra-passe", + "username": "Nome de Utilizador" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/translations/ru.json b/homeassistant/components/garmin_connect/translations/ru.json new file mode 100644 index 0000000000000..b3aa4e6604ca8 --- /dev/null +++ b/homeassistant/components/garmin_connect/translations/ru.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "\u0423\u0447\u0451\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0430." + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f, \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0435\u0449\u0435 \u0440\u0430\u0437.", + "invalid_auth": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f.", + "too_many_requests": "\u0421\u043b\u0438\u0448\u043a\u043e\u043c \u043c\u043d\u043e\u0433\u043e \u0437\u0430\u043f\u0440\u043e\u0441\u043e\u0432, \u043f\u043e\u0432\u0442\u043e\u0440\u0438\u0442\u0435 \u043f\u043e\u043f\u044b\u0442\u043a\u0443 \u043f\u043e\u0437\u0436\u0435.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "step": { + "user": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u044c", + "username": "\u041b\u043e\u0433\u0438\u043d" + }, + "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0412\u0430\u0448\u0438 \u0443\u0447\u0451\u0442\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435.", + "title": "Garmin Connect" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/translations/sl.json b/homeassistant/components/garmin_connect/translations/sl.json new file mode 100644 index 0000000000000..594cbffeaa72f --- /dev/null +++ b/homeassistant/components/garmin_connect/translations/sl.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Ta ra\u010dun je \u017ee konfiguriran." + }, + "error": { + "cannot_connect": "Povezava ni uspela, poskusite znova.", + "invalid_auth": "Neveljavna avtentikacija.", + "too_many_requests": "Preve\u010d zahtev, poskusite pozneje.", + "unknown": "Nepri\u010dakovana napaka." + }, + "step": { + "user": { + "data": { + "password": "Geslo", + "username": "Uporabni\u0161ko ime" + }, + "description": "Vnesite svoje poverilnice.", + "title": "Garmin Connect" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/translations/sv.json b/homeassistant/components/garmin_connect/translations/sv.json new file mode 100644 index 0000000000000..0f11ab2a8b963 --- /dev/null +++ b/homeassistant/components/garmin_connect/translations/sv.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Det h\u00e4r kontot har redan konfigurerats." + }, + "error": { + "cannot_connect": "Kunde inte ansluta, var god f\u00f6rs\u00f6k igen.", + "invalid_auth": "Ogiltig autentisering.", + "too_many_requests": "F\u00f6r m\u00e5nga f\u00f6rfr\u00e5gningar, f\u00f6rs\u00f6k igen senare.", + "unknown": "Ov\u00e4ntat fel." + }, + "step": { + "user": { + "data": { + "password": "L\u00f6senord", + "username": "Anv\u00e4ndarnamn" + }, + "description": "Ange dina anv\u00e4ndaruppgifter.", + "title": "Garmin Connect" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/translations/zh-Hant.json b/homeassistant/components/garmin_connect/translations/zh-Hant.json new file mode 100644 index 0000000000000..f13f62abbedd7 --- /dev/null +++ b/homeassistant/components/garmin_connect/translations/zh-Hant.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "\u6b64\u5e33\u865f\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210\u3002" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557\uff0c\u8acb\u518d\u8a66\u4e00\u6b21\u3002", + "invalid_auth": "\u9a57\u8b49\u7121\u6548\u3002", + "too_many_requests": "\u8acb\u6c42\u6b21\u6578\u904e\u591a\uff0c\u8acb\u7a0d\u5f8c\u91cd\u8a66\u3002", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4\u3002" + }, + "step": { + "user": { + "data": { + "password": "\u5bc6\u78bc", + "username": "\u4f7f\u7528\u8005\u540d\u7a31" + }, + "description": "\u8f38\u5165\u6191\u8b49\u3002", + "title": "Garmin Connect" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/gc100/__init__.py b/homeassistant/components/gc100/__init__.py index b875d045cc09d..36779b28df29a 100644 --- a/homeassistant/components/gc100/__init__.py +++ b/homeassistant/components/gc100/__init__.py @@ -1,34 +1,37 @@ """Support for controlling Global Cache gc100.""" import logging +import gc100 import voluptuous as vol -from homeassistant.const import ( - EVENT_HOMEASSISTANT_STOP, CONF_HOST, CONF_PORT) +from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -CONF_PORTS = 'ports' +CONF_PORTS = "ports" DEFAULT_PORT = 4998 -DOMAIN = 'gc100' +DOMAIN = "gc100" -DATA_GC100 = '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) +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] diff --git a/homeassistant/components/gc100/binary_sensor.py b/homeassistant/components/gc100/binary_sensor.py index 4ba68a1779965..43ceb75e449be 100644 --- a/homeassistant/components/gc100/binary_sensor.py +++ b/homeassistant/components/gc100/binary_sensor.py @@ -1,20 +1,17 @@ """Support for binary sensor using GC100.""" import voluptuous as vol -from homeassistant.components.binary_sensor import ( - PLATFORM_SCHEMA, BinarySensorDevice) +from homeassistant.components.binary_sensor import PLATFORM_SCHEMA, BinarySensorEntity 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, -}) +_SENSORS_SCHEMA = vol.Schema({cv.string: cv.string}) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_PORTS): vol.All(cv.ensure_list, [_SENSORS_SCHEMA]) -}) +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): @@ -23,12 +20,13 @@ def setup_platform(hass, config, add_entities, discovery_info=None): 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])) + binary_sensors.append( + GC100BinarySensor(port_name, port_addr, hass.data[DATA_GC100]) + ) add_entities(binary_sensors, True) -class GC100BinarySensor(BinarySensorDevice): +class GC100BinarySensor(BinarySensorEntity): """Representation of a binary sensor from GC100.""" def __init__(self, name, port_addr, gc100): diff --git a/homeassistant/components/gc100/manifest.json b/homeassistant/components/gc100/manifest.json index 96d792196ce95..e2dffb1e09000 100644 --- a/homeassistant/components/gc100/manifest.json +++ b/homeassistant/components/gc100/manifest.json @@ -1,10 +1,7 @@ { "domain": "gc100", - "name": "Gc100", - "documentation": "https://www.home-assistant.io/components/gc100", - "requirements": [ - "python-gc100==1.0.3a" - ], - "dependencies": [], + "name": "Global Caché GC-100", + "documentation": "https://www.home-assistant.io/integrations/gc100", + "requirements": ["python-gc100==1.0.3a"], "codeowners": [] } diff --git a/homeassistant/components/gc100/switch.py b/homeassistant/components/gc100/switch.py index eea98a4dc23c6..6be42e35deb6a 100644 --- a/homeassistant/components/gc100/switch.py +++ b/homeassistant/components/gc100/switch.py @@ -8,13 +8,11 @@ from . import CONF_PORTS, DATA_GC100 -_SWITCH_SCHEMA = vol.Schema({ - cv.string: cv.string, -}) +_SWITCH_SCHEMA = vol.Schema({cv.string: cv.string}) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_PORTS): vol.All(cv.ensure_list, [_SWITCH_SCHEMA]) -}) +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): @@ -23,8 +21,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): 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])) + switches.append(GC100Switch(port_name, port_addr, hass.data[DATA_GC100])) add_entities(switches, True) diff --git a/homeassistant/components/gdacs/__init__.py b/homeassistant/components/gdacs/__init__.py new file mode 100644 index 0000000000000..8144b7667ca6c --- /dev/null +++ b/homeassistant/components/gdacs/__init__.py @@ -0,0 +1,212 @@ +"""The Global Disaster Alert and Coordination System (GDACS) integration.""" +import asyncio +from datetime import timedelta +import logging + +from aio_georss_gdacs import GdacsFeedManager +import voluptuous as vol + +from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.const import ( + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_RADIUS, + CONF_SCAN_INTERVAL, + CONF_UNIT_SYSTEM_IMPERIAL, + LENGTH_MILES, +) +from homeassistant.core import callback +from homeassistant.helpers import aiohttp_client, config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.util.unit_system import METRIC_SYSTEM + +from .const import ( + CONF_CATEGORIES, + DEFAULT_RADIUS, + DEFAULT_SCAN_INTERVAL, + DOMAIN, + FEED, + PLATFORMS, + VALID_CATEGORIES, +) + +_LOGGER = logging.getLogger(__name__) + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Inclusive(CONF_LATITUDE, "coordinates"): cv.latitude, + vol.Inclusive(CONF_LONGITUDE, "coordinates"): cv.longitude, + vol.Optional(CONF_RADIUS, default=DEFAULT_RADIUS): vol.Coerce(float), + vol.Optional( + CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL + ): cv.time_period, + vol.Optional(CONF_CATEGORIES, default=[]): vol.All( + cv.ensure_list, [vol.In(VALID_CATEGORIES)] + ), + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass, config): + """Set up the GDACS component.""" + if DOMAIN not in config: + return True + + conf = config[DOMAIN] + latitude = conf.get(CONF_LATITUDE, hass.config.latitude) + longitude = conf.get(CONF_LONGITUDE, hass.config.longitude) + scan_interval = conf[CONF_SCAN_INTERVAL] + categories = conf[CONF_CATEGORIES] + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_LATITUDE: latitude, + CONF_LONGITUDE: longitude, + CONF_RADIUS: conf[CONF_RADIUS], + CONF_SCAN_INTERVAL: scan_interval, + CONF_CATEGORIES: categories, + }, + ) + ) + + return True + + +async def async_setup_entry(hass, config_entry): + """Set up the GDACS component as config entry.""" + hass.data.setdefault(DOMAIN, {}) + feeds = hass.data[DOMAIN].setdefault(FEED, {}) + + radius = config_entry.data[CONF_RADIUS] + if hass.config.units.name == CONF_UNIT_SYSTEM_IMPERIAL: + radius = METRIC_SYSTEM.length(radius, LENGTH_MILES) + # Create feed entity manager for all platforms. + manager = GdacsFeedEntityManager(hass, config_entry, radius) + feeds[config_entry.entry_id] = manager + _LOGGER.debug("Feed entity manager added for %s", config_entry.entry_id) + await manager.async_init() + return True + + +async def async_unload_entry(hass, config_entry): + """Unload an GDACS component config entry.""" + manager = hass.data[DOMAIN][FEED].pop(config_entry.entry_id) + await manager.async_stop() + await asyncio.wait( + [ + hass.config_entries.async_forward_entry_unload(config_entry, domain) + for domain in PLATFORMS + ] + ) + return True + + +class GdacsFeedEntityManager: + """Feed Entity Manager for GDACS feed.""" + + def __init__(self, hass, config_entry, radius_in_km): + """Initialize the Feed Entity Manager.""" + self._hass = hass + self._config_entry = config_entry + coordinates = ( + config_entry.data[CONF_LATITUDE], + config_entry.data[CONF_LONGITUDE], + ) + categories = config_entry.data[CONF_CATEGORIES] + websession = aiohttp_client.async_get_clientsession(hass) + self._feed_manager = GdacsFeedManager( + websession, + self._generate_entity, + self._update_entity, + self._remove_entity, + coordinates, + filter_radius=radius_in_km, + filter_categories=categories, + status_async_callback=self._status_update, + ) + self._config_entry_id = config_entry.entry_id + self._scan_interval = timedelta(seconds=config_entry.data[CONF_SCAN_INTERVAL]) + self._track_time_remove_callback = None + self._status_info = None + self.listeners = [] + + async def async_init(self): + """Schedule initial and regular updates based on configured time interval.""" + + for domain in PLATFORMS: + self._hass.async_create_task( + self._hass.config_entries.async_forward_entry_setup( + self._config_entry, domain + ) + ) + + async def update(event_time): + """Update.""" + await self.async_update() + + # Trigger updates at regular intervals. + self._track_time_remove_callback = async_track_time_interval( + self._hass, update, self._scan_interval + ) + + _LOGGER.debug("Feed entity manager initialized") + + async def async_update(self): + """Refresh data.""" + await self._feed_manager.update() + _LOGGER.debug("Feed entity manager updated") + + async def async_stop(self): + """Stop this feed entity manager from refreshing.""" + for unsub_dispatcher in self.listeners: + unsub_dispatcher() + self.listeners = [] + if self._track_time_remove_callback: + self._track_time_remove_callback() + _LOGGER.debug("Feed entity manager stopped") + + @callback + def async_event_new_entity(self): + """Return manager specific event to signal new entity.""" + return f"gdacs_new_geolocation_{self._config_entry_id}" + + def get_entry(self, external_id): + """Get feed entry by external id.""" + return self._feed_manager.feed_entries.get(external_id) + + def status_info(self): + """Return latest status update info received.""" + return self._status_info + + async def _generate_entity(self, external_id): + """Generate new entity.""" + async_dispatcher_send( + self._hass, + self.async_event_new_entity(), + self, + self._config_entry.unique_id, + external_id, + ) + + async def _update_entity(self, external_id): + """Update entity.""" + async_dispatcher_send(self._hass, f"gdacs_update_{external_id}") + + async def _remove_entity(self, external_id): + """Remove entity.""" + async_dispatcher_send(self._hass, f"gdacs_delete_{external_id}") + + async def _status_update(self, status_info): + """Propagate status update.""" + _LOGGER.debug("Status update received: %s", status_info) + self._status_info = status_info + async_dispatcher_send(self._hass, f"gdacs_status_{self._config_entry_id}") diff --git a/homeassistant/components/gdacs/config_flow.py b/homeassistant/components/gdacs/config_flow.py new file mode 100644 index 0000000000000..1e12a116ed5a6 --- /dev/null +++ b/homeassistant/components/gdacs/config_flow.py @@ -0,0 +1,66 @@ +"""Config flow to configure the GDACS integration.""" +import logging + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import ( + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_RADIUS, + CONF_SCAN_INTERVAL, +) +from homeassistant.helpers import config_validation as cv + +from .const import ( # pylint: disable=unused-import + CONF_CATEGORIES, + DEFAULT_RADIUS, + DEFAULT_SCAN_INTERVAL, + DOMAIN, +) + +DATA_SCHEMA = vol.Schema( + {vol.Optional(CONF_RADIUS, default=DEFAULT_RADIUS): cv.positive_int} +) + +_LOGGER = logging.getLogger(__name__) + + +class GdacsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a GDACS config flow.""" + + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + async def _show_form(self, errors=None): + """Show the form to the user.""" + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors or {} + ) + + async def async_step_import(self, import_config): + """Import a config entry from configuration.yaml.""" + return await self.async_step_user(import_config) + + async def async_step_user(self, user_input=None): + """Handle the start of the config flow.""" + _LOGGER.debug("User input: %s", user_input) + if not user_input: + return await self._show_form() + + latitude = user_input.get(CONF_LATITUDE, self.hass.config.latitude) + user_input[CONF_LATITUDE] = latitude + longitude = user_input.get(CONF_LONGITUDE, self.hass.config.longitude) + user_input[CONF_LONGITUDE] = longitude + + identifier = f"{user_input[CONF_LATITUDE]}, {user_input[CONF_LONGITUDE]}" + + await self.async_set_unique_id(identifier) + self._abort_if_unique_id_configured() + + scan_interval = user_input.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + user_input[CONF_SCAN_INTERVAL] = scan_interval.seconds + + categories = user_input.get(CONF_CATEGORIES, []) + user_input[CONF_CATEGORIES] = categories + + return self.async_create_entry(title=identifier, data=user_input) diff --git a/homeassistant/components/gdacs/const.py b/homeassistant/components/gdacs/const.py new file mode 100644 index 0000000000000..5d5c83f013e32 --- /dev/null +++ b/homeassistant/components/gdacs/const.py @@ -0,0 +1,19 @@ +"""Define constants for the GDACS integration.""" +from datetime import timedelta + +from aio_georss_gdacs.consts import EVENT_TYPE_MAP + +DOMAIN = "gdacs" + +PLATFORMS = ("sensor", "geo_location") + +FEED = "feed" + +CONF_CATEGORIES = "categories" + +DEFAULT_ICON = "mdi:alert" +DEFAULT_RADIUS = 500.0 +DEFAULT_SCAN_INTERVAL = timedelta(minutes=5) + +# Fetch valid categories from integration library. +VALID_CATEGORIES = list(EVENT_TYPE_MAP.values()) diff --git a/homeassistant/components/gdacs/geo_location.py b/homeassistant/components/gdacs/geo_location.py new file mode 100644 index 0000000000000..c45d6e56425ad --- /dev/null +++ b/homeassistant/components/gdacs/geo_location.py @@ -0,0 +1,235 @@ +"""Geolocation support for GDACS Feed.""" +import logging +from typing import Optional + +from homeassistant.components.geo_location import GeolocationEvent +from homeassistant.const import ( + ATTR_ATTRIBUTION, + CONF_UNIT_SYSTEM_IMPERIAL, + LENGTH_KILOMETERS, + LENGTH_MILES, +) +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_registry import async_get_registry +from homeassistant.util.unit_system import IMPERIAL_SYSTEM + +from .const import DEFAULT_ICON, DOMAIN, FEED + +_LOGGER = logging.getLogger(__name__) + +ATTR_ALERT_LEVEL = "alert_level" +ATTR_COUNTRY = "country" +ATTR_DESCRIPTION = "description" +ATTR_DURATION_IN_WEEK = "duration_in_week" +ATTR_EVENT_TYPE = "event_type" +ATTR_EXTERNAL_ID = "external_id" +ATTR_FROM_DATE = "from_date" +ATTR_POPULATION = "population" +ATTR_SEVERITY = "severity" +ATTR_TO_DATE = "to_date" +ATTR_VULNERABILITY = "vulnerability" + +ICONS = { + "DR": "mdi:water-off", + "EQ": "mdi:pulse", + "FL": "mdi:home-flood", + "TC": "mdi:weather-hurricane", + "TS": "mdi:waves", + "VO": "mdi:image-filter-hdr", +} + +# An update of this entity is not making a web request, but uses internal data only. +PARALLEL_UPDATES = 0 + +SOURCE = "gdacs" + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up the GDACS Feed platform.""" + manager = hass.data[DOMAIN][FEED][entry.entry_id] + + @callback + def async_add_geolocation(feed_manager, integration_id, external_id): + """Add gelocation entity from feed.""" + new_entity = GdacsEvent(feed_manager, integration_id, external_id) + _LOGGER.debug("Adding geolocation %s", new_entity) + async_add_entities([new_entity], True) + + manager.listeners.append( + async_dispatcher_connect( + hass, manager.async_event_new_entity(), async_add_geolocation + ) + ) + # Do not wait for update here so that the setup can be completed and because an + # update will fetch data from the feed via HTTP and then process that data. + hass.async_create_task(manager.async_update()) + _LOGGER.debug("Geolocation setup done") + + +class GdacsEvent(GeolocationEvent): + """This represents an external event with GDACS feed data.""" + + def __init__(self, feed_manager, integration_id, external_id): + """Initialize entity with data from feed entry.""" + self._feed_manager = feed_manager + self._integration_id = integration_id + self._external_id = external_id + self._title = None + self._distance = None + self._latitude = None + self._longitude = None + self._attribution = None + self._alert_level = None + self._country = None + self._description = None + self._duration_in_week = None + self._event_type_short = None + self._event_type = None + self._from_date = None + self._to_date = None + self._population = None + self._severity = None + self._vulnerability = None + self._version = 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, f"gdacs_delete_{self._external_id}", self._delete_callback + ) + self._remove_signal_update = async_dispatcher_connect( + self.hass, f"gdacs_update_{self._external_id}", self._update_callback + ) + + async def async_will_remove_from_hass(self) -> None: + """Call when entity will be removed from hass.""" + self._remove_signal_delete() + self._remove_signal_update() + # Remove from entity registry. + entity_registry = await async_get_registry(self.hass) + if self.entity_id in entity_registry.entities: + entity_registry.async_remove(self.entity_id) + + @callback + def _delete_callback(self): + """Remove this entity.""" + 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 GDACS feed location events.""" + return False + + async def async_update(self): + """Update this entity from the data held in the feed manager.""" + _LOGGER.debug("Updating %s", self._external_id) + feed_entry = self._feed_manager.get_entry(self._external_id) + if feed_entry: + self._update_from_feed(feed_entry) + + def _update_from_feed(self, feed_entry): + """Update the internal state from the provided feed entry.""" + event_name = feed_entry.event_name + if not event_name: + # Earthquakes usually don't have an event name. + event_name = f"{feed_entry.country} ({feed_entry.event_id})" + self._title = f"{feed_entry.event_type}: {event_name}" + # Convert distance if not metric system. + if self.hass.config.units.name == CONF_UNIT_SYSTEM_IMPERIAL: + self._distance = IMPERIAL_SYSTEM.length( + feed_entry.distance_to_home, LENGTH_KILOMETERS + ) + else: + self._distance = feed_entry.distance_to_home + self._latitude = feed_entry.coordinates[0] + self._longitude = feed_entry.coordinates[1] + self._attribution = feed_entry.attribution + self._alert_level = feed_entry.alert_level + self._country = feed_entry.country + self._description = feed_entry.title + self._duration_in_week = feed_entry.duration_in_week + self._event_type_short = feed_entry.event_type_short + self._event_type = feed_entry.event_type + self._from_date = feed_entry.from_date + self._to_date = feed_entry.to_date + self._population = feed_entry.population + self._severity = feed_entry.severity + self._vulnerability = feed_entry.vulnerability + # Round vulnerability value if presented as float. + if isinstance(self._vulnerability, float): + self._vulnerability = round(self._vulnerability, 1) + self._version = feed_entry.version + + @property + def unique_id(self) -> Optional[str]: + """Return a unique ID containing latitude/longitude and external id.""" + return f"{self._integration_id}_{self._external_id}" + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + if self._event_type_short and self._event_type_short in ICONS: + return ICONS[self._event_type_short] + return DEFAULT_ICON + + @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._title + + @property + def distance(self) -> Optional[float]: + """Return distance value of this external event.""" + return self._distance + + @property + def latitude(self) -> Optional[float]: + """Return latitude value of this external event.""" + return self._latitude + + @property + def longitude(self) -> Optional[float]: + """Return longitude value of this external event.""" + return self._longitude + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + if self.hass.config.units.name == CONF_UNIT_SYSTEM_IMPERIAL: + return LENGTH_MILES + return LENGTH_KILOMETERS + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + attributes = {} + for key, value in ( + (ATTR_EXTERNAL_ID, self._external_id), + (ATTR_DESCRIPTION, self._description), + (ATTR_ATTRIBUTION, self._attribution), + (ATTR_EVENT_TYPE, self._event_type), + (ATTR_ALERT_LEVEL, self._alert_level), + (ATTR_COUNTRY, self._country), + (ATTR_DURATION_IN_WEEK, self._duration_in_week), + (ATTR_FROM_DATE, self._from_date), + (ATTR_TO_DATE, self._to_date), + (ATTR_POPULATION, self._population), + (ATTR_SEVERITY, self._severity), + (ATTR_VULNERABILITY, self._vulnerability), + ): + if value or isinstance(value, bool): + attributes[key] = value + return attributes diff --git a/homeassistant/components/gdacs/manifest.json b/homeassistant/components/gdacs/manifest.json new file mode 100644 index 0000000000000..630ed0a4a06c0 --- /dev/null +++ b/homeassistant/components/gdacs/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "gdacs", + "name": "Global Disaster Alert and Coordination System (GDACS)", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/gdacs", + "requirements": ["aio_georss_gdacs==0.3"], + "codeowners": ["@exxamalte"], + "quality_scale": "platinum" +} diff --git a/homeassistant/components/gdacs/sensor.py b/homeassistant/components/gdacs/sensor.py new file mode 100644 index 0000000000000..fbbb199499b32 --- /dev/null +++ b/homeassistant/components/gdacs/sensor.py @@ -0,0 +1,146 @@ +"""Feed Entity Manager Sensor support for GDACS Feed.""" +import logging +from typing import Optional + +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity +from homeassistant.util import dt + +from .const import DEFAULT_ICON, DOMAIN, FEED + +_LOGGER = logging.getLogger(__name__) + +ATTR_STATUS = "status" +ATTR_LAST_UPDATE = "last_update" +ATTR_LAST_UPDATE_SUCCESSFUL = "last_update_successful" +ATTR_LAST_TIMESTAMP = "last_timestamp" +ATTR_CREATED = "created" +ATTR_UPDATED = "updated" +ATTR_REMOVED = "removed" + +DEFAULT_UNIT_OF_MEASUREMENT = "alerts" + +# An update of this entity is not making a web request, but uses internal data only. +PARALLEL_UPDATES = 0 + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up the GDACS Feed platform.""" + manager = hass.data[DOMAIN][FEED][entry.entry_id] + sensor = GdacsSensor(entry.entry_id, entry.unique_id, entry.title, manager) + async_add_entities([sensor]) + _LOGGER.debug("Sensor setup done") + + +class GdacsSensor(Entity): + """This is a status sensor for the GDACS integration.""" + + def __init__(self, config_entry_id, config_unique_id, config_title, manager): + """Initialize entity.""" + self._config_entry_id = config_entry_id + self._config_unique_id = config_unique_id + self._config_title = config_title + self._manager = manager + self._status = None + self._last_update = None + self._last_update_successful = None + self._last_timestamp = None + self._total = None + self._created = None + self._updated = None + self._removed = None + self._remove_signal_status = None + + async def async_added_to_hass(self): + """Call when entity is added to hass.""" + self._remove_signal_status = async_dispatcher_connect( + self.hass, + f"gdacs_status_{self._config_entry_id}", + self._update_status_callback, + ) + _LOGGER.debug("Waiting for updates %s", self._config_entry_id) + # First update is manual because of how the feed entity manager is updated. + await self.async_update() + + async def async_will_remove_from_hass(self) -> None: + """Call when entity will be removed from hass.""" + if self._remove_signal_status: + self._remove_signal_status() + + @callback + def _update_status_callback(self): + """Call status update method.""" + _LOGGER.debug("Received status update for %s", self._config_entry_id) + self.async_schedule_update_ha_state(True) + + @property + def should_poll(self): + """No polling needed for GDACS status sensor.""" + return False + + async def async_update(self): + """Update this entity from the data held in the feed manager.""" + _LOGGER.debug("Updating %s", self._config_entry_id) + if self._manager: + status_info = self._manager.status_info() + if status_info: + self._update_from_status_info(status_info) + + def _update_from_status_info(self, status_info): + """Update the internal state from the provided information.""" + self._status = status_info.status + self._last_update = ( + dt.as_utc(status_info.last_update) if status_info.last_update else None + ) + if status_info.last_update_successful: + self._last_update_successful = dt.as_utc(status_info.last_update_successful) + else: + self._last_update_successful = None + self._last_timestamp = status_info.last_timestamp + self._total = status_info.total + self._created = status_info.created + self._updated = status_info.updated + self._removed = status_info.removed + + @property + def state(self): + """Return the state of the sensor.""" + return self._total + + @property + def unique_id(self) -> Optional[str]: + """Return a unique ID containing latitude/longitude.""" + return self._config_unique_id + + @property + def name(self) -> Optional[str]: + """Return the name of the entity.""" + return f"GDACS ({self._config_title})" + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + return DEFAULT_ICON + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return DEFAULT_UNIT_OF_MEASUREMENT + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + attributes = {} + for key, value in ( + (ATTR_STATUS, self._status), + (ATTR_LAST_UPDATE, self._last_update), + (ATTR_LAST_UPDATE_SUCCESSFUL, self._last_update_successful), + (ATTR_LAST_TIMESTAMP, self._last_timestamp), + (ATTR_CREATED, self._created), + (ATTR_UPDATED, self._updated), + (ATTR_REMOVED, self._removed), + ): + if value or isinstance(value, bool): + attributes[key] = value + return attributes diff --git a/homeassistant/components/gdacs/strings.json b/homeassistant/components/gdacs/strings.json new file mode 100644 index 0000000000000..496b996823a99 --- /dev/null +++ b/homeassistant/components/gdacs/strings.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "title": "Fill in your filter details.", + "data": { "radius": "Radius" } + } + }, + "abort": { "already_configured": "Location is already configured." } + } +} diff --git a/homeassistant/components/gdacs/translations/ca.json b/homeassistant/components/gdacs/translations/ca.json new file mode 100644 index 0000000000000..db6359c881b71 --- /dev/null +++ b/homeassistant/components/gdacs/translations/ca.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "already_configured": "La ubicaci\u00f3 ja est\u00e0 configurada." + }, + "step": { + "user": { + "data": { + "radius": "Radi" + }, + "title": "Introducci\u00f3 dels detalls del filtre." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/gdacs/translations/da.json b/homeassistant/components/gdacs/translations/da.json new file mode 100644 index 0000000000000..cc59dfa78bbf9 --- /dev/null +++ b/homeassistant/components/gdacs/translations/da.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "already_configured": "Lokaliteten er allerede konfigureret." + }, + "step": { + "user": { + "data": { + "radius": "Radius" + }, + "title": "Udfyld dine filteroplysninger." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/gdacs/translations/de.json b/homeassistant/components/gdacs/translations/de.json new file mode 100644 index 0000000000000..07d1a4bdb79bf --- /dev/null +++ b/homeassistant/components/gdacs/translations/de.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "already_configured": "Der Standort ist bereits konfiguriert." + }, + "step": { + "user": { + "data": { + "radius": "Radius" + }, + "title": "F\u00fclle deine Filterangaben aus." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/gdacs/translations/en.json b/homeassistant/components/gdacs/translations/en.json new file mode 100644 index 0000000000000..8b4d3522ce204 --- /dev/null +++ b/homeassistant/components/gdacs/translations/en.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "already_configured": "Location is already configured." + }, + "step": { + "user": { + "data": { + "radius": "Radius" + }, + "title": "Fill in your filter details." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/gdacs/translations/es.json b/homeassistant/components/gdacs/translations/es.json new file mode 100644 index 0000000000000..816b44ceef00c --- /dev/null +++ b/homeassistant/components/gdacs/translations/es.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "already_configured": "La ubicaci\u00f3n ya est\u00e1 configurada." + }, + "step": { + "user": { + "data": { + "radius": "Radio" + }, + "title": "Rellena los datos de tu filtro." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/gdacs/translations/fr.json b/homeassistant/components/gdacs/translations/fr.json new file mode 100644 index 0000000000000..df44a1d9fa5f2 --- /dev/null +++ b/homeassistant/components/gdacs/translations/fr.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "already_configured": "L'emplacement est d\u00e9j\u00e0 configur\u00e9." + }, + "step": { + "user": { + "data": { + "radius": "Rayon" + }, + "title": "Remplissez les d\u00e9tails de votre filtre." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/gdacs/translations/hu.json b/homeassistant/components/gdacs/translations/hu.json new file mode 100644 index 0000000000000..47eca9a7fac33 --- /dev/null +++ b/homeassistant/components/gdacs/translations/hu.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "already_configured": "A hely m\u00e1r konfigur\u00e1lva van." + }, + "step": { + "user": { + "data": { + "radius": "Sug\u00e1r" + }, + "title": "T\u00f6ltse ki a sz\u0171r\u0151 adatait." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/gdacs/translations/it.json b/homeassistant/components/gdacs/translations/it.json new file mode 100644 index 0000000000000..3fad0146c138e --- /dev/null +++ b/homeassistant/components/gdacs/translations/it.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "already_configured": "La posizione \u00e8 gi\u00e0 configurata." + }, + "step": { + "user": { + "data": { + "radius": "Raggio" + }, + "title": "Inserisci i tuoi dettagli del filtro." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/gdacs/translations/ko.json b/homeassistant/components/gdacs/translations/ko.json new file mode 100644 index 0000000000000..1aeaf2192889e --- /dev/null +++ b/homeassistant/components/gdacs/translations/ko.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "already_configured": "\uc704\uce58\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + }, + "step": { + "user": { + "data": { + "radius": "\ubc18\uacbd" + }, + "title": "\ud544\ud130 \uc138\ubd80 \uc0ac\ud56d\uc744 \uc785\ub825\ud574\uc8fc\uc138\uc694." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/gdacs/translations/lb.json b/homeassistant/components/gdacs/translations/lb.json new file mode 100644 index 0000000000000..5125ce8c58e8d --- /dev/null +++ b/homeassistant/components/gdacs/translations/lb.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "already_configured": "Standuert ass scho konfigu\u00e9iert." + }, + "step": { + "user": { + "data": { + "radius": "Radius" + }, + "title": "F\u00ebllt \u00e4r Filter D\u00e9tailer aus." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/gdacs/translations/nl.json b/homeassistant/components/gdacs/translations/nl.json new file mode 100644 index 0000000000000..f2a09892a6633 --- /dev/null +++ b/homeassistant/components/gdacs/translations/nl.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "already_configured": "Locatie is al geconfigureerd." + }, + "step": { + "user": { + "data": { + "radius": "Radius" + }, + "title": "Vul uw filtergegevens in." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/gdacs/translations/no.json b/homeassistant/components/gdacs/translations/no.json new file mode 100644 index 0000000000000..372a24c0b385b --- /dev/null +++ b/homeassistant/components/gdacs/translations/no.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "already_configured": "Plasseringen er allerede konfigurert." + }, + "step": { + "user": { + "data": { + "radius": "" + }, + "title": "Fyll ut filterdetaljene." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/gdacs/translations/pl.json b/homeassistant/components/gdacs/translations/pl.json new file mode 100644 index 0000000000000..406f8af58f448 --- /dev/null +++ b/homeassistant/components/gdacs/translations/pl.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "already_configured": "Lokalizacja jest ju\u017c skonfigurowana." + }, + "step": { + "user": { + "data": { + "radius": "Promie\u0144" + }, + "title": "Wprowad\u017a szczeg\u00f3\u0142owe dane filtra." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/gdacs/translations/pt.json b/homeassistant/components/gdacs/translations/pt.json new file mode 100644 index 0000000000000..98180e11248aa --- /dev/null +++ b/homeassistant/components/gdacs/translations/pt.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "radius": "Raio" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/gdacs/translations/ru.json b/homeassistant/components/gdacs/translations/ru.json new file mode 100644 index 0000000000000..b946694403784 --- /dev/null +++ b/homeassistant/components/gdacs/translations/ru.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "already_configured": "\u041c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u0443\u0436\u0435 \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u043d\u043e." + }, + "step": { + "user": { + "data": { + "radius": "\u0420\u0430\u0434\u0438\u0443\u0441" + }, + "title": "\u041c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/gdacs/translations/sl.json b/homeassistant/components/gdacs/translations/sl.json new file mode 100644 index 0000000000000..24c4e8a9b50ce --- /dev/null +++ b/homeassistant/components/gdacs/translations/sl.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "already_configured": "Lokacija je \u017ee nastavljena." + }, + "step": { + "user": { + "data": { + "radius": "Radij" + }, + "title": "Izpolnite podrobnosti filtra." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/gdacs/translations/sv.json b/homeassistant/components/gdacs/translations/sv.json new file mode 100644 index 0000000000000..6688b68eb573c --- /dev/null +++ b/homeassistant/components/gdacs/translations/sv.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "already_configured": "Plats \u00e4r redan konfigurerad." + }, + "step": { + "user": { + "data": { + "radius": "Radie" + }, + "title": "Fyll i filterinformation." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/gdacs/translations/zh-Hant.json b/homeassistant/components/gdacs/translations/zh-Hant.json new file mode 100644 index 0000000000000..3e643c732c885 --- /dev/null +++ b/homeassistant/components/gdacs/translations/zh-Hant.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "already_configured": "\u4f4d\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210\u3002" + }, + "step": { + "user": { + "data": { + "radius": "\u534a\u5f91" + }, + "title": "\u586b\u5beb\u904e\u6ffe\u5668\u8cc7\u8a0a\u3002" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/gearbest/manifest.json b/homeassistant/components/gearbest/manifest.json index 39ceca41d0802..4729fd6b6f304 100644 --- a/homeassistant/components/gearbest/manifest.json +++ b/homeassistant/components/gearbest/manifest.json @@ -1,12 +1,7 @@ { "domain": "gearbest", "name": "Gearbest", - "documentation": "https://www.home-assistant.io/components/gearbest", - "requirements": [ - "gearbest_parser==1.0.7" - ], - "dependencies": [], - "codeowners": [ - "@HerrHofrat" - ] + "documentation": "https://www.home-assistant.io/integrations/gearbest", + "requirements": ["gearbest_parser==1.0.7"], + "codeowners": ["@HerrHofrat"] } diff --git a/homeassistant/components/gearbest/sensor.py b/homeassistant/components/gearbest/sensor.py index ee0ee6d4e3bff..b9b2a35b89d83 100644 --- a/homeassistant/components/gearbest/sensor.py +++ b/homeassistant/components/gearbest/sensor.py @@ -1,45 +1,48 @@ """Parse prices of an item from gearbest.""" -import logging from datetime import timedelta +import logging +from gearbest_parser import CurrencyConverter, GearbestParser import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_CURRENCY, CONF_ID, CONF_NAME, CONF_URL 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) +from homeassistant.util import Throttle _LOGGER = logging.getLogger(__name__) -CONF_ITEMS = 'items' +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 +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) + 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, -}) +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 = [] @@ -58,9 +61,7 @@ def currency_update(event_time): """Update currency list.""" converter.update() - track_time_interval(hass, - currency_update, - MIN_TIME_BETWEEN_CURRENCY_UPDATES) + track_time_interval(hass, currency_update, MIN_TIME_BETWEEN_CURRENCY_UPDATES) add_entities(sensors, True) @@ -70,14 +71,13 @@ class GearbestSensor(Entity): 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)) + 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") @@ -109,10 +109,12 @@ def entity_picture(self): @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} + attrs = { + "name": self._item.name, + "description": self._item.description, + "currency": self._item.currency, + "url": self._item.url, + } return attrs @Throttle(MIN_TIME_BETWEEN_UPDATES) diff --git a/homeassistant/components/geizhals/manifest.json b/homeassistant/components/geizhals/manifest.json index d53bceaa1455c..17b4b5e9df045 100644 --- a/homeassistant/components/geizhals/manifest.json +++ b/homeassistant/components/geizhals/manifest.json @@ -1,10 +1,7 @@ { "domain": "geizhals", "name": "Geizhals", - "documentation": "https://www.home-assistant.io/components/geizhals", - "requirements": [ - "geizhals==0.0.9" - ], - "dependencies": [], + "documentation": "https://www.home-assistant.io/integrations/geizhals", + "requirements": ["geizhals==0.0.9"], "codeowners": [] } diff --git a/homeassistant/components/geizhals/sensor.py b/homeassistant/components/geizhals/sensor.py index 03c263f54ab1d..9d5605cc40454 100644 --- a/homeassistant/components/geizhals/sensor.py +++ b/homeassistant/components/geizhals/sensor.py @@ -1,36 +1,34 @@ """Parse prices of a device from geizhals.""" -import logging from datetime import timedelta +import logging +from geizhals import Device, Geizhals import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_NAME import homeassistant.helpers.config_validation as cv -from homeassistant.util import Throttle from homeassistant.helpers.entity import Entity -from homeassistant.const import CONF_NAME +from homeassistant.util import Throttle _LOGGER = logging.getLogger(__name__) -CONF_DESCRIPTION = 'description' -CONF_PRODUCT_ID = 'product_id' -CONF_LOCALE = 'locale' +CONF_DESCRIPTION = "description" +CONF_PRODUCT_ID = "product_id" +CONF_LOCALE = "locale" -ICON = 'mdi:coin' +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']), -}) +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): @@ -40,8 +38,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): product_id = config.get(CONF_PRODUCT_ID) domain = config.get(CONF_LOCALE) - add_entities([Geizwatch(name, description, product_id, domain)], - True) + add_entities([Geizwatch(name, description, product_id, domain)], True) class Geizwatch(Entity): @@ -49,7 +46,6 @@ class Geizwatch(Entity): def __init__(self, name, description, product_id, domain): """Initialize the sensor.""" - from geizhals import Device, Geizhals # internal self._name = name @@ -82,15 +78,17 @@ def state(self): 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]} + 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) diff --git a/homeassistant/components/generic/camera.py b/homeassistant/components/generic/camera.py index bfe42a5b080d5..768ef108969b8 100644 --- a/homeassistant/components/generic/camera.py +++ b/homeassistant/components/generic/camera.py @@ -8,43 +8,56 @@ from requests.auth import HTTPDigestAuth import voluptuous as vol +from homeassistant.components.camera import ( + DEFAULT_CONTENT_TYPE, + PLATFORM_SCHEMA, + SUPPORT_STREAM, + Camera, +) from homeassistant.const import ( - CONF_NAME, CONF_USERNAME, CONF_PASSWORD, CONF_AUTHENTICATION, - HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION, CONF_VERIFY_SSL) + CONF_AUTHENTICATION, + CONF_NAME, + CONF_PASSWORD, + CONF_USERNAME, + CONF_VERIFY_SSL, + HTTP_BASIC_AUTHENTICATION, + HTTP_DIGEST_AUTHENTICATION, +) 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 +from homeassistant.helpers.aiohttp_client import async_get_clientsession _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): +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): vol.Any( + cv.small_float, 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)]) @@ -93,16 +106,16 @@ def frame_interval(self): def camera_image(self): """Return bytes of camera image.""" - return run_coroutine_threadsafe( - self.async_camera_image(), self.hass.loop).result() + return asyncio.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) + _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: @@ -110,32 +123,37 @@ async def async_camera_image(self): # 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) + 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) + _LOGGER.error( + "Error getting new camera image from %s: %s", self._name, error + ) return self._last_image - self._last_image = await self.hass.async_add_job( - fetch) + 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, loop=self.hass.loop): - response = await websession.get( - url, auth=self._auth) + 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) + _LOGGER.error("Timeout getting camera image from %s", self._name) return self._last_image except aiohttp.ClientError as err: - _LOGGER.error("Error getting new camera image: %s", err) + _LOGGER.error( + "Error getting new camera image from %s: %s", self._name, err + ) return self._last_image self._last_url = url @@ -146,7 +164,6 @@ def name(self): """Return the name of this device.""" return self._name - @property - def stream_source(self): + 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 index e4d3622a56253..a066333679d0c 100644 --- a/homeassistant/components/generic/manifest.json +++ b/homeassistant/components/generic/manifest.json @@ -1,8 +1,6 @@ { "domain": "generic", "name": "Generic", - "documentation": "https://www.home-assistant.io/components/generic", - "requirements": [], - "dependencies": [], + "documentation": "https://www.home-assistant.io/integrations/generic", "codeowners": [] } diff --git a/homeassistant/components/generic_thermostat/climate.py b/homeassistant/components/generic_thermostat/climate.py index cfa8ba64ea5e7..d788951340286 100644 --- a/homeassistant/components/generic_thermostat/climate.py +++ b/homeassistant/components/generic_thermostat/climate.py @@ -4,69 +4,89 @@ import voluptuous as vol +from homeassistant.components.climate import PLATFORM_SCHEMA, ClimateEntity +from homeassistant.components.climate.const import ( + ATTR_PRESET_MODE, + CURRENT_HVAC_COOL, + CURRENT_HVAC_HEAT, + CURRENT_HVAC_IDLE, + CURRENT_HVAC_OFF, + HVAC_MODE_COOL, + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + PRESET_AWAY, + PRESET_NONE, + SUPPORT_PRESET_MODE, + SUPPORT_TARGET_TEMPERATURE, +) 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) + ATTR_ENTITY_ID, + ATTR_TEMPERATURE, + CONF_NAME, + EVENT_HOMEASSISTANT_START, + PRECISION_HALVES, + PRECISION_TENTHS, + PRECISION_WHOLE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_ON, + STATE_UNAVAILABLE, + 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) + 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): +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_HVAC_MODE = "initial_hvac_mode" +CONF_AWAY_TEMP = "away_temp" +CONF_PRECISION = "precision" +SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE + +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_HVAC_MODE): vol.In( + [HVAC_MODE_COOL, HVAC_MODE_HEAT, HVAC_MODE_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) @@ -79,77 +99,113 @@ async def async_setup_platform(hass, config, async_add_entities, 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) + initial_hvac_mode = config.get(CONF_INITIAL_HVAC_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): + unit = hass.config.units.temperature_unit + + async_add_entities( + [ + GenericThermostat( + 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_hvac_mode, + away_temp, + precision, + unit, + ) + ] + ) + + +class GenericThermostat(ClimateEntity, 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): + def __init__( + self, + 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_hvac_mode, + away_temp, + precision, + unit, + ): """Initialize the thermostat.""" - self.hass = hass self._name = name self.heater_entity_id = heater_entity_id + self.sensor_entity_id = sensor_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._hvac_mode = initial_hvac_mode + self._saved_target_temp = target_temp or 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 + self._hvac_list = [HVAC_MODE_COOL, HVAC_MODE_OFF] else: - self._enabled = True + self._hvac_list = [HVAC_MODE_HEAT, HVAC_MODE_OFF] 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._unit = unit self._support_flags = SUPPORT_FLAGS - if away_temp is not None: - self._support_flags = SUPPORT_FLAGS | SUPPORT_AWAY_MODE + if away_temp: + self._support_flags = SUPPORT_FLAGS | SUPPORT_PRESET_MODE self._away_temp = away_temp self._is_away = False + async def async_added_to_hass(self): + """Run when entity about to be added.""" + await super().async_added_to_hass() + + # Add listener async_track_state_change( - hass, sensor_entity_id, self._async_sensor_changed) + self.hass, self.sensor_entity_id, self._async_sensor_changed + ) async_track_state_change( - hass, heater_entity_id, self._async_switch_changed) + self.hass, self.heater_entity_id, self._async_switch_changed + ) if self._keep_alive: async_track_time_interval( - hass, self._async_control_heating, self._keep_alive) + self.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) + @callback + def _async_startup(event): + """Init on startup.""" + sensor_state = self.hass.states.get(self.sensor_entity_id) + if sensor_state and sensor_state.state not in ( + STATE_UNAVAILABLE, + STATE_UNKNOWN, + ): + self._async_update_temp(sensor_state) + + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _async_startup) - 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: @@ -161,19 +217,16 @@ async def async_added_to_hass(self): 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) + _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 + self._target_temp = float(old_state.attributes[ATTR_TEMPERATURE]) + if old_state.attributes.get(ATTR_PRESET_MODE) == PRESET_AWAY: + self._is_away = True + if not self._hvac_mode and old_state.state: + self._hvac_mode = old_state.state else: # No previous state, try and restore defaults @@ -182,17 +235,13 @@ async def async_added_to_hass(self): 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) + _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 + # Set default state to off + if not self._hvac_mode: + self._hvac_mode = HVAC_MODE_OFF @property def should_poll(self): @@ -222,9 +271,23 @@ def current_temperature(self): return self._cur_temp @property - def current_operation(self): + def hvac_mode(self): """Return current operation.""" - return self._current_operation + return self._hvac_mode + + @property + def hvac_action(self): + """Return the current running hvac operation if supported. + + Need to be one of CURRENT_HVAC_*. + """ + if self._hvac_mode == HVAC_MODE_OFF: + return CURRENT_HVAC_OFF + if not self._is_device_active: + return CURRENT_HVAC_IDLE + if self.ac_mode: + return CURRENT_HVAC_COOL + return CURRENT_HVAC_HEAT @property def target_temperature(self): @@ -232,38 +295,37 @@ def target_temperature(self): return self._target_temp @property - def operation_list(self): + def hvac_modes(self): """List of available operation modes.""" - return self._operation_list + return self._hvac_list + + @property + def preset_mode(self): + """Return the current preset mode, e.g., home, away, temp.""" + return PRESET_AWAY if self._is_away else PRESET_NONE - 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 + @property + def preset_modes(self): + """Return a list of available preset modes or PRESET_NONE if _away_temp is undefined.""" + return [PRESET_NONE, PRESET_AWAY] if self._away_temp else PRESET_NONE + + async def async_set_hvac_mode(self, hvac_mode): + """Set hvac mode.""" + if hvac_mode == HVAC_MODE_HEAT: + self._hvac_mode = HVAC_MODE_HEAT await self._async_control_heating(force=True) - elif operation_mode == STATE_COOL: - self._current_operation = STATE_COOL - self._enabled = True + elif hvac_mode == HVAC_MODE_COOL: + self._hvac_mode = HVAC_MODE_COOL await self._async_control_heating(force=True) - elif operation_mode == STATE_OFF: - self._current_operation = STATE_OFF - self._enabled = False + elif hvac_mode == HVAC_MODE_OFF: + self._hvac_mode = HVAC_MODE_OFF if self._is_device_active: await self._async_heater_turn_off() else: - _LOGGER.error("Unrecognized operation mode: %s", operation_mode) + _LOGGER.error("Unrecognized hvac mode: %s", hvac_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) + self.async_write_ha_state() async def async_set_temperature(self, **kwargs): """Set new target temperature.""" @@ -272,12 +334,12 @@ async def async_set_temperature(self, **kwargs): return self._target_temp = temperature await self._async_control_heating(force=True) - await self.async_update_ha_state() + self.async_write_ha_state() @property def min_temp(self): """Return the minimum temperature.""" - if self._min_temp: + if self._min_temp is not None: return self._min_temp # get default temp from super class @@ -286,7 +348,7 @@ def min_temp(self): @property def max_temp(self): """Return the maximum temperature.""" - if self._max_temp: + if self._max_temp is not None: return self._max_temp # Get default temp from super class @@ -294,19 +356,19 @@ def max_temp(self): async def _async_sensor_changed(self, entity_id, old_state, new_state): """Handle temperature changes.""" - if new_state is None: + if new_state is None or new_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): return self._async_update_temp(new_state) await self._async_control_heating() - await self.async_update_ha_state() + self.async_write_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() + self.async_write_ha_state() @callback def _async_update_temp(self, state): @@ -319,14 +381,16 @@ def _async_update_temp(self, state): 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): + 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: + _LOGGER.info( + "Obtained current and target temperature. " + "Generic thermostat active. %s, %s", + self._cur_temp, + self._target_temp, + ) + + if not self._active or self._hvac_mode == HVAC_MODE_OFF: return if not force and time is None: @@ -338,33 +402,38 @@ async def _async_control_heating(self, time=None, force=False): if self._is_device_active: current_state = STATE_ON else: - current_state = STATE_OFF + current_state = HVAC_MODE_OFF long_enough = condition.state( - self.hass, self.heater_entity_id, current_state, - self.min_cycle_duration) + 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 + 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) + 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 + _LOGGER.info( + "Keep-alive - Turning on heater heater %s", + self.heater_entity_id, + ) await self._async_heater_turn_on() else: - if (self.ac_mode and too_hot) or \ - (not self.ac_mode and too_cold): + 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 + _LOGGER.info( + "Keep-alive - Turning off heater %s", self.heater_entity_id + ) await self._async_heater_turn_off() @property @@ -387,26 +456,16 @@ async def _async_heater_turn_off(self): 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_set_preset_mode(self, preset_mode: str): + """Set new preset mode.""" + if preset_mode == PRESET_AWAY and not self._is_away: + self._is_away = True + self._saved_target_temp = self._target_temp + self._target_temp = self._away_temp + await self._async_control_heating(force=True) + elif preset_mode == PRESET_NONE and self._is_away: + self._is_away = False + self._target_temp = self._saved_target_temp + await self._async_control_heating(force=True) - 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() + self.async_write_ha_state() diff --git a/homeassistant/components/generic_thermostat/manifest.json b/homeassistant/components/generic_thermostat/manifest.json index 41fb04c84566b..011c3f595927c 100644 --- a/homeassistant/components/generic_thermostat/manifest.json +++ b/homeassistant/components/generic_thermostat/manifest.json @@ -1,11 +1,7 @@ { "domain": "generic_thermostat", - "name": "Generic thermostat", - "documentation": "https://www.home-assistant.io/components/generic_thermostat", - "requirements": [], - "dependencies": [ - "sensor", - "switch" - ], + "name": "Generic Thermostat", + "documentation": "https://www.home-assistant.io/integrations/generic_thermostat", + "dependencies": ["sensor", "switch"], "codeowners": [] } diff --git a/homeassistant/components/geniushub/__init__.py b/homeassistant/components/geniushub/__init__.py index 181e61a7e48d3..0b99224bf7f22 100644 --- a/homeassistant/components/geniushub/__init__.py +++ b/homeassistant/components/geniushub/__init__.py @@ -1,63 +1,358 @@ -"""This module connects to a Genius hub and shares the data.""" +"""Support for a Genius Hub system.""" +from datetime import timedelta import logging +from typing import Any, Dict, Optional +import aiohttp +from geniushubclient import GeniusHub import voluptuous as vol from homeassistant.const import ( - CONF_HOST, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME) + ATTR_ENTITY_ID, + ATTR_TEMPERATURE, + CONF_HOST, + CONF_MAC, + CONF_PASSWORD, + CONF_TOKEN, + CONF_USERNAME, + TEMP_CELSIUS, +) +from homeassistant.core import callback 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_connect, + async_dispatcher_send, +) +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.service import verify_domain_control +from homeassistant.helpers.typing import ConfigType, HomeAssistantType +import homeassistant.util.dt as dt_util _LOGGER = logging.getLogger(__name__) -DOMAIN = 'geniushub' - -_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) +DOMAIN = "geniushub" +# temperature is repeated here, as it gives access to high-precision temps +GH_ZONE_ATTRS = ["mode", "temperature", "type", "occupied", "override"] +GH_DEVICE_ATTRS = { + "luminance": "luminance", + "measuredTemperature": "measured_temperature", + "occupancyTrigger": "occupancy_trigger", + "setback": "setback", + "setTemperature": "set_temperature", + "wakeupInterval": "wakeup_interval", +} -async def async_setup(hass, hass_config): - """Create a Genius Hub system.""" - from geniushubclient import GeniusHubClient # noqa; pylint: disable=no-name-in-module +SCAN_INTERVAL = timedelta(seconds=60) + +MAC_ADDRESS_REGEXP = r"^([0-9A-F]{2}:){5}([0-9A-F]{2})$" + +V1_API_SCHEMA = vol.Schema( + { + vol.Required(CONF_TOKEN): cv.string, + vol.Required(CONF_MAC): vol.Match(MAC_ADDRESS_REGEXP), + } +) +V3_API_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_MAC): vol.Match(MAC_ADDRESS_REGEXP), + } +) +CONFIG_SCHEMA = vol.Schema( + {DOMAIN: vol.Any(V3_API_SCHEMA, V1_API_SCHEMA)}, extra=vol.ALLOW_EXTRA +) + +ATTR_ZONE_MODE = "mode" +ATTR_DURATION = "duration" - geniushub_data = hass.data[DOMAIN] = {} +SVC_SET_ZONE_MODE = "set_zone_mode" +SVC_SET_ZONE_OVERRIDE = "set_zone_override" - kwargs = dict(hass_config[DOMAIN]) +SET_ZONE_MODE_SCHEMA = vol.Schema( + { + vol.Required(ATTR_ENTITY_ID): cv.entity_id, + vol.Required(ATTR_ZONE_MODE): vol.In(["off", "timer", "footprint"]), + } +) +SET_ZONE_OVERRIDE_SCHEMA = vol.Schema( + { + vol.Required(ATTR_ENTITY_ID): cv.entity_id, + vol.Required(ATTR_TEMPERATURE): vol.All( + vol.Coerce(float), vol.Range(min=4, max=28) + ), + vol.Optional(ATTR_DURATION): vol.All( + cv.time_period, vol.Range(min=timedelta(minutes=5), max=timedelta(days=1)), + ), + } +) + + +async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: + """Create a Genius Hub system.""" + hass.data[DOMAIN] = {} + + kwargs = dict(config[DOMAIN]) if CONF_HOST in kwargs: - args = (kwargs.pop(CONF_HOST), ) + args = (kwargs.pop(CONF_HOST),) else: - args = (kwargs.pop(CONF_TOKEN), ) + args = (kwargs.pop(CONF_TOKEN),) + hub_uid = kwargs.pop(CONF_MAC, None) + + client = GeniusHub(*args, **kwargs, session=async_get_clientsession(hass)) + + broker = hass.data[DOMAIN]["broker"] = GeniusBroker(hass, client, hub_uid) try: - client = geniushub_data['client'] = GeniusHubClient( - *args, **kwargs, session=async_get_clientsession(hass) + await client.update() + except aiohttp.ClientResponseError as err: + _LOGGER.error("Setup failed, check your configuration, %s", err) + return False + broker.make_debug_log_entries() + + async_track_time_interval(hass, broker.async_update, SCAN_INTERVAL) + + for platform in ["climate", "water_heater", "sensor", "binary_sensor", "switch"]: + hass.async_create_task(async_load_platform(hass, platform, DOMAIN, {}, config)) + + setup_service_functions(hass, broker) + + return True + + +@callback +def setup_service_functions(hass: HomeAssistantType, broker): + """Set up the service functions.""" + + @verify_domain_control(hass, DOMAIN) + async def set_zone_mode(call) -> None: + """Set the system mode.""" + entity_id = call.data[ATTR_ENTITY_ID] + + registry = await hass.helpers.entity_registry.async_get_registry() + registry_entry = registry.async_get(entity_id) + + if registry_entry is None or registry_entry.platform != DOMAIN: + raise ValueError(f"'{entity_id}' is not a known {DOMAIN} entity") + + if registry_entry.domain != "climate": + raise ValueError(f"'{entity_id}' is not an {DOMAIN} zone") + + payload = { + "unique_id": registry_entry.unique_id, + "service": call.service, + "data": call.data, + } + + async_dispatcher_send(hass, DOMAIN, payload) + + hass.services.async_register( + DOMAIN, SVC_SET_ZONE_MODE, set_zone_mode, schema=SET_ZONE_MODE_SCHEMA + ) + hass.services.async_register( + DOMAIN, SVC_SET_ZONE_OVERRIDE, set_zone_mode, schema=SET_ZONE_OVERRIDE_SCHEMA + ) + + +class GeniusBroker: + """Container for geniushub client and data.""" + + def __init__(self, hass, client, hub_uid) -> None: + """Initialize the geniushub client.""" + self.hass = hass + self.client = client + self._hub_uid = hub_uid + + @property + def hub_uid(self) -> int: + """Return the Hub UID (MAC address).""" + # pylint: disable=no-member + return self._hub_uid if self._hub_uid is not None else self.client.uid + + async def async_update(self, now, **kwargs) -> None: + """Update the geniushub client's data.""" + try: + await self.client.update() + except aiohttp.ClientResponseError as err: + _LOGGER.warning("Update failed, message is: %s", err) + return + self.make_debug_log_entries() + + async_dispatcher_send(self.hass, DOMAIN) + + def make_debug_log_entries(self) -> None: + """Make any useful debug log entries.""" + # pylint: disable=protected-access + _LOGGER.debug( + "Raw JSON: \n\nclient._zones = %s \n\nclient._devices = %s", + self.client._zones, + self.client._devices, ) - await client.hub.update() - except AssertionError: # assert response.status == HTTP_OK - _LOGGER.warning( - "setup(): Failed, check your configuration.", - exc_info=True) +class GeniusEntity(Entity): + """Base for all Genius Hub entities.""" + + def __init__(self) -> None: + """Initialize the entity.""" + self._unique_id = self._name = None + + async def async_added_to_hass(self) -> None: + """Set up a listener when this entity is added to HA.""" + self.async_on_remove(async_dispatcher_connect(self.hass, DOMAIN, self._refresh)) + + async def _refresh(self, payload: Optional[dict] = None) -> None: + """Process any signals.""" + self.async_schedule_update_ha_state(force_refresh=True) + + @property + def unique_id(self) -> Optional[str]: + """Return a unique ID.""" + return self._unique_id + + @property + def name(self) -> str: + """Return the name of the geniushub entity.""" + return self._name + + @property + def should_poll(self) -> bool: + """Return False as geniushub entities should not be polled.""" return False - hass.async_create_task(async_load_platform( - hass, 'climate', DOMAIN, {}, hass_config)) - hass.async_create_task(async_load_platform( - hass, 'water_heater', DOMAIN, {}, hass_config)) +class GeniusDevice(GeniusEntity): + """Base for all Genius Hub devices.""" - return True + def __init__(self, broker, device) -> None: + """Initialize the Device.""" + super().__init__() + + self._device = device + self._unique_id = f"{broker.hub_uid}_device_{device.id}" + self._last_comms = self._state_attr = None + + @property + def device_state_attributes(self) -> Dict[str, Any]: + """Return the device state attributes.""" + + attrs = {} + attrs["assigned_zone"] = self._device.data["assignedZones"][0]["name"] + if self._last_comms: + attrs["last_comms"] = self._last_comms.isoformat() + + state = dict(self._device.data["state"]) + if "_state" in self._device.data: # only via v3 API + state.update(self._device.data["_state"]) + + attrs["state"] = { + GH_DEVICE_ATTRS[k]: v for k, v in state.items() if k in GH_DEVICE_ATTRS + } + + return attrs + + async def async_update(self) -> None: + """Update an entity's state data.""" + if "_state" in self._device.data: # only via v3 API + self._last_comms = dt_util.utc_from_timestamp( + self._device.data["_state"]["lastComms"] + ) + + +class GeniusZone(GeniusEntity): + """Base for all Genius Hub zones.""" + + def __init__(self, broker, zone) -> None: + """Initialize the Zone.""" + super().__init__() + + self._zone = zone + self._unique_id = f"{broker.hub_uid}_zone_{zone.id}" + + async def _refresh(self, payload: Optional[dict] = None) -> None: + """Process any signals.""" + if payload is None: + self.async_schedule_update_ha_state(force_refresh=True) + return + + if payload["unique_id"] != self._unique_id: + return + + if payload["service"] == SVC_SET_ZONE_OVERRIDE: + temperature = round(payload["data"][ATTR_TEMPERATURE] * 10) / 10 + duration = payload["data"].get(ATTR_DURATION, timedelta(hours=1)) + + await self._zone.set_override(temperature, int(duration.total_seconds())) + return + + mode = payload["data"][ATTR_ZONE_MODE] + + # pylint: disable=protected-access + if mode == "footprint" and not self._zone._has_pir: + raise TypeError( + f"'{self.entity_id}' can not support footprint mode (it has no PIR)" + ) + + await self._zone.set_mode(mode) + + @property + def name(self) -> str: + """Return the name of the climate device.""" + return self._zone.name + + @property + def device_state_attributes(self) -> Dict[str, Any]: + """Return the device state attributes.""" + status = {k: v for k, v in self._zone.data.items() if k in GH_ZONE_ATTRS} + return {"status": status} + + +class GeniusHeatingZone(GeniusZone): + """Base for Genius Heating Zones.""" + + def __init__(self, broker, zone) -> None: + """Initialize the Zone.""" + super().__init__(broker, zone) + + self._max_temp = self._min_temp = self._supported_features = None + + @property + def current_temperature(self) -> Optional[float]: + """Return the current temperature.""" + return self._zone.data.get("temperature") + + @property + def target_temperature(self) -> float: + """Return the temperature we try to reach.""" + return self._zone.data["setpoint"] + + @property + def min_temp(self) -> float: + """Return max valid temperature that can be set.""" + return self._min_temp + + @property + def max_temp(self) -> float: + """Return max valid temperature that can be set.""" + return self._max_temp + + @property + def temperature_unit(self) -> str: + """Return the unit of measurement.""" + return TEMP_CELSIUS + + @property + def supported_features(self) -> int: + """Return the bitmask of supported features.""" + return self._supported_features + + async def async_set_temperature(self, **kwargs) -> None: + """Set a new target temperature for this zone.""" + await self._zone.set_override( + kwargs[ATTR_TEMPERATURE], kwargs.get(ATTR_DURATION, 3600) + ) diff --git a/homeassistant/components/geniushub/binary_sensor.py b/homeassistant/components/geniushub/binary_sensor.py new file mode 100644 index 0000000000000..d935192f97d4a --- /dev/null +++ b/homeassistant/components/geniushub/binary_sensor.py @@ -0,0 +1,45 @@ +"""Support for Genius Hub binary_sensor devices.""" +from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.helpers.typing import ConfigType, HomeAssistantType + +from . import DOMAIN, GeniusDevice + +GH_STATE_ATTR = "outputOnOff" + + +async def async_setup_platform( + hass: HomeAssistantType, config: ConfigType, async_add_entities, discovery_info=None +) -> None: + """Set up the Genius Hub sensor entities.""" + if discovery_info is None: + return + + broker = hass.data[DOMAIN]["broker"] + + switches = [ + GeniusBinarySensor(broker, d, GH_STATE_ATTR) + for d in broker.client.device_objs + if GH_STATE_ATTR in d.data["state"] + ] + + async_add_entities(switches, update_before_add=True) + + +class GeniusBinarySensor(GeniusDevice, BinarySensorEntity): + """Representation of a Genius Hub binary_sensor.""" + + def __init__(self, broker, device, state_attr) -> None: + """Initialize the binary sensor.""" + super().__init__(broker, device) + + self._state_attr = state_attr + + if device.type[:21] == "Dual Channel Receiver": + self._name = f"{device.type[:21]} {device.id}" + else: + self._name = f"{device.type} {device.id}" + + @property + def is_on(self) -> bool: + """Return the status of the sensor.""" + return self._device.data["state"][self._state_attr] diff --git a/homeassistant/components/geniushub/climate.py b/homeassistant/components/geniushub/climate.py index b396f8d6dacdb..70d08dc2d1fa6 100644 --- a/homeassistant/components/geniushub/climate.py +++ b/homeassistant/components/geniushub/climate.py @@ -1,156 +1,103 @@ """Support for Genius Hub climate devices.""" -import asyncio -import logging +from typing import List, Optional -from homeassistant.components.climate import ClimateDevice +from homeassistant.components.climate import ClimateEntity 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, TEMP_CELSIUS) - -from . import DOMAIN - -_LOGGER = logging.getLogger(__name__) - -GH_CLIMATE_DEVICES = ['radiator'] - -GENIUSHUB_SUPPORT_FLAGS = \ - SUPPORT_TARGET_TEMPERATURE | \ - SUPPORT_ON_OFF | \ - SUPPORT_OPERATION_MODE - -GENIUSHUB_MAX_TEMP = 28.0 -GENIUSHUB_MIN_TEMP = 4.0 - -# Genius Hub Zones support only Off, Override/Boost, Footprint & Timer modes -HA_OPMODE_TO_GH = { - STATE_AUTO: 'timer', - STATE_ECO: 'footprint', - STATE_MANUAL: 'override', -} -GH_OPMODE_OFF = 'off' -GH_STATE_TO_HA = { - 'timer': STATE_AUTO, - 'footprint': STATE_ECO, - 'away': None, - 'override': STATE_MANUAL, - 'early': STATE_HEAT, - 'test': None, - 'linked': None, - 'other': None, -} # intentionally missing 'off': None - -# temperature is repeated here, as it gives access to high-precision temps -GH_DEVICE_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'] + CURRENT_HVAC_HEAT, + CURRENT_HVAC_IDLE, + CURRENT_HVAC_OFF, + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + PRESET_ACTIVITY, + PRESET_BOOST, + SUPPORT_PRESET_MODE, + SUPPORT_TARGET_TEMPERATURE, +) +from homeassistant.helpers.typing import ConfigType, HomeAssistantType - entities = [GeniusClimate(client, z) - for z in client.hub.zone_objs if z.type in GH_CLIMATE_DEVICES] +from . import DOMAIN, GeniusHeatingZone - async_add_entities(entities) +# GeniusHub Zones support: Off, Timer, Override/Boost, Footprint & Linked modes +HA_HVAC_TO_GH = {HVAC_MODE_OFF: "off", HVAC_MODE_HEAT: "timer"} +GH_HVAC_TO_HA = {v: k for k, v in HA_HVAC_TO_GH.items()} +HA_PRESET_TO_GH = {PRESET_ACTIVITY: "footprint", PRESET_BOOST: "override"} +GH_PRESET_TO_HA = {v: k for k, v in HA_PRESET_TO_GH.items()} -class GeniusClimate(ClimateDevice): - """Representation of a Genius Hub climate device.""" +GH_ZONES = ["radiator", "wet underfloor"] - def __init__(self, client, zone): - """Initialize the climate device.""" - self._client = client - self._objref = zone - self._id = zone.id - self._name = zone.name - # Only some zones have movement detectors, which allows footprint mode - op_list = list(HA_OPMODE_TO_GH) - if not hasattr(self._objref, 'occupied'): - op_list.remove(STATE_ECO) - self._operation_list = op_list +async def async_setup_platform( + hass: HomeAssistantType, config: ConfigType, async_add_entities, discovery_info=None +) -> None: + """Set up the Genius Hub climate entities.""" + if discovery_info is None: + return - @property - def name(self): - """Return the name of the climate device.""" - return self._objref.name + broker = hass.data[DOMAIN]["broker"] - @property - def device_state_attributes(self): - """Return the device state attributes.""" - tmp = self._objref.__dict__.items() - state = {k: v for k, v in tmp if k in GH_DEVICE_STATE_ATTRS} + async_add_entities( + [ + GeniusClimateZone(broker, z) + for z in broker.client.zone_objs + if z.data["type"] in GH_ZONES + ] + ) - return {'status': state} - @property - def current_temperature(self): - """Return the current temperature.""" - return self._objref.temperature +class GeniusClimateZone(GeniusHeatingZone, ClimateEntity): + """Representation of a Genius Hub climate device.""" - @property - def target_temperature(self): - """Return the temperature we try to reach.""" - return self._objref.setpoint + def __init__(self, broker, zone) -> None: + """Initialize the climate device.""" + super().__init__(broker, zone) - @property - def min_temp(self): - """Return max valid temperature that can be set.""" - return GENIUSHUB_MIN_TEMP + self._max_temp = 28.0 + self._min_temp = 4.0 + self._supported_features = SUPPORT_TARGET_TEMPERATURE | SUPPORT_PRESET_MODE @property - def max_temp(self): - """Return max valid temperature that can be set.""" - return GENIUSHUB_MAX_TEMP + def icon(self) -> str: + """Return the icon to use in the frontend UI.""" + return "mdi:radiator" @property - def temperature_unit(self): - """Return the unit of measurement.""" - return TEMP_CELSIUS + def hvac_mode(self) -> str: + """Return hvac operation ie. heat, cool mode.""" + return GH_HVAC_TO_HA.get(self._zone.data["mode"], HVAC_MODE_HEAT) @property - def supported_features(self): - """Return the list of supported features.""" - return GENIUSHUB_SUPPORT_FLAGS + def hvac_modes(self) -> List[str]: + """Return the list of available hvac operation modes.""" + return list(HA_HVAC_TO_GH) @property - def operation_list(self): - """Return the list of available operation modes.""" - return self._operation_list + def hvac_action(self) -> Optional[str]: + """Return the current running hvac operation if supported.""" + if "_state" in self._zone.data: # only for v3 API + if not self._zone.data["_state"].get("bIsActive"): + return CURRENT_HVAC_OFF + if self._zone.data["_state"].get("bOutRequestHeat"): + return CURRENT_HVAC_HEAT + return CURRENT_HVAC_IDLE + return None @property - def current_operation(self): - """Return the current operation mode.""" - return GH_STATE_TO_HA.get(self._objref.mode) + def preset_mode(self) -> Optional[str]: + """Return the current preset mode, e.g., home, away, temp.""" + return GH_PRESET_TO_HA.get(self._zone.data["mode"]) @property - def is_on(self): - """Return True if the device is on.""" - return self._objref.mode in GH_STATE_TO_HA - - async def async_set_operation_mode(self, operation_mode): - """Set a new operation mode for this zone.""" - await self._objref.set_mode(HA_OPMODE_TO_GH.get(operation_mode)) - - async def async_set_temperature(self, **kwargs): - """Set a new target temperature for this zone.""" - temperature = kwargs.get(ATTR_TEMPERATURE) - await self._objref.set_override(temperature, 3600) # 1 hour - - async def async_turn_on(self): - """Turn on this heating zone.""" - await self._objref.set_mode(HA_OPMODE_TO_GH.get(STATE_AUTO)) - - async def async_turn_off(self): - """Turn off this heating zone (i.e. to frost protect).""" - await self._objref.set_mode(GH_OPMODE_OFF) - - async def async_update(self): - """Get the latest data from the hub.""" - try: - await self._objref.update() - except (AssertionError, asyncio.TimeoutError) as err: - _LOGGER.warning("Update for %s failed, message: %s", - self._id, err) + def preset_modes(self) -> Optional[List[str]]: + """Return a list of available preset modes.""" + if "occupied" in self._zone.data: # if has a movement sensor + return [PRESET_ACTIVITY, PRESET_BOOST] + return [PRESET_BOOST] + + async def async_set_hvac_mode(self, hvac_mode: str) -> None: + """Set a new hvac mode.""" + await self._zone.set_mode(HA_HVAC_TO_GH.get(hvac_mode)) + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set a new preset mode.""" + await self._zone.set_mode(HA_PRESET_TO_GH.get(preset_mode, "timer")) diff --git a/homeassistant/components/geniushub/manifest.json b/homeassistant/components/geniushub/manifest.json index 99449211a7ddd..b4a72d8831598 100644 --- a/homeassistant/components/geniushub/manifest.json +++ b/homeassistant/components/geniushub/manifest.json @@ -1,10 +1,7 @@ { "domain": "geniushub", "name": "Genius Hub", - "documentation": "https://www.home-assistant.io/components/geniushub", - "requirements": [ - "geniushub-client==0.4.6" - ], - "dependencies": [], + "documentation": "https://www.home-assistant.io/integrations/geniushub", + "requirements": ["geniushub-client==0.6.30"], "codeowners": ["@zxdavb"] } diff --git a/homeassistant/components/geniushub/sensor.py b/homeassistant/components/geniushub/sensor.py new file mode 100644 index 0000000000000..196cba7212e5d --- /dev/null +++ b/homeassistant/components/geniushub/sensor.py @@ -0,0 +1,117 @@ +"""Support for Genius Hub sensor devices.""" +from datetime import timedelta +from typing import Any, Dict + +from homeassistant.const import DEVICE_CLASS_BATTERY, UNIT_PERCENTAGE +from homeassistant.helpers.typing import ConfigType, HomeAssistantType +import homeassistant.util.dt as dt_util + +from . import DOMAIN, GeniusDevice, GeniusEntity + +GH_STATE_ATTR = "batteryLevel" + +GH_LEVEL_MAPPING = { + "error": "Errors", + "warning": "Warnings", + "information": "Information", +} + + +async def async_setup_platform( + hass: HomeAssistantType, config: ConfigType, async_add_entities, discovery_info=None +) -> None: + """Set up the Genius Hub sensor entities.""" + if discovery_info is None: + return + + broker = hass.data[DOMAIN]["broker"] + + sensors = [ + GeniusBattery(broker, d, GH_STATE_ATTR) + for d in broker.client.device_objs + if GH_STATE_ATTR in d.data["state"] + ] + issues = [GeniusIssue(broker, i) for i in list(GH_LEVEL_MAPPING)] + + async_add_entities(sensors + issues, update_before_add=True) + + +class GeniusBattery(GeniusDevice): + """Representation of a Genius Hub sensor.""" + + def __init__(self, broker, device, state_attr) -> None: + """Initialize the sensor.""" + super().__init__(broker, device) + + self._state_attr = state_attr + + self._name = f"{device.type} {device.id}" + + @property + def icon(self) -> str: + """Return the icon of the sensor.""" + if "_state" in self._device.data: # only for v3 API + interval = timedelta( + seconds=self._device.data["_state"].get("wakeupInterval", 30 * 60) + ) + if self._last_comms < dt_util.utcnow() - interval * 3: + return "mdi:battery-unknown" + + battery_level = self._device.data["state"][self._state_attr] + if battery_level == 255: + return "mdi:battery-unknown" + if battery_level < 40: + return "mdi:battery-alert" + + icon = "mdi:battery" + if battery_level <= 95: + icon += f"-{int(round(battery_level / 10 - 0.01)) * 10}" + + return icon + + @property + def device_class(self) -> str: + """Return the device class of the sensor.""" + return DEVICE_CLASS_BATTERY + + @property + def unit_of_measurement(self) -> str: + """Return the unit of measurement of the sensor.""" + return UNIT_PERCENTAGE + + @property + def state(self) -> str: + """Return the state of the sensor.""" + level = self._device.data["state"][self._state_attr] + return level if level != 255 else 0 + + +class GeniusIssue(GeniusEntity): + """Representation of a Genius Hub sensor.""" + + def __init__(self, broker, level) -> None: + """Initialize the sensor.""" + super().__init__() + + self._hub = broker.client + self._unique_id = f"{broker.hub_uid}_{GH_LEVEL_MAPPING[level]}" + + self._name = f"GeniusHub {GH_LEVEL_MAPPING[level]}" + self._level = level + self._issues = [] + + @property + def state(self) -> str: + """Return the number of issues.""" + return len(self._issues) + + @property + def device_state_attributes(self) -> Dict[str, Any]: + """Return the device state attributes.""" + return {f"{self._level}_list": self._issues} + + async def async_update(self) -> None: + """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/services.yaml b/homeassistant/components/geniushub/services.yaml new file mode 100644 index 0000000000000..50cd8d7d01e75 --- /dev/null +++ b/homeassistant/components/geniushub/services.yaml @@ -0,0 +1,28 @@ +# Support for a Genius Hub system +# Describes the format for available services + +set_zone_mode: + description: >- + Set the zone to an operating mode. + fields: + entity_id: + description: The zone's entity_id. + example: climate.kitchen + mode: + description: "One of: off, timer or footprint." + example: timer + +set_zone_override: + description: >- + Override the zone's setpoint for a given duration. + fields: + entity_id: + description: The zone's entity_id. + example: climate.bathroom + temperature: + description: The target temperature, to 0.1 C. + example: 19.2 + duration: + description: >- + The duration of the override. Optional, default 1 hour, maximum 24 hours. + example: '{"minutes": 135}' diff --git a/homeassistant/components/geniushub/switch.py b/homeassistant/components/geniushub/switch.py new file mode 100644 index 0000000000000..e73468321bd40 --- /dev/null +++ b/homeassistant/components/geniushub/switch.py @@ -0,0 +1,55 @@ +"""Support for Genius Hub switch/outlet devices.""" +from homeassistant.components.switch import DEVICE_CLASS_OUTLET, SwitchEntity +from homeassistant.helpers.typing import ConfigType, HomeAssistantType + +from . import DOMAIN, GeniusZone + +ATTR_DURATION = "duration" + +GH_ON_OFF_ZONE = "on / off" + + +async def async_setup_platform( + hass: HomeAssistantType, config: ConfigType, async_add_entities, discovery_info=None +) -> None: + """Set up the Genius Hub switch entities.""" + if discovery_info is None: + return + + broker = hass.data[DOMAIN]["broker"] + + async_add_entities( + [ + GeniusSwitch(broker, z) + for z in broker.client.zone_objs + if z.data["type"] == GH_ON_OFF_ZONE + ] + ) + + +class GeniusSwitch(GeniusZone, SwitchEntity): + """Representation of a Genius Hub switch.""" + + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + return DEVICE_CLASS_OUTLET + + @property + def is_on(self) -> bool: + """Return the current state of the on/off zone. + + The zone is considered 'on' if & only if it is override/on (e.g. timer/on is 'off'). + """ + return self._zone.data["mode"] == "override" and self._zone.data["setpoint"] + + async def async_turn_off(self, **kwargs) -> None: + """Send the zone to Timer mode. + + The zone is deemed 'off' in this mode, although the plugs may actually be on. + """ + await self._zone.set_mode("timer") + + async def async_turn_on(self, **kwargs) -> None: + """Set the zone to override/on ({'setpoint': true}) for x seconds.""" + await self._zone.set_override(1, kwargs.get(ATTR_DURATION, 3600)) diff --git a/homeassistant/components/geniushub/water_heater.py b/homeassistant/components/geniushub/water_heater.py index f5f09f9b1d5af..51fdce4a6d7c5 100644 --- a/homeassistant/components/geniushub/water_heater.py +++ b/homeassistant/components/geniushub/water_heater.py @@ -1,141 +1,75 @@ """Support for Genius Hub water_heater devices.""" -import asyncio -import logging +from typing import List from homeassistant.components.water_heater import ( - WaterHeaterDevice, - SUPPORT_TARGET_TEMPERATURE, SUPPORT_OPERATION_MODE) -from homeassistant.const import ( - ATTR_TEMPERATURE, STATE_OFF, TEMP_CELSIUS) + SUPPORT_OPERATION_MODE, + SUPPORT_TARGET_TEMPERATURE, + WaterHeaterEntity, +) +from homeassistant.const import STATE_OFF +from homeassistant.helpers.typing import ConfigType, HomeAssistantType -from . import DOMAIN +from . import DOMAIN, GeniusHeatingZone -STATE_AUTO = 'auto' -STATE_MANUAL = 'manual' +STATE_AUTO = "auto" +STATE_MANUAL = "manual" -_LOGGER = logging.getLogger(__name__) - -GH_WATER_HEATERS = ['hot water temperature'] - -GENIUSHUB_SUPPORT_FLAGS = \ - SUPPORT_TARGET_TEMPERATURE | \ - SUPPORT_OPERATION_MODE -# HA does not have SUPPORT_ON_OFF for water_heater - -GENIUSHUB_MAX_TEMP = 80.0 -GENIUSHUB_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_OPMODE_OFF = 'off' +# Genius Hub HW zones support 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, + "off": STATE_OFF, + "timer": STATE_AUTO, + "footprint": None, + "away": None, + "override": STATE_MANUAL, + "early": None, + "test": None, + "linked": None, + "other": None, } -GH_DEVICE_STATE_ATTRS = ['type', 'override'] +GH_HEATERS = ["hot water temperature"] -async def async_setup_platform(hass, hass_config, async_add_entities, - discovery_info=None): +async def async_setup_platform( + hass: HomeAssistantType, config: ConfigType, async_add_entities, discovery_info=None +) -> None: """Set up the Genius Hub water_heater entities.""" - client = hass.data[DOMAIN]['client'] + if discovery_info is None: + return - entities = [GeniusWaterHeater(client, z) - for z in client.hub.zone_objs if z.type in GH_WATER_HEATERS] + broker = hass.data[DOMAIN]["broker"] - async_add_entities(entities) + async_add_entities( + [ + GeniusWaterHeater(broker, z) + for z in broker.client.zone_objs + if z.data["type"] in GH_HEATERS + ] + ) -class GeniusWaterHeater(WaterHeaterDevice): +class GeniusWaterHeater(GeniusHeatingZone, WaterHeaterEntity): """Representation of a Genius Hub water_heater device.""" - def __init__(self, client, boiler): + def __init__(self, broker, zone) -> None: """Initialize the water_heater device.""" - self._client = client - self._boiler = boiler - self._id = boiler.id - self._name = boiler.name - - self._operation_list = list(HA_OPMODE_TO_GH) - - @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() - state = {k: v for k, v in tmp if k in GH_DEVICE_STATE_ATTRS} - - return {'status': state} + super().__init__(broker, zone) - @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 GENIUSHUB_MIN_TEMP - - @property - def max_temp(self): - """Return max valid temperature that can be set.""" - return GENIUSHUB_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 GENIUSHUB_SUPPORT_FLAGS + self._max_temp = 80.0 + self._min_temp = 30.0 + self._supported_features = SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE @property - def operation_list(self): + def operation_list(self) -> List[str]: """Return the list of available operation modes.""" - return self._operation_list + return list(HA_OPMODE_TO_GH) @property - def current_operation(self): + def current_operation(self) -> str: """Return the current operation mode.""" - return GH_STATE_TO_HA.get(self._boiler.mode) + return GH_STATE_TO_HA[self._zone.data["mode"]] - async def async_set_operation_mode(self, operation_mode): + async def async_set_operation_mode(self, operation_mode) -> None: """Set a new operation mode for this boiler.""" - await self._boiler.set_mode(HA_OPMODE_TO_GH.get(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 - - async def async_update(self): - """Get the latest data from the hub.""" - try: - await self._boiler.update() - except (AssertionError, asyncio.TimeoutError) as err: - _LOGGER.warning("Update for %s failed, message: %s", - self._id, err) + await self._zone.set_mode(HA_OPMODE_TO_GH[operation_mode]) diff --git a/homeassistant/components/geo_json_events/geo_location.py b/homeassistant/components/geo_json_events/geo_location.py index f7d79ae714523..2fd8466cc198f 100644 --- a/homeassistant/components/geo_json_events/geo_location.py +++ b/homeassistant/components/geo_json_events/geo_location.py @@ -3,51 +3,57 @@ import logging from typing import Optional +from geojson_client.generic_feed import GenericFeedManager import voluptuous as vol -from homeassistant.components.geo_location import ( - PLATFORM_SCHEMA, GeolocationEvent) +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) + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_RADIUS, + CONF_SCAN_INTERVAL, + CONF_URL, + EVENT_HOMEASSISTANT_START, + LENGTH_KILOMETERS, +) 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.dispatcher import async_dispatcher_connect, dispatcher_send from homeassistant.helpers.event import track_time_interval _LOGGER = logging.getLogger(__name__) -ATTR_EXTERNAL_ID = 'external_id' +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" -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), -}) +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)) + 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) + hass, add_entities, scan_interval, coordinates, url, radius_in_km + ) def start_feed_manager(event): """Start feed manager.""" @@ -59,15 +65,20 @@ def start_feed_manager(event): class GeoJsonFeedEntityManager: """Feed Entity Manager for GeoJSON feeds.""" - def __init__(self, hass, add_entities, scan_interval, coordinates, url, - radius_in_km): + 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._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 @@ -79,8 +90,8 @@ def startup(self): 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) + self._hass, lambda now: self._feed_manager.update(), self._scan_interval + ) def get_entry(self, external_id): """Get feed entry by external id.""" @@ -94,11 +105,11 @@ def _generate_entity(self, external_id): def _update_entity(self, external_id): """Update entity.""" - dispatcher_send(self._hass, SIGNAL_UPDATE_ENTITY.format(external_id)) + dispatcher_send(self._hass, f"geo_json_events_update_{external_id}") def _remove_entity(self, external_id): """Remove entity.""" - dispatcher_send(self._hass, SIGNAL_DELETE_ENTITY.format(external_id)) + dispatcher_send(self._hass, f"geo_json_events_delete_{external_id}") class GeoJsonLocationEvent(GeolocationEvent): @@ -118,11 +129,15 @@ def __init__(self, feed_manager, external_id): 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.hass, + f"geo_json_events_delete_{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) + self.hass, + f"geo_json_events_update_{self._external_id}", + self._update_callback, + ) @callback def _delete_callback(self): @@ -183,7 +198,7 @@ def longitude(self) -> Optional[float]: @property def unit_of_measurement(self): """Return the unit of measurement.""" - return DEFAULT_UNIT_OF_MEASUREMENT + return LENGTH_KILOMETERS @property def device_state_attributes(self): diff --git a/homeassistant/components/geo_json_events/manifest.json b/homeassistant/components/geo_json_events/manifest.json index 8e4d7b8a7cdb5..4cf99155b37ed 100644 --- a/homeassistant/components/geo_json_events/manifest.json +++ b/homeassistant/components/geo_json_events/manifest.json @@ -1,10 +1,7 @@ { "domain": "geo_json_events", - "name": "Geo json events", - "documentation": "https://www.home-assistant.io/components/geo_json_events", - "requirements": [ - "geojson_client==0.3" - ], - "dependencies": [], + "name": "GeoJSON", + "documentation": "https://www.home-assistant.io/integrations/geo_json_events", + "requirements": ["geojson_client==0.4"], "codeowners": [] } diff --git a/homeassistant/components/geo_location/__init__.py b/homeassistant/components/geo_location/__init__.py index 75c99ecc74c87..6142fa222095d 100644 --- a/homeassistant/components/geo_location/__init__.py +++ b/homeassistant/components/geo_location/__init__.py @@ -4,30 +4,46 @@ 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.config_validation import ( # noqa: F401 + PLATFORM_SCHEMA, + PLATFORM_SCHEMA_BASE, +) from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent +# mypy: allow-untyped-defs, no-check-untyped-defs + _LOGGER = logging.getLogger(__name__) -ATTR_DISTANCE = 'distance' -ATTR_SOURCE = 'source' +ATTR_DISTANCE = "distance" +ATTR_SOURCE = "source" -DOMAIN = 'geo_location' +DOMAIN = "geo_location" -ENTITY_ID_FORMAT = DOMAIN + '.{}' +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) + component = hass.data[DOMAIN] = EntityComponent( + _LOGGER, DOMAIN, hass, SCAN_INTERVAL + ) await 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 GeolocationEvent(Entity): """This represents an external event with an associated geolocation.""" diff --git a/homeassistant/components/geo_location/manifest.json b/homeassistant/components/geo_location/manifest.json index 83b4241284e89..c5d3a6eba2e7b 100644 --- a/homeassistant/components/geo_location/manifest.json +++ b/homeassistant/components/geo_location/manifest.json @@ -1,8 +1,6 @@ { "domain": "geo_location", - "name": "Geo location", - "documentation": "https://www.home-assistant.io/components/geo_location", - "requirements": [], - "dependencies": [], + "name": "Geolocation", + "documentation": "https://www.home-assistant.io/integrations/geo_location", "codeowners": [] } diff --git a/homeassistant/components/geo_rss_events/manifest.json b/homeassistant/components/geo_rss_events/manifest.json index bce6758b0fe9e..77d38d58ad77c 100644 --- a/homeassistant/components/geo_rss_events/manifest.json +++ b/homeassistant/components/geo_rss_events/manifest.json @@ -1,10 +1,7 @@ { "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": [] + "name": "GeoRSS", + "documentation": "https://www.home-assistant.io/integrations/geo_rss_events", + "requirements": ["georss_generic_client==0.3"], + "codeowners": ["@exxamalte"] } diff --git a/homeassistant/components/geo_rss_events/sensor.py b/homeassistant/components/geo_rss_events/sensor.py index f900812385b00..5a11136fd4313 100644 --- a/homeassistant/components/geo_rss_events/sensor.py +++ b/homeassistant/components/geo_rss_events/sensor.py @@ -4,50 +4,57 @@ 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 logging +from georss_client import UPDATE_OK, UPDATE_OK_NO_DATA +from georss_client.generic_feed import GenericFeed 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) + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_NAME, + CONF_RADIUS, + CONF_UNIT_OF_MEASUREMENT, + CONF_URL, + LENGTH_KILOMETERS, +) +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) -ATTR_CATEGORY = 'category' -ATTR_DISTANCE = 'distance' -ATTR_TITLE = 'title' +ATTR_CATEGORY = "category" +ATTR_DISTANCE = "distance" +ATTR_TITLE = "title" -CONF_CATEGORIES = 'categories' +CONF_CATEGORIES = "categories" -DEFAULT_ICON = 'mdi:alert' +DEFAULT_ICON = "mdi:alert" DEFAULT_NAME = "Event Service" DEFAULT_RADIUS_IN_KM = 20.0 -DEFAULT_UNIT_OF_MEASUREMENT = 'Events' +DEFAULT_UNIT_OF_MEASUREMENT = "Events" -DOMAIN = 'geo_rss_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, -}) +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): @@ -60,21 +67,31 @@ def setup_platform(hass, config, add_entities, discovery_info=None): 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) + _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) + 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) + device = GeoRssServiceSensor( + (latitude, longitude), + url, + radius_in_km, + category, + name, + unit_of_measurement, + ) devices.append(device) add_entities(devices, True) @@ -82,25 +99,27 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class GeoRssServiceSensor(Entity): """Representation of a Sensor.""" - def __init__(self, coordinates, url, radius, category, service_name, - unit_of_measurement): + 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]) + + 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) + return f"{self._service_name} {'Any' if self._category is None else self._category}" @property def state(self): @@ -124,25 +143,25 @@ def device_state_attributes(self): 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) + if status == 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) + matrix[entry.title] = f"{entry.distance_to_home:.0f}{LENGTH_KILOMETERS}" self._state_attributes = matrix - elif status == georss_client.UPDATE_OK_NO_DATA: - _LOGGER.debug("Update successful, but no data received from %s", - self._feed) + elif status == 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) + _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 diff --git a/homeassistant/components/geofency/.translations/bg.json b/homeassistant/components/geofency/.translations/bg.json deleted file mode 100644 index 6f06d5c00c628..0000000000000 --- a/homeassistant/components/geofency/.translations/bg.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "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 deleted file mode 100644 index 125ca51399a2d..0000000000000 --- a/homeassistant/components/geofency/.translations/ca.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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 deleted file mode 100644 index 2fa1dfc9f4b33..0000000000000 --- a/homeassistant/components/geofency/.translations/cs.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "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 deleted file mode 100644 index 1390dfb504a61..0000000000000 --- a/homeassistant/components/geofency/.translations/da.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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 deleted file mode 100644 index ad4722fa9fc70..0000000000000 --- a/homeassistant/components/geofency/.translations/de.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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 deleted file mode 100644 index 27b6335c6f9b4..0000000000000 --- a/homeassistant/components/geofency/.translations/en.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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 deleted file mode 100644 index 637a430a1f8a4..0000000000000 --- a/homeassistant/components/geofency/.translations/es-419.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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 deleted file mode 100644 index 04d5c01e03e91..0000000000000 --- a/homeassistant/components/geofency/.translations/es.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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 deleted file mode 100644 index b390f2dab44bd..0000000000000 --- a/homeassistant/components/geofency/.translations/fr.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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 deleted file mode 100644 index 85f71d74434cd..0000000000000 --- a/homeassistant/components/geofency/.translations/hu.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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 deleted file mode 100644 index 1adad3825a302..0000000000000 --- a/homeassistant/components/geofency/.translations/it.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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 deleted file mode 100644 index db60ec18fe195..0000000000000 --- a/homeassistant/components/geofency/.translations/ko.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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 deleted file mode 100644 index 490026b366dd3..0000000000000 --- a/homeassistant/components/geofency/.translations/lb.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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 deleted file mode 100644 index 04aec33b5d686..0000000000000 --- a/homeassistant/components/geofency/.translations/nl.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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 deleted file mode 100644 index 4409616cef497..0000000000000 --- a/homeassistant/components/geofency/.translations/no.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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 deleted file mode 100644 index b2b8b606723fd..0000000000000 --- a/homeassistant/components/geofency/.translations/pl.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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 deleted file mode 100644 index bc68c3ec8223c..0000000000000 --- a/homeassistant/components/geofency/.translations/pt.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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 deleted file mode 100644 index 6c699d21ce67e..0000000000000 --- a/homeassistant/components/geofency/.translations/ru.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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 deleted file mode 100644 index e56d41d4f1aac..0000000000000 --- a/homeassistant/components/geofency/.translations/sl.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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 deleted file mode 100644 index 88c9709147fcc..0000000000000 --- a/homeassistant/components/geofency/.translations/sv.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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 deleted file mode 100644 index d18d8bc82807b..0000000000000 --- a/homeassistant/components/geofency/.translations/zh-Hans.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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 deleted file mode 100644 index bec33c26d100b..0000000000000 --- a/homeassistant/components/geofency/.translations/zh-Hant.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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 index 0b4b757ce9efb..cb663676512f4 100644 --- a/homeassistant/components/geofency/__init__.py +++ b/homeassistant/components/geofency/__init__.py @@ -6,63 +6,83 @@ 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) + 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__) -DOMAIN = 'geofency' -CONF_MOBILE_BEACONS = 'mobile_beacons' +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) +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' +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' +BEACON_DEV_PREFIX = "beacon" -LOCATION_ENTRY = '1' -LOCATION_EXIT = '0' +LOCATION_ENTRY = "1" +LOCATION_EXIT = "0" -TRACKER_UPDATE = '{}_tracker_update'.format(DOMAIN) +TRACKER_UPDATE = f"{DOMAIN}_tracker_update" def _address(value: str) -> str: r"""Coerce address by replacing '\n' with ' '.""" - return value.replace('\n', ' ') + 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) +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] = [slugify(beacon) for beacon in mobile_beacons] + hass.data[DOMAIN] = { + "beacons": [slugify(beacon) for beacon in mobile_beacons], + "devices": set(), + "unsub_device_tracker": {}, + } return True @@ -71,15 +91,12 @@ async def handle_webhook(hass, webhook_id, request): try: data = WEBHOOK_SCHEMA(dict(await request.post())) except vol.MultipleInvalid as error: - return web.Response( - body=error.error_message, - status=HTTP_UNPROCESSABLE_ENTITY - ) + return web.Response(text=error.error_message, status=HTTP_UNPROCESSABLE_ENTITY) - if _is_mobile_beacon(data, hass.data[DOMAIN]): + if _is_mobile_beacon(data, hass.data[DOMAIN]["beacons"]): return _set_location(hass, data, None) - if data['entry'] == LOCATION_ENTRY: - location_name = data['name'] + if data["entry"] == LOCATION_ENTRY: + location_name = data["name"] else: location_name = STATE_NOT_HOME if ATTR_CURRENT_LATITUDE in data: @@ -91,14 +108,14 @@ async def handle_webhook(hass, webhook_id, request): 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 + 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'] + return f"{BEACON_DEV_PREFIX}_{data['name']}" + return data["device"] def _set_location(hass, data, location_name): @@ -106,17 +123,22 @@ def _set_location(hass, data, location_name): device = _device_name(data) async_dispatcher_send( - hass, TRACKER_UPDATE, device, - (data[ATTR_LATITUDE], data[ATTR_LONGITUDE]), location_name, data) + 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) + return web.Response(text=f"Setting location for {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) + DOMAIN, "Geofency", entry.data[CONF_WEBHOOK_ID], handle_webhook + ) hass.async_create_task( hass.config_entries.async_forward_entry_setup(entry, DEVICE_TRACKER) @@ -127,19 +149,10 @@ async def async_setup_entry(hass, entry): 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 - - -config_entry_flow.register_webhook_flow( - DOMAIN, - 'Geofency Webhook', - { - 'docs_url': 'https://www.home-assistant.io/components/geofency/' - } -) diff --git a/homeassistant/components/geofency/config_flow.py b/homeassistant/components/geofency/config_flow.py new file mode 100644 index 0000000000000..2d8bce86d741d --- /dev/null +++ b/homeassistant/components/geofency/config_flow.py @@ -0,0 +1,10 @@ +"""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/integrations/geofency/"}, +) diff --git a/homeassistant/components/geofency/const.py b/homeassistant/components/geofency/const.py new file mode 100644 index 0000000000000..b0c54a4d4079d --- /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 index abccf610f5e44..e730f108f8f6c 100644 --- a/homeassistant/components/geofency/device_tracker.py +++ b/homeassistant/components/geofency/device_tracker.py @@ -1,35 +1,136 @@ """Support for the Geofency device tracker platform.""" import logging -from homeassistant.components.device_tracker import ( - DOMAIN as DEVICE_TRACKER_DOMAIN) +from homeassistant.components.device_tracker import SOURCE_TYPE_GPS +from homeassistant.components.device_tracker.config_entry import TrackerEntity +from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE +from homeassistant.core import callback +from homeassistant.helpers import device_registry from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.restore_state import RestoreEntity -from . import DOMAIN as GEOFENCY_DOMAIN, TRACKER_UPDATE +from . import DOMAIN as GF_DOMAIN, TRACKER_UPDATE _LOGGER = logging.getLogger(__name__) -DATA_KEY = '{}.{}'.format(GEOFENCY_DOMAIN, DEVICE_TRACKER_DOMAIN) +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Geofency config entry.""" -async def async_setup_entry(hass, entry, async_see): - """Configure a dispatcher connection based on a config entry.""" - async def _set_location(device, gps, location_name, attributes): + @callback + def _receive_data(device, gps, location_name, attributes): """Fire HA event to set location.""" - await async_see( - dev_id=device, - gps=gps, - location_name=location_name, - attributes=attributes - ) + if device in hass.data[GF_DOMAIN]["devices"]: + return - hass.data[DATA_KEY] = async_dispatcher_connect( - hass, TRACKER_UPDATE, _set_location - ) - return True + 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) -async def async_unload_entry(hass, entry): - """Unload the config entry and remove the dispatcher connection.""" - hass.data[DATA_KEY]() return True + + +class GeofencyEntity(TrackerEntity, 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 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 index 576d0e419a733..0fbc30444557f 100644 --- a/homeassistant/components/geofency/manifest.json +++ b/homeassistant/components/geofency/manifest.json @@ -1,10 +1,8 @@ { "domain": "geofency", "name": "Geofency", - "documentation": "https://www.home-assistant.io/components/geofency", - "requirements": [], - "dependencies": [ - "webhook" - ], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/geofency", + "dependencies": ["webhook"], "codeowners": [] } diff --git a/homeassistant/components/geofency/strings.json b/homeassistant/components/geofency/strings.json index e67af592c1680..1c6a72f27c838 100644 --- a/homeassistant/components/geofency/strings.json +++ b/homeassistant/components/geofency/strings.json @@ -1,6 +1,5 @@ { "config": { - "title": "Geofency Webhook", "step": { "user": { "title": "Set up the Geofency Webhook", @@ -15,4 +14,4 @@ "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/geofency/translations/bg.json b/homeassistant/components/geofency/translations/bg.json new file mode 100644 index 0000000000000..1bd9cf1a81887 --- /dev/null +++ b/homeassistant/components/geofency/translations/bg.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Home Assistant \u0442\u0440\u044f\u0431\u0432\u0430 \u0434\u0430 \u0435 \u0434\u043e\u0441\u0442\u044a\u043f\u0435\u043d \u043e\u0442 \u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442 \u0437\u0430 \u0434\u0430 \u043f\u043e\u043b\u0443\u0447\u0430\u0432\u0430 \u0441\u044a\u043e\u0431\u0449\u0435\u043d\u0438\u044f \u043e\u0442 Geofency.", + "one_instance_allowed": "\u041d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u0430 \u0435 \u0441\u0430\u043c\u043e \u0435\u0434\u043d\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f." + }, + "create_entry": { + "default": "\u0417\u0430 \u0434\u0430 \u0438\u0437\u043f\u0440\u0430\u0449\u0430\u0442\u0435 \u0441\u044a\u0431\u0438\u0442\u0438\u044f \u0434\u043e Home Assistant, \u0449\u0435 \u0442\u0440\u044f\u0431\u0432\u0430 \u0434\u0430 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u0435 \u0444\u0443\u043d\u043a\u0446\u0438\u044f\u0442\u0430 webhook \u0432 Geofency. \n\n \u041f\u043e\u043f\u044a\u043b\u043d\u0435\u0442\u0435 \u0441\u043b\u0435\u0434\u043d\u0430\u0442\u0430 \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044f: \n\n - URL: ` {webhook_url} ` \n - Method: POST \n\n \u0412\u0438\u0436\u0442\u0435 [\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u0446\u0438\u044f\u0442\u0430]({docs_url}) \u0437\u0430 \u043f\u043e\u0432\u0435\u0447\u0435 \u043f\u043e\u0434\u0440\u043e\u0431\u043d\u043e\u0441\u0442\u0438." + }, + "step": { + "user": { + "description": "\u0421\u0438\u0433\u0443\u0440\u043d\u0438 \u043b\u0438 \u0441\u0442\u0435, \u0447\u0435 \u0438\u0441\u043a\u0430\u0442\u0435 \u0434\u0430 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u0435 Geofency Webhook?", + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0432\u0430\u043d\u0435 \u043d\u0430 Geofency Webhook" + } + } + } +} \ 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..315b1d3be8d31 --- /dev/null +++ b/homeassistant/components/geofency/translations/ca.json @@ -0,0 +1,17 @@ +{ + "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 de Geofency?", + "title": "Configuraci\u00f3 del Webhook de 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..a91fa1cbb6f2e --- /dev/null +++ b/homeassistant/components/geofency/translations/cs.json @@ -0,0 +1,13 @@ +{ + "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" + } + } + } +} \ 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..176ba84b7681a --- /dev/null +++ b/homeassistant/components/geofency/translations/da.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Din Home Assistant-instans skal v\u00e6re tilg\u00e6ngelig 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 h\u00e6ndelser til Home Assistant skal du konfigurere webhook-funktionen i Geofency.\n\n Udfyld f\u00f8lgende oplysninger: \n\n - Webadresse: `{webhook_url}`\n - Metode: POST\n \nSe [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" + } + } + } +} \ 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..c585ee467aab4 --- /dev/null +++ b/homeassistant/components/geofency/translations/de.json @@ -0,0 +1,17 @@ +{ + "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": "Richte den Geofency Webhook ein" + } + } + } +} \ 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..dad5e9c77d492 --- /dev/null +++ b/homeassistant/components/geofency/translations/en.json @@ -0,0 +1,17 @@ +{ + "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" + } + } + } +} \ 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..bb8160ed5eff6 --- /dev/null +++ b/homeassistant/components/geofency/translations/es-419.json @@ -0,0 +1,17 @@ +{ + "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" + } + } + } +} \ 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..9149d6699beba --- /dev/null +++ b/homeassistant/components/geofency/translations/es.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Tu Home Assistant debe ser accesible desde Internet para recibir mensajes de GPSLogger.", + "one_instance_allowed": "S\u00f3lo 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" + } + } + } +} \ 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..142f40754b9dd --- /dev/null +++ b/homeassistant/components/geofency/translations/fr.json @@ -0,0 +1,17 @@ +{ + "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" + } + } + } +} \ 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..026912e0d3e34 --- /dev/null +++ b/homeassistant/components/geofency/translations/hu.json @@ -0,0 +1,17 @@ +{ + "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" + } + } + } +} \ 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..0640d351e53db --- /dev/null +++ b/homeassistant/components/geofency/translations/it.json @@ -0,0 +1,17 @@ +{ + "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" + } + } + } +} \ 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..d0d0fde2efd1a --- /dev/null +++ b/homeassistant/components/geofency/translations/ko.json @@ -0,0 +1,17 @@ +{ + "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 \uc6f9 \ud6c5\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 \uc6f9 \ud6c5\uc744 \uc124\uc815\ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?", + "title": "Geofency \uc6f9 \ud6c5 \uc124\uc815\ud558\uae30" + } + } + } +} \ 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..16f973e52603b --- /dev/null +++ b/homeassistant/components/geofency/translations/lb.json @@ -0,0 +1,17 @@ +{ + "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" + } + } + } +} \ 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..bedf5f7efb492 --- /dev/null +++ b/homeassistant/components/geofency/translations/nl.json @@ -0,0 +1,17 @@ +{ + "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" + } + } + } +} \ 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..ea9e1827b6373 --- /dev/null +++ b/homeassistant/components/geofency/translations/no.json @@ -0,0 +1,17 @@ +{ + "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 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" + } + } + } +} \ 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..9180d3483effd --- /dev/null +++ b/homeassistant/components/geofency/translations/pl.json @@ -0,0 +1,17 @@ +{ + "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": "Na pewno chcesz skonfigurowa\u0107 Geofency?", + "title": "Konfiguracja Geofency Webhook" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/geofency/translations/pt-BR.json b/homeassistant/components/geofency/translations/pt-BR.json new file mode 100644 index 0000000000000..1f715bf4a7a3d --- /dev/null +++ b/homeassistant/components/geofency/translations/pt-BR.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Sua inst\u00e2ncia do Home Assistant precisa estar acess\u00edvel na Internet para receber mensagens da Geofency.", + "one_instance_allowed": "Apenas uma \u00fanica inst\u00e2ncia \u00e9 necess\u00e1ria." + }, + "create_entry": { + "default": "Para enviar eventos para o Home Assistant, voc\u00ea precisar\u00e1 configurar o recurso webhook no Geofency. \n\n Preencha as seguintes informa\u00e7\u00f5es: \n\n - URL: ` {webhook_url} ` \n - M\u00e9todo: POST \n\n Veja [a documenta\u00e7\u00e3o] ( {docs_url} ) para mais detalhes." + }, + "step": { + "user": { + "description": "Tem certeza de que deseja configurar o Geofency Webhook?", + "title": "Configurar o 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..c5b080f0dcd56 --- /dev/null +++ b/homeassistant/components/geofency/translations/pt.json @@ -0,0 +1,17 @@ +{ + "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" + } + } + } +} \ 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..dd2a20ad61c1e --- /dev/null +++ b/homeassistant/components/geofency/translations/ru.json @@ -0,0 +1,17 @@ +{ + "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 Webhook \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 [\u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u044f\u043c\u0438]({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" + } + } + } +} \ 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..ba293c8c11a44 --- /dev/null +++ b/homeassistant/components/geofency/translations/sl.json @@ -0,0 +1,17 @@ +{ + "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" + } + } + } +} \ 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..f18a7bd25bbe8 --- /dev/null +++ b/homeassistant/components/geofency/translations/sv.json @@ -0,0 +1,17 @@ +{ + "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" + } + } + } +} \ 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..5be88f69e41cb --- /dev/null +++ b/homeassistant/components/geofency/translations/zh-Hans.json @@ -0,0 +1,17 @@ +{ + "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" + } + } + } +} \ 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..4bf9f6b715842 --- /dev/null +++ b/homeassistant/components/geofency/translations/zh-Hant.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Home Assistant \u7269\u4ef6\u5fc5\u9808\u80fd\u5920\u7531\u7db2\u969b\u7db2\u8def\u5b58\u53d6\uff0c\u65b9\u80fd\u63a5\u53d7 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" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_quakes/__init__.py b/homeassistant/components/geonetnz_quakes/__init__.py new file mode 100644 index 0000000000000..9395c9dbe66c2 --- /dev/null +++ b/homeassistant/components/geonetnz_quakes/__init__.py @@ -0,0 +1,222 @@ +"""The GeoNet NZ Quakes integration.""" +import asyncio +from datetime import timedelta +import logging + +from aio_geojson_geonetnz_quakes import GeonetnzQuakesFeedManager +import voluptuous as vol + +from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.const import ( + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_RADIUS, + CONF_SCAN_INTERVAL, + CONF_UNIT_SYSTEM_IMPERIAL, + LENGTH_MILES, +) +from homeassistant.core import callback +from homeassistant.helpers import aiohttp_client, config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.util.unit_system import METRIC_SYSTEM + +from .const import ( + CONF_MINIMUM_MAGNITUDE, + CONF_MMI, + DEFAULT_FILTER_TIME_INTERVAL, + DEFAULT_MINIMUM_MAGNITUDE, + DEFAULT_MMI, + DEFAULT_RADIUS, + DEFAULT_SCAN_INTERVAL, + DOMAIN, + FEED, + PLATFORMS, +) + +_LOGGER = logging.getLogger(__name__) + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Inclusive(CONF_LATITUDE, "coordinates"): cv.latitude, + vol.Inclusive(CONF_LONGITUDE, "coordinates"): cv.longitude, + vol.Optional(CONF_MMI, default=DEFAULT_MMI): vol.All( + vol.Coerce(int), vol.Range(min=-1, max=8) + ), + vol.Optional(CONF_RADIUS, default=DEFAULT_RADIUS): vol.Coerce(float), + vol.Optional( + CONF_MINIMUM_MAGNITUDE, default=DEFAULT_MINIMUM_MAGNITUDE + ): vol.All(vol.Coerce(float), vol.Range(min=0)), + vol.Optional( + CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL + ): cv.time_period, + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass, config): + """Set up the GeoNet NZ Quakes component.""" + if DOMAIN not in config: + return True + + conf = config[DOMAIN] + latitude = conf.get(CONF_LATITUDE, hass.config.latitude) + longitude = conf.get(CONF_LONGITUDE, hass.config.longitude) + mmi = conf[CONF_MMI] + scan_interval = conf[CONF_SCAN_INTERVAL] + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_LATITUDE: latitude, + CONF_LONGITUDE: longitude, + CONF_RADIUS: conf[CONF_RADIUS], + CONF_MINIMUM_MAGNITUDE: conf[CONF_MINIMUM_MAGNITUDE], + CONF_MMI: mmi, + CONF_SCAN_INTERVAL: scan_interval, + }, + ) + ) + + return True + + +async def async_setup_entry(hass, config_entry): + """Set up the GeoNet NZ Quakes component as config entry.""" + hass.data.setdefault(DOMAIN, {}) + feeds = hass.data[DOMAIN].setdefault(FEED, {}) + + radius = config_entry.data[CONF_RADIUS] + if hass.config.units.name == CONF_UNIT_SYSTEM_IMPERIAL: + radius = METRIC_SYSTEM.length(radius, LENGTH_MILES) + # Create feed entity manager for all platforms. + manager = GeonetnzQuakesFeedEntityManager(hass, config_entry, radius) + feeds[config_entry.entry_id] = manager + _LOGGER.debug("Feed entity manager added for %s", config_entry.entry_id) + await manager.async_init() + return True + + +async def async_unload_entry(hass, config_entry): + """Unload an GeoNet NZ Quakes component config entry.""" + manager = hass.data[DOMAIN][FEED].pop(config_entry.entry_id) + await manager.async_stop() + await asyncio.wait( + [ + hass.config_entries.async_forward_entry_unload(config_entry, domain) + for domain in PLATFORMS + ] + ) + return True + + +class GeonetnzQuakesFeedEntityManager: + """Feed Entity Manager for GeoNet NZ Quakes feed.""" + + def __init__(self, hass, config_entry, radius_in_km): + """Initialize the Feed Entity Manager.""" + self._hass = hass + self._config_entry = config_entry + coordinates = ( + config_entry.data[CONF_LATITUDE], + config_entry.data[CONF_LONGITUDE], + ) + websession = aiohttp_client.async_get_clientsession(hass) + self._feed_manager = GeonetnzQuakesFeedManager( + websession, + self._generate_entity, + self._update_entity, + self._remove_entity, + coordinates, + mmi=config_entry.data[CONF_MMI], + filter_radius=radius_in_km, + filter_minimum_magnitude=config_entry.data[CONF_MINIMUM_MAGNITUDE], + filter_time=DEFAULT_FILTER_TIME_INTERVAL, + status_callback=self._status_update, + ) + self._config_entry_id = config_entry.entry_id + self._scan_interval = timedelta(seconds=config_entry.data[CONF_SCAN_INTERVAL]) + self._track_time_remove_callback = None + self._status_info = None + self.listeners = [] + + async def async_init(self): + """Schedule initial and regular updates based on configured time interval.""" + + for domain in PLATFORMS: + self._hass.async_create_task( + self._hass.config_entries.async_forward_entry_setup( + self._config_entry, domain + ) + ) + + async def update(event_time): + """Update.""" + await self.async_update() + + # Trigger updates at regular intervals. + self._track_time_remove_callback = async_track_time_interval( + self._hass, update, self._scan_interval + ) + + _LOGGER.debug("Feed entity manager initialized") + + async def async_update(self): + """Refresh data.""" + await self._feed_manager.update() + _LOGGER.debug("Feed entity manager updated") + + async def async_stop(self): + """Stop this feed entity manager from refreshing.""" + for unsub_dispatcher in self.listeners: + unsub_dispatcher() + self.listeners = [] + if self._track_time_remove_callback: + self._track_time_remove_callback() + _LOGGER.debug("Feed entity manager stopped") + + @callback + def async_event_new_entity(self): + """Return manager specific event to signal new entity.""" + return f"geonetnz_quakes_new_geolocation_{self._config_entry_id}" + + def get_entry(self, external_id): + """Get feed entry by external id.""" + return self._feed_manager.feed_entries.get(external_id) + + def status_info(self): + """Return latest status update info received.""" + return self._status_info + + async def _generate_entity(self, external_id): + """Generate new entity.""" + async_dispatcher_send( + self._hass, + self.async_event_new_entity(), + self, + self._config_entry.unique_id, + external_id, + ) + + async def _update_entity(self, external_id): + """Update entity.""" + async_dispatcher_send(self._hass, f"geonetnz_quakes_update_{external_id}") + + async def _remove_entity(self, external_id): + """Remove entity.""" + async_dispatcher_send(self._hass, f"geonetnz_quakes_delete_{external_id}") + + async def _status_update(self, status_info): + """Propagate status update.""" + _LOGGER.debug("Status update received: %s", status_info) + self._status_info = status_info + async_dispatcher_send( + self._hass, f"geonetnz_quakes_status_{self._config_entry_id}" + ) diff --git a/homeassistant/components/geonetnz_quakes/config_flow.py b/homeassistant/components/geonetnz_quakes/config_flow.py new file mode 100644 index 0000000000000..f3f5829f465a4 --- /dev/null +++ b/homeassistant/components/geonetnz_quakes/config_flow.py @@ -0,0 +1,76 @@ +"""Config flow to configure the GeoNet NZ Quakes integration.""" +import logging + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import ( + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_RADIUS, + CONF_SCAN_INTERVAL, +) +from homeassistant.helpers import config_validation as cv + +from .const import ( # pylint: disable=unused-import + CONF_MINIMUM_MAGNITUDE, + CONF_MMI, + DEFAULT_MINIMUM_MAGNITUDE, + DEFAULT_MMI, + DEFAULT_RADIUS, + DEFAULT_SCAN_INTERVAL, + DOMAIN, +) + +DATA_SCHEMA = vol.Schema( + { + vol.Optional(CONF_MMI, default=DEFAULT_MMI): vol.All( + vol.Coerce(int), vol.Range(min=-1, max=8) + ), + vol.Optional(CONF_RADIUS, default=DEFAULT_RADIUS): cv.positive_int, + } +) + +_LOGGER = logging.getLogger(__name__) + + +class GeonetnzQuakesFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a GeoNet NZ Quakes config flow.""" + + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + async def _show_form(self, errors=None): + """Show the form to the user.""" + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors or {} + ) + + async def async_step_import(self, import_config): + """Import a config entry from configuration.yaml.""" + return await self.async_step_user(import_config) + + async def async_step_user(self, user_input=None): + """Handle the start of the config flow.""" + _LOGGER.debug("User input: %s", user_input) + if not user_input: + return await self._show_form() + + latitude = user_input.get(CONF_LATITUDE, self.hass.config.latitude) + user_input[CONF_LATITUDE] = latitude + longitude = user_input.get(CONF_LONGITUDE, self.hass.config.longitude) + user_input[CONF_LONGITUDE] = longitude + + identifier = f"{user_input[CONF_LATITUDE]}, {user_input[CONF_LONGITUDE]}" + + await self.async_set_unique_id(identifier) + self._abort_if_unique_id_configured() + + scan_interval = user_input.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + user_input[CONF_SCAN_INTERVAL] = scan_interval.seconds + + minimum_magnitude = user_input.get( + CONF_MINIMUM_MAGNITUDE, DEFAULT_MINIMUM_MAGNITUDE + ) + user_input[CONF_MINIMUM_MAGNITUDE] = minimum_magnitude + + return self.async_create_entry(title=identifier, data=user_input) diff --git a/homeassistant/components/geonetnz_quakes/const.py b/homeassistant/components/geonetnz_quakes/const.py new file mode 100644 index 0000000000000..43818b55f6f1d --- /dev/null +++ b/homeassistant/components/geonetnz_quakes/const.py @@ -0,0 +1,17 @@ +"""Define constants for the GeoNet NZ Quakes integration.""" +from datetime import timedelta + +DOMAIN = "geonetnz_quakes" + +PLATFORMS = ("sensor", "geo_location") + +CONF_MINIMUM_MAGNITUDE = "minimum_magnitude" +CONF_MMI = "mmi" + +FEED = "feed" + +DEFAULT_FILTER_TIME_INTERVAL = timedelta(days=7) +DEFAULT_MINIMUM_MAGNITUDE = 0.0 +DEFAULT_MMI = 3 +DEFAULT_RADIUS = 50.0 +DEFAULT_SCAN_INTERVAL = timedelta(minutes=5) diff --git a/homeassistant/components/geonetnz_quakes/geo_location.py b/homeassistant/components/geonetnz_quakes/geo_location.py new file mode 100644 index 0000000000000..ed0b9f9f71445 --- /dev/null +++ b/homeassistant/components/geonetnz_quakes/geo_location.py @@ -0,0 +1,202 @@ +"""Geolocation support for GeoNet NZ Quakes Feeds.""" +import logging +from typing import Optional + +from homeassistant.components.geo_location import GeolocationEvent +from homeassistant.const import ( + ATTR_ATTRIBUTION, + ATTR_TIME, + CONF_UNIT_SYSTEM_IMPERIAL, + LENGTH_KILOMETERS, + LENGTH_MILES, +) +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_registry import async_get_registry +from homeassistant.util.unit_system import IMPERIAL_SYSTEM + +from .const import DOMAIN, FEED + +_LOGGER = logging.getLogger(__name__) + +ATTR_DEPTH = "depth" +ATTR_EXTERNAL_ID = "external_id" +ATTR_LOCALITY = "locality" +ATTR_MAGNITUDE = "magnitude" +ATTR_MMI = "mmi" +ATTR_PUBLICATION_DATE = "publication_date" +ATTR_QUALITY = "quality" + +# An update of this entity is not making a web request, but uses internal data only. +PARALLEL_UPDATES = 0 + +SOURCE = "geonetnz_quakes" + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up the GeoNet NZ Quakes Feed platform.""" + manager = hass.data[DOMAIN][FEED][entry.entry_id] + + @callback + def async_add_geolocation(feed_manager, integration_id, external_id): + """Add gelocation entity from feed.""" + new_entity = GeonetnzQuakesEvent(feed_manager, integration_id, external_id) + _LOGGER.debug("Adding geolocation %s", new_entity) + async_add_entities([new_entity], True) + + manager.listeners.append( + async_dispatcher_connect( + hass, manager.async_event_new_entity(), async_add_geolocation + ) + ) + # Do not wait for update here so that the setup can be completed and because an + # update will fetch data from the feed via HTTP and then process that data. + hass.async_create_task(manager.async_update()) + _LOGGER.debug("Geolocation setup done") + + +class GeonetnzQuakesEvent(GeolocationEvent): + """This represents an external event with GeoNet NZ Quakes feed data.""" + + def __init__(self, feed_manager, integration_id, external_id): + """Initialize entity with data from feed entry.""" + self._feed_manager = feed_manager + self._integration_id = integration_id + self._external_id = external_id + self._title = None + self._distance = None + self._latitude = None + self._longitude = None + self._attribution = None + self._depth = None + self._locality = None + self._magnitude = None + self._mmi = None + self._quality = None + self._time = 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, + f"geonetnz_quakes_delete_{self._external_id}", + self._delete_callback, + ) + self._remove_signal_update = async_dispatcher_connect( + self.hass, + f"geonetnz_quakes_update_{self._external_id}", + self._update_callback, + ) + + async def async_will_remove_from_hass(self) -> None: + """Call when entity will be removed from hass.""" + self._remove_signal_delete() + self._remove_signal_update() + # Remove from entity registry. + entity_registry = await async_get_registry(self.hass) + if self.entity_id in entity_registry.entities: + entity_registry.async_remove(self.entity_id) + + @callback + def _delete_callback(self): + """Remove this entity.""" + 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 GeoNet NZ Quakes feed location events.""" + return False + + async def async_update(self): + """Update this entity from the data held in the feed manager.""" + _LOGGER.debug("Updating %s", self._external_id) + feed_entry = self._feed_manager.get_entry(self._external_id) + if feed_entry: + self._update_from_feed(feed_entry) + + def _update_from_feed(self, feed_entry): + """Update the internal state from the provided feed entry.""" + self._title = feed_entry.title + # Convert distance if not metric system. + if self.hass.config.units.name == CONF_UNIT_SYSTEM_IMPERIAL: + self._distance = IMPERIAL_SYSTEM.length( + feed_entry.distance_to_home, LENGTH_KILOMETERS + ) + else: + self._distance = feed_entry.distance_to_home + self._latitude = feed_entry.coordinates[0] + self._longitude = feed_entry.coordinates[1] + self._attribution = feed_entry.attribution + self._depth = feed_entry.depth + self._locality = feed_entry.locality + self._magnitude = feed_entry.magnitude + self._mmi = feed_entry.mmi + self._quality = feed_entry.quality + self._time = feed_entry.time + + @property + def unique_id(self) -> Optional[str]: + """Return a unique ID containing latitude/longitude and external id.""" + return f"{self._integration_id}_{self._external_id}" + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + return "mdi:pulse" + + @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._title + + @property + def distance(self) -> Optional[float]: + """Return distance value of this external event.""" + return self._distance + + @property + def latitude(self) -> Optional[float]: + """Return latitude value of this external event.""" + return self._latitude + + @property + def longitude(self) -> Optional[float]: + """Return longitude value of this external event.""" + return self._longitude + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + if self.hass.config.units.name == CONF_UNIT_SYSTEM_IMPERIAL: + return LENGTH_MILES + return LENGTH_KILOMETERS + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + attributes = {} + for key, value in ( + (ATTR_EXTERNAL_ID, self._external_id), + (ATTR_ATTRIBUTION, self._attribution), + (ATTR_DEPTH, self._depth), + (ATTR_LOCALITY, self._locality), + (ATTR_MAGNITUDE, self._magnitude), + (ATTR_MMI, self._mmi), + (ATTR_QUALITY, self._quality), + (ATTR_TIME, self._time), + ): + if value or isinstance(value, bool): + attributes[key] = value + return attributes diff --git a/homeassistant/components/geonetnz_quakes/manifest.json b/homeassistant/components/geonetnz_quakes/manifest.json new file mode 100644 index 0000000000000..1e61d52604729 --- /dev/null +++ b/homeassistant/components/geonetnz_quakes/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "geonetnz_quakes", + "name": "GeoNet NZ Quakes", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/geonetnz_quakes", + "requirements": ["aio_geojson_geonetnz_quakes==0.12"], + "codeowners": ["@exxamalte"], + "quality_scale": "platinum" +} diff --git a/homeassistant/components/geonetnz_quakes/sensor.py b/homeassistant/components/geonetnz_quakes/sensor.py new file mode 100644 index 0000000000000..1cb2d0dc0915f --- /dev/null +++ b/homeassistant/components/geonetnz_quakes/sensor.py @@ -0,0 +1,147 @@ +"""Feed Entity Manager Sensor support for GeoNet NZ Quakes Feeds.""" +import logging +from typing import Optional + +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity +from homeassistant.util import dt + +from .const import DOMAIN, FEED + +_LOGGER = logging.getLogger(__name__) + +ATTR_STATUS = "status" +ATTR_LAST_UPDATE = "last_update" +ATTR_LAST_UPDATE_SUCCESSFUL = "last_update_successful" +ATTR_LAST_TIMESTAMP = "last_timestamp" +ATTR_CREATED = "created" +ATTR_UPDATED = "updated" +ATTR_REMOVED = "removed" + +DEFAULT_ICON = "mdi:pulse" +DEFAULT_UNIT_OF_MEASUREMENT = "quakes" + +# An update of this entity is not making a web request, but uses internal data only. +PARALLEL_UPDATES = 0 + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up the GeoNet NZ Quakes Feed platform.""" + manager = hass.data[DOMAIN][FEED][entry.entry_id] + sensor = GeonetnzQuakesSensor(entry.entry_id, entry.unique_id, entry.title, manager) + async_add_entities([sensor]) + _LOGGER.debug("Sensor setup done") + + +class GeonetnzQuakesSensor(Entity): + """This is a status sensor for the GeoNet NZ Quakes integration.""" + + def __init__(self, config_entry_id, config_unique_id, config_title, manager): + """Initialize entity.""" + self._config_entry_id = config_entry_id + self._config_unique_id = config_unique_id + self._config_title = config_title + self._manager = manager + self._status = None + self._last_update = None + self._last_update_successful = None + self._last_timestamp = None + self._total = None + self._created = None + self._updated = None + self._removed = None + self._remove_signal_status = None + + async def async_added_to_hass(self): + """Call when entity is added to hass.""" + self._remove_signal_status = async_dispatcher_connect( + self.hass, + f"geonetnz_quakes_status_{self._config_entry_id}", + self._update_status_callback, + ) + _LOGGER.debug("Waiting for updates %s", self._config_entry_id) + # First update is manual because of how the feed entity manager is updated. + await self.async_update() + + async def async_will_remove_from_hass(self) -> None: + """Call when entity will be removed from hass.""" + if self._remove_signal_status: + self._remove_signal_status() + + @callback + def _update_status_callback(self): + """Call status update method.""" + _LOGGER.debug("Received status update for %s", self._config_entry_id) + self.async_schedule_update_ha_state(True) + + @property + def should_poll(self): + """No polling needed for GeoNet NZ Quakes status sensor.""" + return False + + async def async_update(self): + """Update this entity from the data held in the feed manager.""" + _LOGGER.debug("Updating %s", self._config_entry_id) + if self._manager: + status_info = self._manager.status_info() + if status_info: + self._update_from_status_info(status_info) + + def _update_from_status_info(self, status_info): + """Update the internal state from the provided information.""" + self._status = status_info.status + self._last_update = ( + dt.as_utc(status_info.last_update) if status_info.last_update else None + ) + if status_info.last_update_successful: + self._last_update_successful = dt.as_utc(status_info.last_update_successful) + else: + self._last_update_successful = None + self._last_timestamp = status_info.last_timestamp + self._total = status_info.total + self._created = status_info.created + self._updated = status_info.updated + self._removed = status_info.removed + + @property + def state(self): + """Return the state of the sensor.""" + return self._total + + @property + def unique_id(self) -> str: + """Return a unique ID containing latitude/longitude.""" + return self._config_unique_id + + @property + def name(self) -> Optional[str]: + """Return the name of the entity.""" + return f"GeoNet NZ Quakes ({self._config_title})" + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + return DEFAULT_ICON + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return DEFAULT_UNIT_OF_MEASUREMENT + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + attributes = {} + for key, value in ( + (ATTR_STATUS, self._status), + (ATTR_LAST_UPDATE, self._last_update), + (ATTR_LAST_UPDATE_SUCCESSFUL, self._last_update_successful), + (ATTR_LAST_TIMESTAMP, self._last_timestamp), + (ATTR_CREATED, self._created), + (ATTR_UPDATED, self._updated), + (ATTR_REMOVED, self._removed), + ): + if value or isinstance(value, bool): + attributes[key] = value + return attributes diff --git a/homeassistant/components/geonetnz_quakes/strings.json b/homeassistant/components/geonetnz_quakes/strings.json new file mode 100644 index 0000000000000..fe328c05603be --- /dev/null +++ b/homeassistant/components/geonetnz_quakes/strings.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "title": "Fill in your filter details.", + "data": { "radius": "Radius", "mmi": "MMI" } + } + }, + "abort": { "already_configured": "Location is already configured." } + } +} diff --git a/homeassistant/components/geonetnz_quakes/translations/bg.json b/homeassistant/components/geonetnz_quakes/translations/bg.json new file mode 100644 index 0000000000000..8b4d3e91f2c81 --- /dev/null +++ b/homeassistant/components/geonetnz_quakes/translations/bg.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "user": { + "data": { + "mmi": "MMI", + "radius": "Radius" + }, + "title": "\u041f\u043e\u043f\u044a\u043b\u043d\u0435\u0442\u0435 \u0434\u0430\u043d\u043d\u0438\u0442\u0435 \u0437\u0430 \u0444\u0438\u043b\u0442\u044a\u0440\u0430 \u0441\u0438." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_quakes/translations/ca.json b/homeassistant/components/geonetnz_quakes/translations/ca.json new file mode 100644 index 0000000000000..e97142a6e3fe9 --- /dev/null +++ b/homeassistant/components/geonetnz_quakes/translations/ca.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "already_configured": "La ubicaci\u00f3 ja est\u00e0 configurada." + }, + "step": { + "user": { + "data": { + "mmi": "MMI", + "radius": "Radi" + }, + "title": "Introdueix els detalls del filtre." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_quakes/translations/da.json b/homeassistant/components/geonetnz_quakes/translations/da.json new file mode 100644 index 0000000000000..a1ef4d41469c4 --- /dev/null +++ b/homeassistant/components/geonetnz_quakes/translations/da.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "user": { + "data": { + "mmi": "MMI", + "radius": "Radius" + }, + "title": "Udfyld dine filteroplysninger." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_quakes/translations/de.json b/homeassistant/components/geonetnz_quakes/translations/de.json new file mode 100644 index 0000000000000..583712c6c4ea4 --- /dev/null +++ b/homeassistant/components/geonetnz_quakes/translations/de.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "already_configured": "Der Standort ist bereits konfiguriert." + }, + "step": { + "user": { + "data": { + "mmi": "MMI", + "radius": "Radius" + }, + "title": "F\u00fclle deine Filterdaten aus." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_quakes/translations/en.json b/homeassistant/components/geonetnz_quakes/translations/en.json new file mode 100644 index 0000000000000..68f73bcf0898c --- /dev/null +++ b/homeassistant/components/geonetnz_quakes/translations/en.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "already_configured": "Location is already configured." + }, + "step": { + "user": { + "data": { + "mmi": "MMI", + "radius": "Radius" + }, + "title": "Fill in your filter details." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_quakes/translations/es.json b/homeassistant/components/geonetnz_quakes/translations/es.json new file mode 100644 index 0000000000000..0de80cf346a3a --- /dev/null +++ b/homeassistant/components/geonetnz_quakes/translations/es.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "already_configured": "La ubicaci\u00f3n ya est\u00e1 configurada." + }, + "step": { + "user": { + "data": { + "mmi": "MMI", + "radius": "Radio" + }, + "title": "Complete todos los campos requeridos" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_quakes/translations/fr.json b/homeassistant/components/geonetnz_quakes/translations/fr.json new file mode 100644 index 0000000000000..e448f9993bf2e --- /dev/null +++ b/homeassistant/components/geonetnz_quakes/translations/fr.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "already_configured": "L'emplacement est d\u00e9j\u00e0 configur\u00e9." + }, + "step": { + "user": { + "data": { + "mmi": "MMI", + "radius": "Rayon" + }, + "title": "Remplissez les d\u00e9tails de votre filtre." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_quakes/translations/hu.json b/homeassistant/components/geonetnz_quakes/translations/hu.json new file mode 100644 index 0000000000000..4a163d24b7592 --- /dev/null +++ b/homeassistant/components/geonetnz_quakes/translations/hu.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "radius": "Sug\u00e1r" + }, + "title": "T\u00f6ltsd ki a sz\u0171r\u0151 adatait." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_quakes/translations/it.json b/homeassistant/components/geonetnz_quakes/translations/it.json new file mode 100644 index 0000000000000..c07f04cdb644e --- /dev/null +++ b/homeassistant/components/geonetnz_quakes/translations/it.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "already_configured": "La posizione \u00e8 gi\u00e0 configurata." + }, + "step": { + "user": { + "data": { + "mmi": "Intensit\u00e0 in Scala Mercalli Modificata", + "radius": "Raggio" + }, + "title": "Inserisci i tuoi dettagli del filtro." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_quakes/translations/ko.json b/homeassistant/components/geonetnz_quakes/translations/ko.json new file mode 100644 index 0000000000000..b231629e8567a --- /dev/null +++ b/homeassistant/components/geonetnz_quakes/translations/ko.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "already_configured": "\uc704\uce58\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + }, + "step": { + "user": { + "data": { + "mmi": "MMI", + "radius": "\ubc18\uacbd" + }, + "title": "\ud544\ud130 \uc138\ubd80 \uc0ac\ud56d\uc744 \uc785\ub825\ud574\uc8fc\uc138\uc694." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_quakes/translations/lb.json b/homeassistant/components/geonetnz_quakes/translations/lb.json new file mode 100644 index 0000000000000..482330099b345 --- /dev/null +++ b/homeassistant/components/geonetnz_quakes/translations/lb.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "already_configured": "Standuert ass scho konfigu\u00e9iert." + }, + "step": { + "user": { + "data": { + "mmi": "MMI", + "radius": "Radius" + }, + "title": "F\u00ebllt \u00e4r Filter D\u00e9tailer aus." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_quakes/translations/nl.json b/homeassistant/components/geonetnz_quakes/translations/nl.json new file mode 100644 index 0000000000000..865860a5adfc4 --- /dev/null +++ b/homeassistant/components/geonetnz_quakes/translations/nl.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "already_configured": "Locatie is al geconfigureerd." + }, + "step": { + "user": { + "data": { + "mmi": "MMI", + "radius": "Straal" + }, + "title": "Vul uw filtergegevens in." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_quakes/translations/nn.json b/homeassistant/components/geonetnz_quakes/translations/nn.json new file mode 100644 index 0000000000000..d8afb1e7aaead --- /dev/null +++ b/homeassistant/components/geonetnz_quakes/translations/nn.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "mmi": "MMI", + "radius": "Radius" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_quakes/translations/no.json b/homeassistant/components/geonetnz_quakes/translations/no.json new file mode 100644 index 0000000000000..fc3b339d807e0 --- /dev/null +++ b/homeassistant/components/geonetnz_quakes/translations/no.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "already_configured": "Plasseringen er allerede konfigurert." + }, + "step": { + "user": { + "data": { + "mmi": "", + "radius": "" + }, + "title": "Fyll ut filterdetaljene." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_quakes/translations/pl.json b/homeassistant/components/geonetnz_quakes/translations/pl.json new file mode 100644 index 0000000000000..80d891a441ae2 --- /dev/null +++ b/homeassistant/components/geonetnz_quakes/translations/pl.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "already_configured": "Lokalizacja jest ju\u017c skonfigurowana." + }, + "step": { + "user": { + "data": { + "mmi": "MMI", + "radius": "Promie\u0144" + }, + "title": "Wprowad\u017a szczeg\u00f3\u0142owe dane filtra." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_quakes/translations/pt-BR.json b/homeassistant/components/geonetnz_quakes/translations/pt-BR.json new file mode 100644 index 0000000000000..9f08d6b820c22 --- /dev/null +++ b/homeassistant/components/geonetnz_quakes/translations/pt-BR.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "user": { + "data": { + "mmi": "MMI", + "radius": "Radius" + }, + "title": "Preencha os detalhes do filtro." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_quakes/translations/ru.json b/homeassistant/components/geonetnz_quakes/translations/ru.json new file mode 100644 index 0000000000000..7ee4f64431e33 --- /dev/null +++ b/homeassistant/components/geonetnz_quakes/translations/ru.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0434\u043b\u044f \u044d\u0442\u043e\u0433\u043e \u043c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430." + }, + "step": { + "user": { + "data": { + "mmi": "MMI", + "radius": "\u0420\u0430\u0434\u0438\u0443\u0441" + }, + "title": "GeoNet NZ Quakes" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_quakes/translations/sl.json b/homeassistant/components/geonetnz_quakes/translations/sl.json new file mode 100644 index 0000000000000..b1e2071146379 --- /dev/null +++ b/homeassistant/components/geonetnz_quakes/translations/sl.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "already_configured": "Lokacija je \u017ee nastavljena." + }, + "step": { + "user": { + "data": { + "mmi": "MMI", + "radius": "Radij" + }, + "title": "Izpolnite podrobnosti filtra." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_quakes/translations/sv.json b/homeassistant/components/geonetnz_quakes/translations/sv.json new file mode 100644 index 0000000000000..feb654c267c7b --- /dev/null +++ b/homeassistant/components/geonetnz_quakes/translations/sv.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "already_configured": "Plats \u00e4r redan konfigurerad." + }, + "step": { + "user": { + "data": { + "mmi": "MMI", + "radius": "Radie" + }, + "title": "Fyll i dina filterdetaljer." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_quakes/translations/zh-Hans.json b/homeassistant/components/geonetnz_quakes/translations/zh-Hans.json new file mode 100644 index 0000000000000..3786b03f41fc3 --- /dev/null +++ b/homeassistant/components/geonetnz_quakes/translations/zh-Hans.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "user": { + "title": "\u586b\u5199\u60a8\u7684filter\u8be6\u7ec6\u4fe1\u606f\u3002" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_quakes/translations/zh-Hant.json b/homeassistant/components/geonetnz_quakes/translations/zh-Hant.json new file mode 100644 index 0000000000000..1d697401b9562 --- /dev/null +++ b/homeassistant/components/geonetnz_quakes/translations/zh-Hant.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "already_configured": "\u4f4d\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210\u3002" + }, + "step": { + "user": { + "data": { + "mmi": "MMI", + "radius": "\u534a\u5f91" + }, + "title": "\u586b\u5beb\u904e\u6ffe\u5668\u8cc7\u8a0a\u3002" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_volcano/__init__.py b/homeassistant/components/geonetnz_volcano/__init__.py new file mode 100644 index 0000000000000..e2c6cb77083f4 --- /dev/null +++ b/homeassistant/components/geonetnz_volcano/__init__.py @@ -0,0 +1,198 @@ +"""The GeoNet NZ Volcano integration.""" +import asyncio +from datetime import datetime, timedelta +import logging +from typing import Optional + +from aio_geojson_geonetnz_volcano import GeonetnzVolcanoFeedManager +import voluptuous as vol + +from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.const import ( + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_RADIUS, + CONF_SCAN_INTERVAL, + CONF_UNIT_SYSTEM, + CONF_UNIT_SYSTEM_IMPERIAL, + LENGTH_MILES, +) +from homeassistant.core import callback +from homeassistant.helpers import aiohttp_client, config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.util.unit_system import METRIC_SYSTEM + +from .config_flow import configured_instances +from .const import DEFAULT_RADIUS, DEFAULT_SCAN_INTERVAL, DOMAIN, FEED + +_LOGGER = logging.getLogger(__name__) + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Optional(CONF_LATITUDE): cv.latitude, + vol.Optional(CONF_LONGITUDE): cv.longitude, + vol.Optional(CONF_RADIUS, default=DEFAULT_RADIUS): vol.Coerce(float), + vol.Optional( + CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL + ): cv.time_period, + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass, config): + """Set up the GeoNet NZ Volcano component.""" + if DOMAIN not in config: + return True + + conf = config[DOMAIN] + + latitude = conf.get(CONF_LATITUDE, hass.config.latitude) + longitude = conf.get(CONF_LONGITUDE, hass.config.longitude) + scan_interval = conf[CONF_SCAN_INTERVAL] + + identifier = f"{latitude}, {longitude}" + if identifier in configured_instances(hass): + return True + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_LATITUDE: latitude, + CONF_LONGITUDE: longitude, + CONF_RADIUS: conf[CONF_RADIUS], + CONF_SCAN_INTERVAL: scan_interval, + }, + ) + ) + + return True + + +async def async_setup_entry(hass, config_entry): + """Set up the GeoNet NZ Volcano component as config entry.""" + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN].setdefault(FEED, {}) + + radius = config_entry.data[CONF_RADIUS] + unit_system = config_entry.data[CONF_UNIT_SYSTEM] + if unit_system == CONF_UNIT_SYSTEM_IMPERIAL: + radius = METRIC_SYSTEM.length(radius, LENGTH_MILES) + # Create feed entity manager for all platforms. + manager = GeonetnzVolcanoFeedEntityManager(hass, config_entry, radius, unit_system) + hass.data[DOMAIN][FEED][config_entry.entry_id] = manager + _LOGGER.debug("Feed entity manager added for %s", config_entry.entry_id) + await manager.async_init() + return True + + +async def async_unload_entry(hass, config_entry): + """Unload an GeoNet NZ Volcano component config entry.""" + manager = hass.data[DOMAIN][FEED].pop(config_entry.entry_id) + await manager.async_stop() + await asyncio.wait( + [hass.config_entries.async_forward_entry_unload(config_entry, "sensor")] + ) + return True + + +class GeonetnzVolcanoFeedEntityManager: + """Feed Entity Manager for GeoNet NZ Volcano feed.""" + + def __init__(self, hass, config_entry, radius_in_km, unit_system): + """Initialize the Feed Entity Manager.""" + self._hass = hass + self._config_entry = config_entry + coordinates = ( + config_entry.data[CONF_LATITUDE], + config_entry.data[CONF_LONGITUDE], + ) + websession = aiohttp_client.async_get_clientsession(hass) + self._feed_manager = GeonetnzVolcanoFeedManager( + websession, + self._generate_entity, + self._update_entity, + self._remove_entity, + coordinates, + filter_radius=radius_in_km, + ) + self._config_entry_id = config_entry.entry_id + self._scan_interval = timedelta(seconds=config_entry.data[CONF_SCAN_INTERVAL]) + self._unit_system = unit_system + self._track_time_remove_callback = None + self.listeners = [] + + async def async_init(self): + """Schedule initial and regular updates based on configured time interval.""" + + self._hass.async_create_task( + self._hass.config_entries.async_forward_entry_setup( + self._config_entry, "sensor" + ) + ) + + async def update(event_time): + """Update.""" + await self.async_update() + + # Trigger updates at regular intervals. + self._track_time_remove_callback = async_track_time_interval( + self._hass, update, self._scan_interval + ) + + _LOGGER.debug("Feed entity manager initialized") + + async def async_update(self): + """Refresh data.""" + await self._feed_manager.update() + _LOGGER.debug("Feed entity manager updated") + + async def async_stop(self): + """Stop this feed entity manager from refreshing.""" + for unsub_dispatcher in self.listeners: + unsub_dispatcher() + self.listeners = [] + if self._track_time_remove_callback: + self._track_time_remove_callback() + _LOGGER.debug("Feed entity manager stopped") + + @callback + def async_event_new_entity(self): + """Return manager specific event to signal new entity.""" + return f"geonetnz_volcano_new_sensor_{self._config_entry_id}" + + def get_entry(self, external_id): + """Get feed entry by external id.""" + return self._feed_manager.feed_entries.get(external_id) + + def last_update(self) -> Optional[datetime]: + """Return the last update of this feed.""" + return self._feed_manager.last_update + + def last_update_successful(self) -> Optional[datetime]: + """Return the last successful update of this feed.""" + return self._feed_manager.last_update_successful + + async def _generate_entity(self, external_id): + """Generate new entity.""" + async_dispatcher_send( + self._hass, + self.async_event_new_entity(), + self, + external_id, + self._unit_system, + ) + + async def _update_entity(self, external_id): + """Update entity.""" + async_dispatcher_send(self._hass, f"geonetnz_volcano_update_{external_id}") + + async def _remove_entity(self, external_id): + """Ignore removing entity.""" diff --git a/homeassistant/components/geonetnz_volcano/config_flow.py b/homeassistant/components/geonetnz_volcano/config_flow.py new file mode 100644 index 0000000000000..c19e7d4b303d7 --- /dev/null +++ b/homeassistant/components/geonetnz_volcano/config_flow.py @@ -0,0 +1,74 @@ +"""Config flow to configure the GeoNet NZ Volcano integration.""" +import logging + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import ( + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_RADIUS, + CONF_SCAN_INTERVAL, + CONF_UNIT_SYSTEM, + CONF_UNIT_SYSTEM_IMPERIAL, + CONF_UNIT_SYSTEM_METRIC, +) +from homeassistant.core import callback +from homeassistant.helpers import config_validation as cv + +from .const import DEFAULT_RADIUS, DEFAULT_SCAN_INTERVAL, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +@callback +def configured_instances(hass): + """Return a set of configured GeoNet NZ Volcano instances.""" + return { + f"{entry.data[CONF_LATITUDE]}, {entry.data[CONF_LONGITUDE]}" + for entry in hass.config_entries.async_entries(DOMAIN) + } + + +class GeonetnzVolcanoFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a GeoNet NZ Volcano config flow.""" + + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + async def _show_form(self, errors=None): + """Show the form to the user.""" + data_schema = vol.Schema( + {vol.Optional(CONF_RADIUS, default=DEFAULT_RADIUS): cv.positive_int} + ) + + return self.async_show_form( + step_id="user", data_schema=data_schema, errors=errors or {} + ) + + async def async_step_import(self, import_config): + """Import a config entry from configuration.yaml.""" + return await self.async_step_user(import_config) + + async def async_step_user(self, user_input=None): + """Handle the start of the config flow.""" + if not user_input: + return await self._show_form() + + latitude = user_input.get(CONF_LATITUDE, self.hass.config.latitude) + user_input[CONF_LATITUDE] = latitude + longitude = user_input.get(CONF_LONGITUDE, self.hass.config.longitude) + user_input[CONF_LONGITUDE] = longitude + + identifier = f"{user_input[CONF_LATITUDE]}, {user_input[CONF_LONGITUDE]}" + if identifier in configured_instances(self.hass): + return await self._show_form({"base": "identifier_exists"}) + + if self.hass.config.units.name == CONF_UNIT_SYSTEM_IMPERIAL: + user_input[CONF_UNIT_SYSTEM] = CONF_UNIT_SYSTEM_IMPERIAL + else: + user_input[CONF_UNIT_SYSTEM] = CONF_UNIT_SYSTEM_METRIC + + scan_interval = user_input.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + user_input[CONF_SCAN_INTERVAL] = scan_interval.seconds + + return self.async_create_entry(title=identifier, data=user_input) diff --git a/homeassistant/components/geonetnz_volcano/const.py b/homeassistant/components/geonetnz_volcano/const.py new file mode 100644 index 0000000000000..d48e9775f1959 --- /dev/null +++ b/homeassistant/components/geonetnz_volcano/const.py @@ -0,0 +1,16 @@ +"""Define constants for the GeoNet NZ Volcano integration.""" +from datetime import timedelta + +DOMAIN = "geonetnz_volcano" + +FEED = "feed" + +ATTR_ACTIVITY = "activity" +ATTR_DISTANCE = "distance" +ATTR_EXTERNAL_ID = "external_id" +ATTR_HAZARDS = "hazards" + +# Icon alias "mdi:mountain" not working. +DEFAULT_ICON = "mdi:image-filter-hdr" +DEFAULT_RADIUS = 50.0 +DEFAULT_SCAN_INTERVAL = timedelta(minutes=5) diff --git a/homeassistant/components/geonetnz_volcano/manifest.json b/homeassistant/components/geonetnz_volcano/manifest.json new file mode 100644 index 0000000000000..13e1e9baf3e92 --- /dev/null +++ b/homeassistant/components/geonetnz_volcano/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "geonetnz_volcano", + "name": "GeoNet NZ Volcano", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/geonetnz_volcano", + "requirements": ["aio_geojson_geonetnz_volcano==0.5"], + "codeowners": ["@exxamalte"] +} diff --git a/homeassistant/components/geonetnz_volcano/sensor.py b/homeassistant/components/geonetnz_volcano/sensor.py new file mode 100644 index 0000000000000..3d5d0681f0222 --- /dev/null +++ b/homeassistant/components/geonetnz_volcano/sensor.py @@ -0,0 +1,168 @@ +"""Feed Entity Manager Sensor support for GeoNet NZ Volcano Feeds.""" +import logging +from typing import Optional + +from homeassistant.const import ( + ATTR_ATTRIBUTION, + ATTR_LATITUDE, + ATTR_LONGITUDE, + CONF_UNIT_SYSTEM_IMPERIAL, + LENGTH_KILOMETERS, +) +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity +from homeassistant.util import dt +from homeassistant.util.unit_system import IMPERIAL_SYSTEM + +from .const import ( + ATTR_ACTIVITY, + ATTR_DISTANCE, + ATTR_EXTERNAL_ID, + ATTR_HAZARDS, + DEFAULT_ICON, + DOMAIN, + FEED, +) + +_LOGGER = logging.getLogger(__name__) + +ATTR_LAST_UPDATE = "feed_last_update" +ATTR_LAST_UPDATE_SUCCESSFUL = "feed_last_update_successful" + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up the GeoNet NZ Volcano Feed platform.""" + manager = hass.data[DOMAIN][FEED][entry.entry_id] + + @callback + def async_add_sensor(feed_manager, external_id, unit_system): + """Add sensor entity from feed.""" + new_entity = GeonetnzVolcanoSensor( + entry.entry_id, feed_manager, external_id, unit_system + ) + _LOGGER.debug("Adding sensor %s", new_entity) + async_add_entities([new_entity], True) + + manager.listeners.append( + async_dispatcher_connect( + hass, manager.async_event_new_entity(), async_add_sensor + ) + ) + hass.async_create_task(manager.async_update()) + _LOGGER.debug("Sensor setup done") + + +class GeonetnzVolcanoSensor(Entity): + """This represents an external event with GeoNet NZ Volcano feed data.""" + + def __init__(self, config_entry_id, feed_manager, external_id, unit_system): + """Initialize entity with data from feed entry.""" + self._config_entry_id = config_entry_id + self._feed_manager = feed_manager + self._external_id = external_id + self._unit_system = unit_system + self._title = None + self._distance = None + self._latitude = None + self._longitude = None + self._attribution = None + self._alert_level = None + self._activity = None + self._hazards = None + self._feed_last_update = None + self._feed_last_update_successful = None + self._remove_signal_update = None + + async def async_added_to_hass(self): + """Call when entity is added to hass.""" + self._remove_signal_update = async_dispatcher_connect( + self.hass, + f"geonetnz_volcano_update_{self._external_id}", + self._update_callback, + ) + + async def async_will_remove_from_hass(self) -> None: + """Call when entity will be removed from hass.""" + if self._remove_signal_update: + self._remove_signal_update() + + @callback + def _update_callback(self): + """Call update method.""" + self.async_schedule_update_ha_state(True) + + @property + def should_poll(self): + """No polling needed for GeoNet NZ Volcano feed location events.""" + return False + + async def async_update(self): + """Update this entity from the data held in the feed manager.""" + _LOGGER.debug("Updating %s", self._external_id) + feed_entry = self._feed_manager.get_entry(self._external_id) + last_update = self._feed_manager.last_update() + last_update_successful = self._feed_manager.last_update_successful() + if feed_entry: + self._update_from_feed(feed_entry, last_update, last_update_successful) + + def _update_from_feed(self, feed_entry, last_update, last_update_successful): + """Update the internal state from the provided feed entry.""" + self._title = feed_entry.title + # Convert distance if not metric system. + if self._unit_system == CONF_UNIT_SYSTEM_IMPERIAL: + self._distance = round( + IMPERIAL_SYSTEM.length(feed_entry.distance_to_home, LENGTH_KILOMETERS), + 1, + ) + else: + self._distance = round(feed_entry.distance_to_home, 1) + self._latitude = round(feed_entry.coordinates[0], 5) + self._longitude = round(feed_entry.coordinates[1], 5) + self._attribution = feed_entry.attribution + self._alert_level = feed_entry.alert_level + self._activity = feed_entry.activity + self._hazards = feed_entry.hazards + self._feed_last_update = dt.as_utc(last_update) if last_update else None + self._feed_last_update_successful = ( + dt.as_utc(last_update_successful) if last_update_successful else None + ) + + @property + def state(self): + """Return the state of the sensor.""" + return self._alert_level + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + return DEFAULT_ICON + + @property + def name(self) -> Optional[str]: + """Return the name of the entity.""" + return f"Volcano {self._title}" + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return "alert level" + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + attributes = {} + for key, value in ( + (ATTR_EXTERNAL_ID, self._external_id), + (ATTR_ATTRIBUTION, self._attribution), + (ATTR_ACTIVITY, self._activity), + (ATTR_HAZARDS, self._hazards), + (ATTR_LONGITUDE, self._longitude), + (ATTR_LATITUDE, self._latitude), + (ATTR_DISTANCE, self._distance), + (ATTR_LAST_UPDATE, self._feed_last_update), + (ATTR_LAST_UPDATE_SUCCESSFUL, self._feed_last_update_successful), + ): + if value or isinstance(value, bool): + attributes[key] = value + return attributes diff --git a/homeassistant/components/geonetnz_volcano/strings.json b/homeassistant/components/geonetnz_volcano/strings.json new file mode 100644 index 0000000000000..d364d76b2edcc --- /dev/null +++ b/homeassistant/components/geonetnz_volcano/strings.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "title": "Fill in your filter details.", + "data": { "radius": "Radius" } + } + }, + "error": { "identifier_exists": "Location already registered" } + } +} diff --git a/homeassistant/components/geonetnz_volcano/translations/bg.json b/homeassistant/components/geonetnz_volcano/translations/bg.json new file mode 100644 index 0000000000000..e50751ad49e5b --- /dev/null +++ b/homeassistant/components/geonetnz_volcano/translations/bg.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "identifier_exists": "\u041c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u0430\u043d\u043e" + }, + "step": { + "user": { + "data": { + "radius": "\u0420\u0430\u0434\u0438\u0443\u0441" + }, + "title": "\u041f\u043e\u043f\u044a\u043b\u043d\u0435\u0442\u0435 \u0434\u0430\u043d\u043d\u0438\u0442\u0435 \u0437\u0430 \u0444\u0438\u043b\u0442\u044a\u0440\u0430 \u0441\u0438." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_volcano/translations/ca.json b/homeassistant/components/geonetnz_volcano/translations/ca.json new file mode 100644 index 0000000000000..8af45618a17a4 --- /dev/null +++ b/homeassistant/components/geonetnz_volcano/translations/ca.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "identifier_exists": "Ubicaci\u00f3 ja registrada" + }, + "step": { + "user": { + "data": { + "radius": "Radi" + }, + "title": "Introducci\u00f3 dels detalls del filtre." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_volcano/translations/da.json b/homeassistant/components/geonetnz_volcano/translations/da.json new file mode 100644 index 0000000000000..b83162e0b352b --- /dev/null +++ b/homeassistant/components/geonetnz_volcano/translations/da.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "identifier_exists": "Lokalitet allerede registreret" + }, + "step": { + "user": { + "data": { + "radius": "Radius" + }, + "title": "Udfyld dine filteroplysninger." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_volcano/translations/de.json b/homeassistant/components/geonetnz_volcano/translations/de.json new file mode 100644 index 0000000000000..8c3cd960b4d2b --- /dev/null +++ b/homeassistant/components/geonetnz_volcano/translations/de.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "identifier_exists": "Standort bereits registriert" + }, + "step": { + "user": { + "data": { + "radius": "Radius" + }, + "title": "F\u00fclle deine Filterangaben aus." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_volcano/translations/en.json b/homeassistant/components/geonetnz_volcano/translations/en.json new file mode 100644 index 0000000000000..fe24b6dcea059 --- /dev/null +++ b/homeassistant/components/geonetnz_volcano/translations/en.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "identifier_exists": "Location already registered" + }, + "step": { + "user": { + "data": { + "radius": "Radius" + }, + "title": "Fill in your filter details." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_volcano/translations/es.json b/homeassistant/components/geonetnz_volcano/translations/es.json new file mode 100644 index 0000000000000..c26033e18619c --- /dev/null +++ b/homeassistant/components/geonetnz_volcano/translations/es.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "identifier_exists": "Lugar ya registrado" + }, + "step": { + "user": { + "data": { + "radius": "Radio" + }, + "title": "Complete los detalles de su filtro." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_volcano/translations/fr.json b/homeassistant/components/geonetnz_volcano/translations/fr.json new file mode 100644 index 0000000000000..2692768910c8c --- /dev/null +++ b/homeassistant/components/geonetnz_volcano/translations/fr.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "identifier_exists": "Emplacement d\u00e9j\u00e0 enregistr\u00e9" + }, + "step": { + "user": { + "data": { + "radius": "Rayon" + }, + "title": "Remplissez les d\u00e9tails de votre filtre." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_volcano/translations/hu.json b/homeassistant/components/geonetnz_volcano/translations/hu.json new file mode 100644 index 0000000000000..e1d2bdb9f9edc --- /dev/null +++ b/homeassistant/components/geonetnz_volcano/translations/hu.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "identifier_exists": "A hely m\u00e1r regisztr\u00e1lt" + }, + "step": { + "user": { + "data": { + "radius": "Sug\u00e1r" + }, + "title": "T\u00f6ltse ki a sz\u0171r\u0151 adatait." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_volcano/translations/it.json b/homeassistant/components/geonetnz_volcano/translations/it.json new file mode 100644 index 0000000000000..c566cdfb81b7e --- /dev/null +++ b/homeassistant/components/geonetnz_volcano/translations/it.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "identifier_exists": "Localit\u00e0 gi\u00e0 registrata" + }, + "step": { + "user": { + "data": { + "radius": "Raggio" + }, + "title": "Inserisci i tuoi dettagli del filtro." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_volcano/translations/ko.json b/homeassistant/components/geonetnz_volcano/translations/ko.json new file mode 100644 index 0000000000000..9ee235ea2b65b --- /dev/null +++ b/homeassistant/components/geonetnz_volcano/translations/ko.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "identifier_exists": "\uc704\uce58\uac00 \uc774\ubbf8 \ub4f1\ub85d\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, + "step": { + "user": { + "data": { + "radius": "\ubc18\uacbd" + }, + "title": "\ud544\ud130 \uc138\ubd80 \uc0ac\ud56d\uc744 \uc785\ub825\ud574\uc8fc\uc138\uc694." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_volcano/translations/lb.json b/homeassistant/components/geonetnz_volcano/translations/lb.json new file mode 100644 index 0000000000000..0d5108982b676 --- /dev/null +++ b/homeassistant/components/geonetnz_volcano/translations/lb.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "identifier_exists": "Standuert ass scho registr\u00e9iert" + }, + "step": { + "user": { + "data": { + "radius": "Radius" + }, + "title": "F\u00ebllt \u00e4r Filter D\u00e9tailer aus." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_volcano/translations/nl.json b/homeassistant/components/geonetnz_volcano/translations/nl.json new file mode 100644 index 0000000000000..73c7c1eaab358 --- /dev/null +++ b/homeassistant/components/geonetnz_volcano/translations/nl.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "identifier_exists": "Locatie al geregistreerd" + }, + "step": { + "user": { + "data": { + "radius": "Straal" + }, + "title": "Vul uw filtergegevens in." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_volcano/translations/no.json b/homeassistant/components/geonetnz_volcano/translations/no.json new file mode 100644 index 0000000000000..36d7216dcd9c2 --- /dev/null +++ b/homeassistant/components/geonetnz_volcano/translations/no.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "identifier_exists": "Beliggenhet er allerede registrert" + }, + "step": { + "user": { + "data": { + "radius": "" + }, + "title": "Fyll ut filterdetaljene." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_volcano/translations/pl.json b/homeassistant/components/geonetnz_volcano/translations/pl.json new file mode 100644 index 0000000000000..23a5640ae6f4d --- /dev/null +++ b/homeassistant/components/geonetnz_volcano/translations/pl.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "identifier_exists": "Lokalizacja jest ju\u017c zarejestrowana." + }, + "step": { + "user": { + "data": { + "radius": "Promie\u0144" + }, + "title": "Wprowad\u017a szczeg\u00f3\u0142owe dane filtra." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_volcano/translations/pt-BR.json b/homeassistant/components/geonetnz_volcano/translations/pt-BR.json new file mode 100644 index 0000000000000..b16295999265f --- /dev/null +++ b/homeassistant/components/geonetnz_volcano/translations/pt-BR.json @@ -0,0 +1,14 @@ +{ + "config": { + "error": { + "identifier_exists": "Local j\u00e1 registrado" + }, + "step": { + "user": { + "data": { + "radius": "Raio" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_volcano/translations/pt.json b/homeassistant/components/geonetnz_volcano/translations/pt.json new file mode 100644 index 0000000000000..98180e11248aa --- /dev/null +++ b/homeassistant/components/geonetnz_volcano/translations/pt.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "radius": "Raio" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_volcano/translations/ro.json b/homeassistant/components/geonetnz_volcano/translations/ro.json new file mode 100644 index 0000000000000..904bcd683109c --- /dev/null +++ b/homeassistant/components/geonetnz_volcano/translations/ro.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "radius": "Raz\u0103" + }, + "title": "Completa\u021bi detaliile filtrului." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_volcano/translations/ru.json b/homeassistant/components/geonetnz_volcano/translations/ru.json new file mode 100644 index 0000000000000..33a7f90b6a71d --- /dev/null +++ b/homeassistant/components/geonetnz_volcano/translations/ru.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "identifier_exists": "\u041c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u0443\u0436\u0435 \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u043d\u043e." + }, + "step": { + "user": { + "data": { + "radius": "\u0420\u0430\u0434\u0438\u0443\u0441" + }, + "title": "GeoNet NZ Volcano" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_volcano/translations/sl.json b/homeassistant/components/geonetnz_volcano/translations/sl.json new file mode 100644 index 0000000000000..885c41957f7ce --- /dev/null +++ b/homeassistant/components/geonetnz_volcano/translations/sl.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "identifier_exists": "Lokacija je \u017ee registrirana" + }, + "step": { + "user": { + "data": { + "radius": "Radij" + }, + "title": "Izpolnite podrobnosti filtra." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_volcano/translations/sv.json b/homeassistant/components/geonetnz_volcano/translations/sv.json new file mode 100644 index 0000000000000..3abddb78c68ff --- /dev/null +++ b/homeassistant/components/geonetnz_volcano/translations/sv.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "identifier_exists": "Plats redan registrerad" + }, + "step": { + "user": { + "data": { + "radius": "Radie" + }, + "title": "Fyll i dina filterdetaljer." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_volcano/translations/zh-Hant.json b/homeassistant/components/geonetnz_volcano/translations/zh-Hant.json new file mode 100644 index 0000000000000..a587c9c0fd4bd --- /dev/null +++ b/homeassistant/components/geonetnz_volcano/translations/zh-Hant.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "identifier_exists": "\u5ea7\u6a19\u5df2\u8a3b\u518a" + }, + "step": { + "user": { + "data": { + "radius": "\u534a\u5f91" + }, + "title": "\u586b\u5beb\u904e\u6ffe\u5668\u8cc7\u8a0a\u3002" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/gios/__init__.py b/homeassistant/components/gios/__init__.py new file mode 100644 index 0000000000000..c7e708e3207ef --- /dev/null +++ b/homeassistant/components/gios/__init__.py @@ -0,0 +1,75 @@ +"""The GIOS component.""" +import logging + +from aiohttp.client_exceptions import ClientConnectorError +from async_timeout import timeout +from gios import ApiError, Gios, InvalidSensorsData, NoStationError + +from homeassistant.core import Config, HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import CONF_STATION_ID, DOMAIN, SCAN_INTERVAL + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass: HomeAssistant, config: Config) -> bool: + """Set up configured GIOS.""" + return True + + +async def async_setup_entry(hass, config_entry): + """Set up GIOS as config entry.""" + station_id = config_entry.data[CONF_STATION_ID] + _LOGGER.debug("Using station_id: %s", station_id) + + websession = async_get_clientsession(hass) + + coordinator = GiosDataUpdateCoordinator(hass, websession, station_id) + await coordinator.async_refresh() + + if not coordinator.last_update_success: + raise ConfigEntryNotReady + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][config_entry.entry_id] = coordinator + + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(config_entry, "air_quality") + ) + return True + + +async def async_unload_entry(hass, config_entry): + """Unload a config entry.""" + hass.data[DOMAIN].pop(config_entry.entry_id) + await hass.config_entries.async_forward_entry_unload(config_entry, "air_quality") + return True + + +class GiosDataUpdateCoordinator(DataUpdateCoordinator): + """Define an object to hold GIOS data.""" + + def __init__(self, hass, session, station_id): + """Class to manage fetching GIOS data API.""" + self.gios = Gios(station_id, session) + + super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) + + async def _async_update_data(self): + """Update data via library.""" + try: + with timeout(30): + await self.gios.update() + except ( + ApiError, + NoStationError, + ClientConnectorError, + InvalidSensorsData, + ) as error: + raise UpdateFailed(error) + if not self.gios.data: + raise UpdateFailed("Invalid sensors data") + return self.gios.data diff --git a/homeassistant/components/gios/air_quality.py b/homeassistant/components/gios/air_quality.py new file mode 100644 index 0000000000000..d180b0a0ddfe8 --- /dev/null +++ b/homeassistant/components/gios/air_quality.py @@ -0,0 +1,157 @@ +"""Support for the GIOS service.""" +from homeassistant.components.air_quality import ( + ATTR_CO, + ATTR_NO2, + ATTR_OZONE, + ATTR_PM_2_5, + ATTR_PM_10, + ATTR_SO2, + AirQualityEntity, +) +from homeassistant.const import CONF_NAME + +from .const import ATTR_STATION, DOMAIN, ICONS_MAP + +ATTRIBUTION = "Data provided by GIOŚ" + +SENSOR_MAP = { + "CO": ATTR_CO, + "NO2": ATTR_NO2, + "O3": ATTR_OZONE, + "PM10": ATTR_PM_10, + "PM2.5": ATTR_PM_2_5, + "SO2": ATTR_SO2, +} + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Add a GIOS entities from a config_entry.""" + name = config_entry.data[CONF_NAME] + + coordinator = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities([GiosAirQuality(coordinator, name)], False) + + +def round_state(func): + """Round state.""" + + def _decorator(self): + res = func(self) + if isinstance(res, float): + return round(res) + return res + + return _decorator + + +class GiosAirQuality(AirQualityEntity): + """Define an GIOS sensor.""" + + def __init__(self, coordinator, name): + """Initialize.""" + self.coordinator = coordinator + self._name = name + self._attrs = {} + + @property + def name(self): + """Return the name.""" + return self._name + + @property + def icon(self): + """Return the icon.""" + if self.air_quality_index in ICONS_MAP: + return ICONS_MAP[self.air_quality_index] + return "mdi:blur" + + @property + def air_quality_index(self): + """Return the air quality index.""" + return self._get_sensor_value("AQI") + + @property + @round_state + def particulate_matter_2_5(self): + """Return the particulate matter 2.5 level.""" + return self._get_sensor_value("PM2.5") + + @property + @round_state + def particulate_matter_10(self): + """Return the particulate matter 10 level.""" + return self._get_sensor_value("PM10") + + @property + @round_state + def ozone(self): + """Return the O3 (ozone) level.""" + return self._get_sensor_value("O3") + + @property + @round_state + def carbon_monoxide(self): + """Return the CO (carbon monoxide) level.""" + return self._get_sensor_value("CO") + + @property + @round_state + def sulphur_dioxide(self): + """Return the SO2 (sulphur dioxide) level.""" + return self._get_sensor_value("SO2") + + @property + @round_state + def nitrogen_dioxide(self): + """Return the NO2 (nitrogen dioxide) level.""" + return self._get_sensor_value("NO2") + + @property + def attribution(self): + """Return the attribution.""" + return ATTRIBUTION + + @property + def unique_id(self): + """Return a unique_id for this entity.""" + return self.coordinator.gios.station_id + + @property + def should_poll(self): + """Return the polling requirement of the entity.""" + return False + + @property + def available(self): + """Return True if entity is available.""" + return self.coordinator.last_update_success + + @property + def device_state_attributes(self): + """Return the state attributes.""" + # Different measuring stations have different sets of sensors. We don't know + # what data we will get. + for sensor in SENSOR_MAP: + if sensor in self.coordinator.data: + self._attrs[f"{SENSOR_MAP[sensor]}_index"] = self.coordinator.data[ + sensor + ]["index"] + self._attrs[ATTR_STATION] = self.coordinator.gios.station_name + return self._attrs + + async def async_added_to_hass(self): + """Connect to dispatcher listening for entity data notifications.""" + self.async_on_remove( + self.coordinator.async_add_listener(self.async_write_ha_state) + ) + + async def async_update(self): + """Update GIOS entity.""" + await self.coordinator.async_request_refresh() + + def _get_sensor_value(self, sensor): + """Return value of specified sensor.""" + if sensor in self.coordinator.data: + return self.coordinator.data[sensor]["value"] + return None diff --git a/homeassistant/components/gios/config_flow.py b/homeassistant/components/gios/config_flow.py new file mode 100644 index 0000000000000..5741af47a072e --- /dev/null +++ b/homeassistant/components/gios/config_flow.py @@ -0,0 +1,58 @@ +"""Adds config flow for GIOS.""" +import asyncio + +from aiohttp.client_exceptions import ClientConnectorError +from async_timeout import timeout +from gios import ApiError, Gios, InvalidSensorsData, NoStationError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_NAME +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CONF_STATION_ID, DEFAULT_NAME, DOMAIN # pylint:disable=unused-import + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_STATION_ID): int, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): str, + } +) + + +class GiosFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Config flow for GIOS.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + errors = {} + + if user_input is not None: + try: + await self.async_set_unique_id( + user_input[CONF_STATION_ID], raise_on_progress=False + ) + self._abort_if_unique_id_configured() + + websession = async_get_clientsession(self.hass) + + with timeout(30): + gios = Gios(user_input[CONF_STATION_ID], websession) + await gios.update() + + return self.async_create_entry( + title=user_input[CONF_STATION_ID], data=user_input, + ) + except (ApiError, ClientConnectorError, asyncio.TimeoutError): + errors["base"] = "cannot_connect" + except NoStationError: + errors[CONF_STATION_ID] = "wrong_station_id" + except InvalidSensorsData: + errors[CONF_STATION_ID] = "invalid_sensors_data" + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/gios/const.py b/homeassistant/components/gios/const.py new file mode 100644 index 0000000000000..918b4fba2e4f7 --- /dev/null +++ b/homeassistant/components/gios/const.py @@ -0,0 +1,24 @@ +"""Constants for GIOS integration.""" +from datetime import timedelta + +ATTR_NAME = "name" +ATTR_STATION = "station" +CONF_STATION_ID = "station_id" +DEFAULT_NAME = "GIOŚ" +# Term of service GIOŚ allow downloading data no more than twice an hour. +SCAN_INTERVAL = timedelta(minutes=30) +DOMAIN = "gios" + +AQI_GOOD = "dobry" +AQI_MODERATE = "umiarkowany" +AQI_POOR = "dostateczny" +AQI_VERY_GOOD = "bardzo dobry" +AQI_VERY_POOR = "zły" + +ICONS_MAP = { + AQI_VERY_GOOD: "mdi:emoticon-excited", + AQI_GOOD: "mdi:emoticon-happy", + AQI_MODERATE: "mdi:emoticon-neutral", + AQI_POOR: "mdi:emoticon-sad", + AQI_VERY_POOR: "mdi:emoticon-dead", +} diff --git a/homeassistant/components/gios/manifest.json b/homeassistant/components/gios/manifest.json new file mode 100644 index 0000000000000..527bb7e116fd0 --- /dev/null +++ b/homeassistant/components/gios/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "gios", + "name": "GIOŚ", + "documentation": "https://www.home-assistant.io/integrations/gios", + "codeowners": ["@bieniu"], + "requirements": ["gios==0.1.1"], + "config_flow": true +} diff --git a/homeassistant/components/gios/strings.json b/homeassistant/components/gios/strings.json new file mode 100644 index 0000000000000..2187bcbc9980c --- /dev/null +++ b/homeassistant/components/gios/strings.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "user": { + "title": "GIO\u015a (Polish Chief Inspectorate Of Environmental Protection)", + "description": "Set up GIO\u015a (Polish Chief Inspectorate Of Environmental Protection) air quality integration. If you need help with the configuration have a look here: https://www.home-assistant.io/integrations/gios", + "data": { + "name": "Name of the integration", + "station_id": "ID of the measuring station" + } + } + }, + "error": { + "wrong_station_id": "ID of the measuring station is not correct.", + "invalid_sensors_data": "Invalid sensors' data for this measuring station.", + "cannot_connect": "Cannot connect to the GIO\u015a server." + }, + "abort": { + "already_configured": "GIO\u015a integration for this measuring station is already configured." + } + } +} diff --git a/homeassistant/components/gios/translations/ca.json b/homeassistant/components/gios/translations/ca.json new file mode 100644 index 0000000000000..29703281b087c --- /dev/null +++ b/homeassistant/components/gios/translations/ca.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "La integraci\u00f3 GIO\u015a per a aquesta estaci\u00f3 ja est\u00e0 configurada." + }, + "error": { + "cannot_connect": "No s'ha pogut connectar al servidor de GIO\u015a.", + "invalid_sensors_data": "Les dades dels sensors d'aquesta estaci\u00f3 de mesura s\u00f3n inv\u00e0lides.", + "wrong_station_id": "L'ID de l'estaci\u00f3 de mesura \u00e9s incorrecte." + }, + "step": { + "user": { + "data": { + "name": "Nom de la integraci\u00f3", + "station_id": "ID de l'estaci\u00f3 de mesura" + }, + "description": "Integraci\u00f3 de mesura de qualitat de l'aire GIO\u015a (Polish Chief Inspectorate Of Environmental Protection). Si necessites ajuda amb la configuraci\u00f3, fes un cop d'ull a: https://www.home-assistant.io/integrations/gios", + "title": "GIO\u015a (Polish Chief Inspectorate Of Environmental Protection)" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/gios/translations/da.json b/homeassistant/components/gios/translations/da.json new file mode 100644 index 0000000000000..d4442982e1ed5 --- /dev/null +++ b/homeassistant/components/gios/translations/da.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "GIO\u015a-integration for denne m\u00e5lestation er allerede konfigureret." + }, + "error": { + "cannot_connect": "Kan ikke oprette forbindelse til GIO\u015a-serveren.", + "invalid_sensors_data": "Ugyldige sensordata for denne m\u00e5lestation.", + "wrong_station_id": "M\u00e5lestationens ID er ikke korrekt." + }, + "step": { + "user": { + "data": { + "name": "Navn p\u00e5 integrationen", + "station_id": "ID for m\u00e5lestationen" + }, + "description": "Ops\u00e6t GIO\u015a (polsk inspektorat for milj\u00f8beskyttelse) luftkvalitet-integration. Hvis du har brug for hj\u00e6lp med konfigurationen, kig her: https://www.home-assistant.io/integrations/gios", + "title": "GIO\u015a (Polish Chief Inspectorate Of Environmental Protection)" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/gios/translations/de.json b/homeassistant/components/gios/translations/de.json new file mode 100644 index 0000000000000..0a5cea1819dbc --- /dev/null +++ b/homeassistant/components/gios/translations/de.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "GIO\u015a integration f\u00fcr diese Messstation ist bereits konfiguriert. " + }, + "error": { + "cannot_connect": "Es kann keine Verbindung zum GIO\u015a-Server hergestellt werden.", + "invalid_sensors_data": "Ung\u00fcltige Sensordaten f\u00fcr diese Messstation.", + "wrong_station_id": "ID der Messstation ist nicht korrekt." + }, + "step": { + "user": { + "data": { + "name": "Name der Integration", + "station_id": "ID der Messstation" + }, + "description": "Einrichtung von GIO\u015a (Polnische Hauptinspektion f\u00fcr Umweltschutz) Integration der Luftqualit\u00e4t. Wenn du Hilfe bei der Konfiguration ben\u00f6tigst, schaue hier: https://www.home-assistant.io/integrations/gios", + "title": "GIO\u015a (Polnische Hauptinspektion f\u00fcr Umweltschutz)" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/gios/translations/en.json b/homeassistant/components/gios/translations/en.json new file mode 100644 index 0000000000000..3d07ad843bd1a --- /dev/null +++ b/homeassistant/components/gios/translations/en.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "GIO\u015a integration for this measuring station is already configured." + }, + "error": { + "cannot_connect": "Cannot connect to the GIO\u015a server.", + "invalid_sensors_data": "Invalid sensors' data for this measuring station.", + "wrong_station_id": "ID of the measuring station is not correct." + }, + "step": { + "user": { + "data": { + "name": "Name of the integration", + "station_id": "ID of the measuring station" + }, + "description": "Set up GIO\u015a (Polish Chief Inspectorate Of Environmental Protection) air quality integration. If you need help with the configuration have a look here: https://www.home-assistant.io/integrations/gios", + "title": "GIO\u015a (Polish Chief Inspectorate Of Environmental Protection)" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/gios/translations/es-419.json b/homeassistant/components/gios/translations/es-419.json new file mode 100644 index 0000000000000..53439a7ab7be4 --- /dev/null +++ b/homeassistant/components/gios/translations/es-419.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "name": "Nombre de la integraci\u00f3n" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/gios/translations/es.json b/homeassistant/components/gios/translations/es.json new file mode 100644 index 0000000000000..a7c30cd9d72b5 --- /dev/null +++ b/homeassistant/components/gios/translations/es.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "La integraci\u00f3n de GIO\u015a para esta estaci\u00f3n de medici\u00f3n ya est\u00e1 configurada." + }, + "error": { + "cannot_connect": "No se puede conectar al servidor GIO\u015a.", + "invalid_sensors_data": "Datos de sensores no v\u00e1lidos para esta estaci\u00f3n de medici\u00f3n.", + "wrong_station_id": "El ID de la estaci\u00f3n de medici\u00f3n no es correcta." + }, + "step": { + "user": { + "data": { + "name": "Nombre de la integraci\u00f3n", + "station_id": "ID de la estaci\u00f3n de medici\u00f3n" + }, + "description": "Configurar la integraci\u00f3n de la calidad del aire GIO\u015a (Inspecci\u00f3n Jefe de Protecci\u00f3n Ambiental de Polonia). Si necesita ayuda con la configuraci\u00f3n, eche un vistazo aqu\u00ed: https://www.home-assistant.io/integrations/gios", + "title": "GIO\u015a (Inspecci\u00f3n Jefe de Protecci\u00f3n del Medio Ambiente de Polonia)" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/gios/translations/fr.json b/homeassistant/components/gios/translations/fr.json new file mode 100644 index 0000000000000..b06c41208bc37 --- /dev/null +++ b/homeassistant/components/gios/translations/fr.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "L'int\u00e9gration GIO\u015a pour cette station de mesure est d\u00e9j\u00e0 configur\u00e9e." + }, + "error": { + "cannot_connect": "Impossible de se connecter au serveur GIOS", + "invalid_sensors_data": "Donn\u00e9es des capteurs non valides pour cette station de mesure.", + "wrong_station_id": "L'identifiant de la station de mesure n'est pas correct." + }, + "step": { + "user": { + "data": { + "name": "Nom de l'int\u00e9gration", + "station_id": "Identifiant de la station de mesure" + }, + "description": "Mettre en place l'int\u00e9gration de la qualit\u00e9 de l'air GIO\u015a (Inspection g\u00e9n\u00e9rale polonaise de la protection de l'environnement). Si vous avez besoin d'aide pour la configuration, regardez ici: https://www.home-assistant.io/integrations/gios", + "title": "GIO\u015a (Inspection g\u00e9n\u00e9rale polonaise de la protection de l'environnement)" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/gios/translations/hu.json b/homeassistant/components/gios/translations/hu.json new file mode 100644 index 0000000000000..5702d3b33d265 --- /dev/null +++ b/homeassistant/components/gios/translations/hu.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "A GIO\u015a integr\u00e1ci\u00f3 ehhez a m\u00e9r\u0151\u00e1llom\u00e1shoz m\u00e1r konfigur\u00e1lva van." + }, + "error": { + "cannot_connect": "Nem lehet csatlakozni a GIO\u015a szerverhez.", + "invalid_sensors_data": "\u00c9rv\u00e9nytelen \u00e9rz\u00e9kel\u0151k adatai ehhez a m\u00e9r\u0151\u00e1llom\u00e1shoz.", + "wrong_station_id": "A m\u00e9r\u0151\u00e1llom\u00e1s azonos\u00edt\u00f3ja nem megfelel\u0151." + }, + "step": { + "user": { + "data": { + "name": "Az integr\u00e1ci\u00f3 neve", + "station_id": "A m\u00e9r\u0151\u00e1llom\u00e1s azonos\u00edt\u00f3ja" + }, + "description": "A GIO\u015a (lengyel k\u00f6rnyezetv\u00e9delmi f\u0151fel\u00fcgyel\u0151) leveg\u0151min\u0151s\u00e9gi integr\u00e1ci\u00f3j\u00e1nak be\u00e1ll\u00edt\u00e1sa. Ha seg\u00edts\u00e9gre van sz\u00fcks\u00e9ged a konfigur\u00e1ci\u00f3val kapcsolatban, l\u00e1togass ide: https://www.home-assistant.io/integrations/gios", + "title": "GIO\u015a (Lengyel K\u00f6rnyezetv\u00e9delmi F\u0151fel\u00fcgyel\u0151s\u00e9g)" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/gios/translations/it.json b/homeassistant/components/gios/translations/it.json new file mode 100644 index 0000000000000..e49fe2ebfe8d4 --- /dev/null +++ b/homeassistant/components/gios/translations/it.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "L'integrazione GIO\u015a per questa stazione di misurazione \u00e8 gi\u00e0 configurata." + }, + "error": { + "cannot_connect": "Impossibile connettersi al server GIO\u015a.", + "invalid_sensors_data": "Dati dei sensori non validi per questa stazione di misura.", + "wrong_station_id": "L'ID della stazione di misura non \u00e8 corretto." + }, + "step": { + "user": { + "data": { + "name": "Nome dell'integrazione", + "station_id": "ID della stazione di misura" + }, + "description": "Impostare l'integrazione della qualit\u00e0 dell'aria GIO\u015a (Ispettorato capo polacco di protezione ambientale). Se hai bisogno di aiuto con la configurazione dai un'occhiata qui: https://www.home-assistant.io/integrations/gios", + "title": "GIO\u015a (Ispettorato capo polacco di protezione ambientale)" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/gios/translations/ko.json b/homeassistant/components/gios/translations/ko.json new file mode 100644 index 0000000000000..2ad64efadc1de --- /dev/null +++ b/homeassistant/components/gios/translations/ko.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "\uc774 \uce21\uc815 \uc2a4\ud14c\uc774\uc158\uc5d0 \ub300\ud55c GIO\u015a \ud1b5\ud569 \uad6c\uc131\uc694\uc18c\ub294 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + }, + "error": { + "cannot_connect": "GIO\u015a \uc11c\ubc84\uc5d0 \uc5f0\uacb0\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4", + "invalid_sensors_data": "\uc774 \uce21\uc815 \uc2a4\ud14c\uc774\uc158\uc5d0 \ub300\ud55c \uc13c\uc11c \ub370\uc774\ud130\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", + "wrong_station_id": "\uce21\uc815 \uc2a4\ud14c\uc774\uc158\uc758 ID \uac00 \uc62c\ubc14\ub974\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4." + }, + "step": { + "user": { + "data": { + "name": "\ud1b5\ud569 \uad6c\uc131\uc694\uc18c\uc758 \uc774\ub984", + "station_id": "\uce21\uc815 \uc2a4\ud14c\uc774\uc158\uc758 ID" + }, + "description": "\ud3f4\ub780\ub4dc \ud658\uacbd\uccad (GIO\u015a) \ub300\uae30\uc9c8 \ud1b5\ud569 \uad6c\uc131\uc694\uc18c\ub97c \uc124\uc815\ud569\ub2c8\ub2e4. \uad6c\uc131\uc5d0 \ub3c4\uc6c0\uc774 \ud544\uc694\ud55c \uacbd\uc6b0 https://www.home-assistant.io/integrations/gios \ub97c \ucc38\uc870\ud574\uc8fc\uc138\uc694", + "title": "\ud3f4\ub780\ub4dc \ud658\uacbd\uccad (GIO\u015a)" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/gios/translations/lb.json b/homeassistant/components/gios/translations/lb.json new file mode 100644 index 0000000000000..66cd2393a22fc --- /dev/null +++ b/homeassistant/components/gios/translations/lb.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "GIO\u015a Integratioun fir d\u00ebs Miess Statioun ass scho konfigur\u00e9iert." + }, + "error": { + "cannot_connect": "Konnt sech net mam GIO\u015a Server verbannen.", + "invalid_sensors_data": "Ong\u00eblteg Sensor Donn\u00e9e\u00eb fir d\u00ebs Miess Statioun", + "wrong_station_id": "ID vun der Miess Statioun ass net korrekt." + }, + "step": { + "user": { + "data": { + "name": "Numm vun der Integratioun", + "station_id": "ID vun der Miess Statioun" + }, + "description": "GIO\u015a (Polnesch Chefinspektorat vum \u00cbmweltschutz) Loft Qualit\u00e9it Integratioun ariichten. Fir w\u00e9ider H\u00ebllef mat der Konfiuratioun kuckt hei: https://www.home-assistant.io/integrations/gios", + "title": "GIO\u015a (Polnesch Chefinspektorat vum \u00cbmweltschutz)" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/gios/translations/nl.json b/homeassistant/components/gios/translations/nl.json new file mode 100644 index 0000000000000..09fddb56225f7 --- /dev/null +++ b/homeassistant/components/gios/translations/nl.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "GIO\u015a-integratie voor dit meetstation is al geconfigureerd." + }, + "error": { + "cannot_connect": "Kan geen verbinding maken met de GIO\u015a-server.", + "invalid_sensors_data": "Ongeldige sensorgegevens voor dit meetstation.", + "wrong_station_id": "ID van het meetstation is niet correct." + }, + "step": { + "user": { + "data": { + "name": "Naam van de integratie", + "station_id": "ID van het meetstation" + }, + "description": "GIO\u015a (Poolse hoofdinspectie van milieubescherming) luchtkwaliteitintegratie instellen. Als u hulp nodig hebt bij de configuratie, kijk dan hier: https://www.home-assistant.io/integrations/gios", + "title": "GIO\u015a (Poolse hoofdinspectie van milieubescherming)" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/gios/translations/no.json b/homeassistant/components/gios/translations/no.json new file mode 100644 index 0000000000000..4b9183d9d87b8 --- /dev/null +++ b/homeassistant/components/gios/translations/no.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "GIO\u015a-integrasjon for denne m\u00e5lestasjonen er allerede konfigurert." + }, + "error": { + "cannot_connect": "Kan ikke koble til GIO\u015a-tjeneren", + "invalid_sensors_data": "Ugyldig sensordata for denne m\u00e5lestasjonen", + "wrong_station_id": "ID for m\u00e5lestasjon er ikke korrekt" + }, + "step": { + "user": { + "data": { + "name": "Navn p\u00e5 integrasjon", + "station_id": "ID til m\u00e5lestasjon" + }, + "description": "Sett opp GIO\u015a (Polish Chief Inspectorate Of Environmental Protection) luftkvalitet integrering. Hvis du trenger hjelp med konfigurasjonen ta en titt her: https://www.home-assistant.io/integrations/gios", + "title": "" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/gios/translations/pl.json b/homeassistant/components/gios/translations/pl.json new file mode 100644 index 0000000000000..4d4b07f31cc96 --- /dev/null +++ b/homeassistant/components/gios/translations/pl.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Integracja GIO\u015a dla tej stacji pomiarowej jest ju\u017c skonfigurowana." + }, + "error": { + "cannot_connect": "Nie mo\u017cna po\u0142\u0105czy\u0107 si\u0119 z serwerem GIO\u015a.", + "invalid_sensors_data": "Nieprawid\u0142owe dane sensor\u00f3w dla tej stacji pomiarowej.", + "wrong_station_id": "Identyfikator stacji pomiarowej nie jest prawid\u0142owy." + }, + "step": { + "user": { + "data": { + "name": "Nazwa integracji", + "station_id": "Identyfikator stacji pomiarowej" + }, + "description": "Konfiguracja integracji jako\u015bci powietrza GIO\u015a (G\u0142\u00f3wny Inspektorat Ochrony \u015arodowiska). Je\u015bli potrzebujesz pomocy z konfiguracj\u0105, przejd\u017a na stron\u0119: https://www.home-assistant.io/integrations/gios", + "title": "G\u0142\u00f3wny Inspektorat Ochrony \u015arodowiska (GIO\u015a)" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/gios/translations/pt-BR.json b/homeassistant/components/gios/translations/pt-BR.json new file mode 100644 index 0000000000000..83add749e4710 --- /dev/null +++ b/homeassistant/components/gios/translations/pt-BR.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "user": { + "title": "GIO\u015a (Inspetor-Chefe Polon\u00eas de Prote\u00e7\u00e3o Ambiental)" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/gios/translations/ru.json b/homeassistant/components/gios/translations/ru.json new file mode 100644 index 0000000000000..ca94b617c9374 --- /dev/null +++ b/homeassistant/components/gios/translations/ru.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430." + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a \u0441\u0435\u0440\u0432\u0435\u0440\u0443 GIO\u015a.", + "invalid_sensors_data": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435 \u0441\u0435\u043d\u0441\u043e\u0440\u043e\u0432 \u0434\u043b\u044f \u044d\u0442\u043e\u0439 \u0438\u0437\u043c\u0435\u0440\u0438\u0442\u0435\u043b\u044c\u043d\u043e\u0439 \u0441\u0442\u0430\u043d\u0446\u0438\u0438.", + "wrong_station_id": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 ID \u0438\u0437\u043c\u0435\u0440\u0438\u0442\u0435\u043b\u044c\u043d\u043e\u0439 \u0441\u0442\u0430\u043d\u0446\u0438\u0438." + }, + "step": { + "user": { + "data": { + "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438", + "station_id": "ID \u0438\u0437\u043c\u0435\u0440\u0438\u0442\u0435\u043b\u044c\u043d\u043e\u0439 \u0441\u0442\u0430\u043d\u0446\u0438\u0438" + }, + "description": "\u0418\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044f \u043e \u043a\u0430\u0447\u0435\u0441\u0442\u0432\u0435 \u0432\u043e\u0437\u0434\u0443\u0445\u0430 \u043e\u0442 \u041f\u043e\u043b\u044c\u0441\u043a\u043e\u0439 \u0438\u043d\u0441\u043f\u0435\u043a\u0446\u0438\u0438 \u043f\u043e \u043e\u0445\u0440\u0430\u043d\u0435 \u043e\u043a\u0440\u0443\u0436\u0430\u044e\u0449\u0435\u0439 \u0441\u0440\u0435\u0434\u044b (GIO\u015a). \u041e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 \u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u0435\u0439 \u043f\u043e \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0435 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438: https://www.home-assistant.io/integrations/gios.", + "title": "GIO\u015a (\u041f\u043e\u043b\u044c\u0441\u043a\u0430\u044f \u0438\u043d\u0441\u043f\u0435\u043a\u0446\u0438\u044f \u043f\u043e \u043e\u0445\u0440\u0430\u043d\u0435 \u043e\u043a\u0440\u0443\u0436\u0430\u044e\u0449\u0435\u0439 \u0441\u0440\u0435\u0434\u044b)" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/gios/translations/sl.json b/homeassistant/components/gios/translations/sl.json new file mode 100644 index 0000000000000..f01728783cc6e --- /dev/null +++ b/homeassistant/components/gios/translations/sl.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "GIO\u015a integracija za to merilno postajo je \u017ee nastavljena." + }, + "error": { + "cannot_connect": "Ne morem se povezati s stre\u017enikom GIO\u015a.", + "invalid_sensors_data": "Neveljavni podatki senzorjev za to merilno postajo.", + "wrong_station_id": "ID merilne postaje ni pravilen." + }, + "step": { + "user": { + "data": { + "name": "Ime integracije", + "station_id": "ID merilne postaje" + }, + "description": "Nastavite GIO\u015a (poljski glavni in\u0161pektorat za varstvo okolja) integracijo kakovosti zraka. \u010ce potrebujete pomo\u010d pri konfiguraciji si oglejte tukaj: https://www.home-assistant.io/integrations/gios", + "title": "GIO\u015a (glavni poljski in\u0161pektorat za varstvo okolja)" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/gios/translations/sv.json b/homeassistant/components/gios/translations/sv.json new file mode 100644 index 0000000000000..a8bafa50119bc --- /dev/null +++ b/homeassistant/components/gios/translations/sv.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "GIO\u015a-integration f\u00f6r denna m\u00e4tstation \u00e4r redan konfigurerad." + }, + "error": { + "cannot_connect": "Det g\u00e5r inte att ansluta till GIO\u015a-servern.", + "invalid_sensors_data": "Ogiltig sensordata f\u00f6r denna m\u00e4tstation.", + "wrong_station_id": "M\u00e4tstationens ID \u00e4r inte korrekt." + }, + "step": { + "user": { + "data": { + "name": "Integrationens namn", + "station_id": "M\u00e4tstationens ID" + }, + "description": "St\u00e4ll in luftkvalitetintegration f\u00f6r GIO\u015a (polsk chefinspektorat f\u00f6r milj\u00f6skydd). Om du beh\u00f6ver hj\u00e4lp med konfigurationen titta h\u00e4r: https://www.home-assistant.io/integrations/gios", + "title": "GIO\u015a (Polish Chief Inspectorate Of Environmental Protection)" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/gios/translations/zh-Hant.json b/homeassistant/components/gios/translations/zh-Hant.json new file mode 100644 index 0000000000000..0d75f83f9e529 --- /dev/null +++ b/homeassistant/components/gios/translations/zh-Hant.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "\u6b64 GIO\u015a \u76e3\u6e2c\u7ad9\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210\u3002" + }, + "error": { + "cannot_connect": "\u7121\u6cd5\u9023\u7dda\u81f3 GIO\u015a \u4f3a\u670d\u5668\u3002", + "invalid_sensors_data": "\u6b64\u76e3\u6e2c\u7ad9\u50b3\u611f\u5668\u8cc7\u6599\u7121\u6548\u3002", + "wrong_station_id": "\u76e3\u6e2c\u7ad9 ID \u4e0d\u6b63\u78ba\u3002" + }, + "step": { + "user": { + "data": { + "name": "\u6574\u5408\u540d\u7a31", + "station_id": "\u76e3\u6e2c\u7ad9 ID" + }, + "description": "\u8a2d\u5b9a GIO\u015a\uff08\u6ce2\u862d\u7e3d\u74b0\u5883\u4fdd\u8b77\u7763\u5bdf\u8655\uff09\u7a7a\u6c23\u54c1\u8cea\u6574\u5408\u3002\u5047\u5982\u9700\u8981\u5354\u52a9\uff0c\u8acb\u53c3\u8003\uff1ahttps://www.home-assistant.io/integrations/gios", + "title": "GIO\u015a\uff08\u6ce2\u862d\u7e3d\u74b0\u5883\u4fdd\u8b77\u7763\u5bdf\u8655\uff09" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/github/manifest.json b/homeassistant/components/github/manifest.json index a2c2ae04376bd..1a9cd620b0e8f 100644 --- a/homeassistant/components/github/manifest.json +++ b/homeassistant/components/github/manifest.json @@ -1,10 +1,7 @@ { "domain": "github", - "name": "Github", - "documentation": "https://www.home-assistant.io/components/github", - "requirements": [ - "PyGithub==1.43.5" - ], - "dependencies": [], + "name": "GitHub", + "documentation": "https://www.home-assistant.io/integrations/github", + "requirements": ["PyGithub==1.43.8"], "codeowners": [] } diff --git a/homeassistant/components/github/sensor.py b/homeassistant/components/github/sensor.py index d552d2c65ccc2..dcd81dc68df68 100644 --- a/homeassistant/components/github/sensor.py +++ b/homeassistant/components/github/sensor.py @@ -1,43 +1,56 @@ """Support for GitHub.""" from datetime import timedelta import logging + +import github 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) + 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' +CONF_REPOS = "repositories" + +ATTR_LATEST_COMMIT_MESSAGE = "latest_commit_message" +ATTR_LATEST_COMMIT_SHA = "latest_commit_sha" +ATTR_LATEST_RELEASE_TAG = "latest_release_tag" +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" +ATTR_FORKS = "forks" +ATTR_CLONES = "clones" +ATTR_CLONES_UNIQUE = "clones_unique" +ATTR_VIEWS = "views" +ATTR_VIEWS_UNIQUE = "views_unique" + +DEFAULT_NAME = "GitHub" SCAN_INTERVAL = timedelta(seconds=300) -REPO_SCHEMA = vol.Schema({ - vol.Required(CONF_PATH): cv.string, - vol.Optional(CONF_NAME): cv.string -}) +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]) -}) +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): @@ -47,13 +60,15 @@ def setup_platform(hass, config, add_entities, discovery_info=None): data = GitHubData( repository=repository, access_token=config.get(CONF_ACCESS_TOKEN), - server_url=config.get(CONF_URL) + 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)) + _LOGGER.error( + "Error setting up GitHub platform. %s", + "Check previous errors for details", + ) + else: + sensors.append(GitHubSensor(data)) add_entities(sensors, True) @@ -69,12 +84,18 @@ def __init__(self, github_data): self._repository_path = None self._latest_commit_message = None self._latest_commit_sha = None + self._latest_release_tag = 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._forks = None + self._clones = None + self._clones_unique = None + self._views = None + self._views_unique = None self._github_data = github_data @property @@ -100,7 +121,7 @@ def available(self): @property def device_state_attributes(self): """Return the state attributes.""" - return { + attrs = { ATTR_PATH: self._repository_path, ATTR_NAME: self._name, ATTR_LATEST_COMMIT_MESSAGE: self._latest_commit_message, @@ -110,39 +131,60 @@ def device_state_attributes(self): 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 + ATTR_STARGAZERS: self._stargazers, + ATTR_FORKS: self._forks, } + if self._latest_release_tag is not None: + attrs[ATTR_LATEST_RELEASE_TAG] = self._latest_release_tag + if self._clones is not None: + attrs[ATTR_CLONES] = self._clones + if self._clones_unique is not None: + attrs[ATTR_CLONES_UNIQUE] = self._clones_unique + if self._views is not None: + attrs[ATTR_VIEWS] = self._views + if self._views_unique is not None: + attrs[ATTR_VIEWS_UNIQUE] = self._views_unique + return attrs @property def icon(self): """Return the icon to use in the frontend.""" - return 'mdi:github-circle' + 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 + if self._github_data.latest_release_url is not None: + self._latest_release_tag = self._github_data.latest_release_url.split( + "tag/" + )[1] + else: + self._latest_release_tag = None self._latest_release_url = self._github_data.latest_release_url + self._state = self._github_data.latest_commit_sha[0:7] 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 + self._forks = self._github_data.forks + self._clones = self._github_data.clones + self._clones_unique = self._github_data.clones_unique + self._views = self._github_data.views + self._views_unique = self._github_data.views_unique -class GitHubData(): +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 @@ -150,8 +192,7 @@ def __init__(self, repository, access_token=None, server_url=None): try: if server_url is not None: server_url += "/api/v3" - self._github_obj = github.Github( - access_token, base_url=server_url) + self._github_obj = github.Github(access_token, base_url=server_url) else: self._github_obj = github.Github(access_token) @@ -164,7 +205,6 @@ def __init__(self, repository, access_token=None, server_url=None): return self.name = repository.get(CONF_NAME, repo.name) - self.available = False self.latest_commit_message = None self.latest_commit_sha = None @@ -174,6 +214,11 @@ def __init__(self, repository, access_token=None, server_url=None): self.pull_request_count = None self.latest_open_pr_url = None self.stargazers = None + self.forks = None + self.clones = None + self.clones_unique = None + self.views = None + self.views_unique = None def update(self): """Update GitHub Sensor.""" @@ -181,14 +226,15 @@ def update(self): repo = self._github_obj.get_repo(self.repository_path) self.stargazers = repo.stargazers_count + self.forks = repo.forks_count - open_issues = repo.get_issues(state='open', sort='created') + 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') + 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: @@ -202,6 +248,17 @@ def update(self): if releases and releases.totalCount > 0: self.latest_release_url = releases[0].html_url + if repo.permissions.push: + clones = repo.get_clones_traffic() + if clones is not None: + self.clones = clones.get("count") + self.clones_unique = clones.get("uniques") + + views = repo.get_views_traffic() + if views is not None: + self.views = views.get("count") + self.views_unique = views.get("uniques") + self.available = True except self._github.GithubException as err: _LOGGER.error("GitHub error for %s: %s", self.repository_path, err) diff --git a/homeassistant/components/gitlab_ci/manifest.json b/homeassistant/components/gitlab_ci/manifest.json index 4ea04de9e0239..5061d35c18903 100644 --- a/homeassistant/components/gitlab_ci/manifest.json +++ b/homeassistant/components/gitlab_ci/manifest.json @@ -1,10 +1,7 @@ { "domain": "gitlab_ci", - "name": "Gitlab ci", - "documentation": "https://www.home-assistant.io/components/gitlab_ci", - "requirements": [ - "python-gitlab==1.6.0" - ], - "dependencies": [], + "name": "GitLab-CI", + "documentation": "https://www.home-assistant.io/integrations/gitlab_ci", + "requirements": ["python-gitlab==1.6.0"], "codeowners": [] } diff --git a/homeassistant/components/gitlab_ci/sensor.py b/homeassistant/components/gitlab_ci/sensor.py index 54cbf34fdfc2c..9edbe9733a899 100644 --- a/homeassistant/components/gitlab_ci/sensor.py +++ b/homeassistant/components/gitlab_ci/sensor.py @@ -2,44 +2,52 @@ from datetime import timedelta import logging +from gitlab import Gitlab, GitlabAuthenticationError, GitlabGetError 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) + 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' +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' +CONF_GITLAB_ID = "gitlab_id" -DEFAULT_NAME = 'GitLab CI Status' -DEFAULT_URL = 'https://gitlab.com' +DEFAULT_NAME = "GitLab CI Status" +DEFAULT_URL = "https://gitlab.com" -ICON_HAPPY = 'mdi:emoticon-happy' -ICON_OTHER = 'mdi:git' -ICON_SAD = 'mdi:emoticon-happy' +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 -}) +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): @@ -52,7 +60,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): priv_token=config[CONF_TOKEN], gitlab_id=config[CONF_GITLAB_ID], interval=_interval, - url=_url + url=_url, ) add_entities([GitLabSensor(_gitlab_data, _name)], True) @@ -102,15 +110,15 @@ def device_state_attributes(self): 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 + ATTR_BUILD_BRANCH: self._branch, } @property def icon(self): """Return the icon to use in the frontend.""" - if self._state == 'success': + if self._state == "success": return ICON_HAPPY - if self._state == 'failed': + if self._state == "failed": return ICON_SAD return ICON_OTHER @@ -129,17 +137,15 @@ def update(self): self._available = self._gitlab_data.available -class GitLabData(): +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 = 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 @@ -157,19 +163,19 @@ def _update(self): _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.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: + except GitlabAuthenticationError as erra: _LOGGER.error("Authentication Error: %s", erra) self.available = False - except self._gitlab_exceptions.GitlabGetError as errg: + except GitlabGetError as errg: _LOGGER.error("Project Not Found: %s", errg) self.available = False diff --git a/homeassistant/components/gitter/manifest.json b/homeassistant/components/gitter/manifest.json index 6600e46a4ce95..c1c13af792a1d 100644 --- a/homeassistant/components/gitter/manifest.json +++ b/homeassistant/components/gitter/manifest.json @@ -1,12 +1,7 @@ { "domain": "gitter", "name": "Gitter", - "documentation": "https://www.home-assistant.io/components/gitter", - "requirements": [ - "gitterpy==0.1.7" - ], - "dependencies": [], - "codeowners": [ - "@fabaff" - ] + "documentation": "https://www.home-assistant.io/integrations/gitter", + "requirements": ["gitterpy==0.1.7"], + "codeowners": ["@fabaff"] } diff --git a/homeassistant/components/gitter/sensor.py b/homeassistant/components/gitter/sensor.py index 06fb6e3a3b544..4f1eeca7d7190 100644 --- a/homeassistant/components/gitter/sensor.py +++ b/homeassistant/components/gitter/sensor.py @@ -1,6 +1,8 @@ """Support for displaying details about a Gitter.im chat room.""" import logging +from gitterpy.client import GitterClient +from gitterpy.errors import GitterRoomError, GitterTokenError import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA @@ -10,26 +12,26 @@ _LOGGER = logging.getLogger(__name__) -ATTR_MENTION = 'mention' -ATTR_ROOM = 'room' -ATTR_USERNAME = 'username' +ATTR_MENTION = "mention" +ATTR_ROOM = "room" +ATTR_USERNAME = "username" -DEFAULT_NAME = 'Gitter messages' -DEFAULT_ROOM = 'home-assistant/home-assistant' +DEFAULT_NAME = "Gitter messages" +DEFAULT_ROOM = "home-assistant/home-assistant" -ICON = 'mdi:message-settings-variant' +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, -}) +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) @@ -37,7 +39,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): gitter = GitterClient(api_key) try: - username = gitter.auth.get_my_id['name'] + username = gitter.auth.get_my_id["name"] except GitterTokenError: _LOGGER.error("Token is not valid") return @@ -56,7 +58,7 @@ def __init__(self, data, room, name, username): self._username = username self._state = None self._mention = 0 - self._unit_of_measurement = 'Msg' + self._unit_of_measurement = "Msg" @property def name(self): @@ -89,7 +91,6 @@ def icon(self): 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) @@ -97,8 +98,8 @@ def update(self): _LOGGER.error(error) return - if 'error' not in data.keys(): - self._mention = len(data['mention']) - self._state = len(data['chat']) + 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 index b458d8788fcf7..d09aa78253474 100644 --- a/homeassistant/components/glances/__init__.py +++ b/homeassistant/components/glances/__init__.py @@ -1 +1,174 @@ -"""The glances component.""" +"""The Glances component.""" +from datetime import timedelta +import logging + +from glances_api import Glances, exceptions +import voluptuous as vol + +from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_SCAN_INTERVAL, + CONF_SSL, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +from homeassistant.core import Config, HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import async_track_time_interval + +from .const import ( + CONF_VERSION, + DATA_UPDATED, + DEFAULT_HOST, + DEFAULT_NAME, + DEFAULT_PORT, + DEFAULT_SCAN_INTERVAL, + DEFAULT_VERSION, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + +GLANCES_SCHEMA = vol.All( + vol.Schema( + { + 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_VERSION, default=DEFAULT_VERSION): vol.In([2, 3]), + } + ) +) + +CONFIG_SCHEMA = vol.Schema( + {DOMAIN: vol.All(cv.ensure_list, [GLANCES_SCHEMA])}, extra=vol.ALLOW_EXTRA +) + + +async def async_setup(hass: HomeAssistant, config: Config) -> bool: + """Configure Glances using config flow only.""" + if DOMAIN in config: + for entry in config[DOMAIN]: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=entry + ) + ) + + return True + + +async def async_setup_entry(hass, config_entry): + """Set up Glances from config entry.""" + client = GlancesData(hass, config_entry) + hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = client + if not await client.async_setup(): + return False + + return True + + +async def async_unload_entry(hass, config_entry): + """Unload a config entry.""" + await hass.config_entries.async_forward_entry_unload(config_entry, "sensor") + hass.data[DOMAIN].pop(config_entry.entry_id) + return True + + +class GlancesData: + """Get the latest data from Glances api.""" + + def __init__(self, hass, config_entry): + """Initialize the Glances data.""" + self.hass = hass + self.config_entry = config_entry + self.api = None + self.unsub_timer = None + self.available = False + + @property + def host(self): + """Return client host.""" + return self.config_entry.data[CONF_HOST] + + async def async_update(self): + """Get the latest data from the Glances REST API.""" + try: + await self.api.get_data() + self.available = True + except exceptions.GlancesApiError: + _LOGGER.error("Unable to fetch data from Glances") + self.available = False + _LOGGER.debug("Glances data updated") + async_dispatcher_send(self.hass, DATA_UPDATED) + + async def async_setup(self): + """Set up the Glances client.""" + try: + self.api = get_api(self.hass, self.config_entry.data) + await self.api.get_data() + self.available = True + _LOGGER.debug("Successfully connected to Glances") + + except exceptions.GlancesApiConnectionError: + _LOGGER.debug("Can not connect to Glances") + raise ConfigEntryNotReady + + self.add_options() + self.set_scan_interval(self.config_entry.options[CONF_SCAN_INTERVAL]) + self.config_entry.add_update_listener(self.async_options_updated) + + self.hass.async_create_task( + self.hass.config_entries.async_forward_entry_setup( + self.config_entry, "sensor" + ) + ) + return True + + def add_options(self): + """Add options for Glances integration.""" + if not self.config_entry.options: + options = {CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL} + self.hass.config_entries.async_update_entry( + self.config_entry, options=options + ) + + def set_scan_interval(self, scan_interval): + """Update scan interval.""" + + async def refresh(event_time): + """Get the latest data from Glances api.""" + await self.async_update() + + if self.unsub_timer is not None: + self.unsub_timer() + self.unsub_timer = async_track_time_interval( + self.hass, refresh, timedelta(seconds=scan_interval) + ) + + @staticmethod + async def async_options_updated(hass, entry): + """Triggered by config entry options updates.""" + hass.data[DOMAIN][entry.entry_id].set_scan_interval( + entry.options[CONF_SCAN_INTERVAL] + ) + + +def get_api(hass, entry): + """Return the api from glances_api.""" + params = entry.copy() + params.pop(CONF_NAME) + verify_ssl = params.pop(CONF_VERIFY_SSL) + session = async_get_clientsession(hass, verify_ssl) + return Glances(hass.loop, session, **params) diff --git a/homeassistant/components/glances/config_flow.py b/homeassistant/components/glances/config_flow.py new file mode 100644 index 0000000000000..3c86fae035735 --- /dev/null +++ b/homeassistant/components/glances/config_flow.py @@ -0,0 +1,130 @@ +"""Config flow for Glances.""" +import glances_api +import voluptuous as vol + +from homeassistant import config_entries, core, exceptions +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_SCAN_INTERVAL, + CONF_SSL, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +from homeassistant.core import callback + +from . import get_api +from .const import ( + CONF_VERSION, + DEFAULT_HOST, + DEFAULT_NAME, + DEFAULT_PORT, + DEFAULT_SCAN_INTERVAL, + DEFAULT_VERSION, + DOMAIN, + SUPPORTED_VERSIONS, +) + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_NAME, default=DEFAULT_NAME): str, + vol.Required(CONF_HOST, default=DEFAULT_HOST): str, + vol.Optional(CONF_USERNAME): str, + vol.Optional(CONF_PASSWORD): str, + vol.Required(CONF_PORT, default=DEFAULT_PORT): int, + vol.Required(CONF_VERSION, default=DEFAULT_VERSION): int, + vol.Optional(CONF_SSL, default=False): bool, + vol.Optional(CONF_VERIFY_SSL, default=False): bool, + } +) + + +async def validate_input(hass: core.HomeAssistant, data): + """Validate the user input allows us to connect.""" + for entry in hass.config_entries.async_entries(DOMAIN): + if entry.data[CONF_HOST] == data[CONF_HOST]: + raise AlreadyConfigured + + if data[CONF_VERSION] not in SUPPORTED_VERSIONS: + raise WrongVersion + try: + api = get_api(hass, data) + await api.get_data() + except glances_api.exceptions.GlancesApiConnectionError: + raise CannotConnect + + +class GlancesFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a Glances config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get the options flow for this handler.""" + return GlancesOptionsFlowHandler(config_entry) + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + if user_input is not None: + try: + await validate_input(self.hass, user_input) + return self.async_create_entry( + title=user_input[CONF_NAME], data=user_input + ) + except AlreadyConfigured: + return self.async_abort(reason="already_configured") + except CannotConnect: + errors["base"] = "cannot_connect" + except WrongVersion: + errors[CONF_VERSION] = "wrong_version" + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + async def async_step_import(self, import_config): + """Import from Glances sensor config.""" + + return await self.async_step_user(user_input=import_config) + + +class GlancesOptionsFlowHandler(config_entries.OptionsFlow): + """Handle Glances client options.""" + + def __init__(self, config_entry): + """Initialize Glances options flow.""" + self.config_entry = config_entry + + async def async_step_init(self, user_input=None): + """Manage the Glances options.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + options = { + vol.Optional( + CONF_SCAN_INTERVAL, + default=self.config_entry.options.get( + CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL + ), + ): int + } + + return self.async_show_form(step_id="init", data_schema=vol.Schema(options)) + + +class CannotConnect(exceptions.HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class AlreadyConfigured(exceptions.HomeAssistantError): + """Error to indicate host is already configured.""" + + +class WrongVersion(exceptions.HomeAssistantError): + """Error to indicate the selected version is wrong.""" diff --git a/homeassistant/components/glances/const.py b/homeassistant/components/glances/const.py new file mode 100644 index 0000000000000..53dc635204903 --- /dev/null +++ b/homeassistant/components/glances/const.py @@ -0,0 +1,46 @@ +"""Constants for Glances component.""" +from homeassistant.const import ( + DATA_GIBIBYTES, + DATA_MEBIBYTES, + TEMP_CELSIUS, + UNIT_PERCENTAGE, +) + +DOMAIN = "glances" +CONF_VERSION = "version" + +DEFAULT_HOST = "localhost" +DEFAULT_NAME = "Glances" +DEFAULT_PORT = 61208 +DEFAULT_VERSION = 3 +DEFAULT_SCAN_INTERVAL = 60 + +DATA_UPDATED = "glances_data_updated" +SUPPORTED_VERSIONS = [2, 3] + +SENSOR_TYPES = { + "disk_use_percent": ["fs", "used percent", UNIT_PERCENTAGE, "mdi:harddisk"], + "disk_use": ["fs", "used", DATA_GIBIBYTES, "mdi:harddisk"], + "disk_free": ["fs", "free", DATA_GIBIBYTES, "mdi:harddisk"], + "memory_use_percent": ["mem", "RAM used percent", UNIT_PERCENTAGE, "mdi:memory"], + "memory_use": ["mem", "RAM used", DATA_MEBIBYTES, "mdi:memory"], + "memory_free": ["mem", "RAM free", DATA_MEBIBYTES, "mdi:memory"], + "swap_use_percent": ["memswap", "Swap used percent", UNIT_PERCENTAGE, "mdi:memory"], + "swap_use": ["memswap", "Swap used", DATA_GIBIBYTES, "mdi:memory"], + "swap_free": ["memswap", "Swap free", DATA_GIBIBYTES, "mdi:memory"], + "processor_load": ["load", "CPU load", "15 min", "mdi:memory"], + "process_running": ["processcount", "Running", "Count", "mdi:memory"], + "process_total": ["processcount", "Total", "Count", "mdi:memory"], + "process_thread": ["processcount", "Thread", "Count", "mdi:memory"], + "process_sleeping": ["processcount", "Sleeping", "Count", "mdi:memory"], + "cpu_use_percent": ["cpu", "CPU used", UNIT_PERCENTAGE, "mdi:memory"], + "sensor_temp": ["sensors", "Temp", TEMP_CELSIUS, "mdi:thermometer"], + "docker_active": ["docker", "Containers active", "", "mdi:docker"], + "docker_cpu_use": ["docker", "Containers CPU used", UNIT_PERCENTAGE, "mdi:docker"], + "docker_memory_use": [ + "docker", + "Containers RAM used", + DATA_MEBIBYTES, + "mdi:docker", + ], +} diff --git a/homeassistant/components/glances/manifest.json b/homeassistant/components/glances/manifest.json index 621bca8c4309a..b50601ae835e5 100644 --- a/homeassistant/components/glances/manifest.json +++ b/homeassistant/components/glances/manifest.json @@ -1,12 +1,8 @@ { "domain": "glances", "name": "Glances", - "documentation": "https://www.home-assistant.io/components/glances", - "requirements": [ - "glances_api==0.2.0" - ], - "dependencies": [], - "codeowners": [ - "@fabaff" - ] + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/glances", + "requirements": ["glances_api==0.2.0"], + "codeowners": ["@fabaff", "@engrbm87"] } diff --git a/homeassistant/components/glances/sensor.py b/homeassistant/components/glances/sensor.py index 534b4c5cd59c5..f701dfdb741a6 100644 --- a/homeassistant/components/glances/sensor.py +++ b/homeassistant/components/glances/sensor.py @@ -1,94 +1,64 @@ """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.const import CONF_NAME, STATE_UNAVAILABLE +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect 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] +from .const import DATA_UPDATED, DOMAIN, SENSOR_TYPES - 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)) +_LOGGER = logging.getLogger(__name__) - await glances.async_update() - if glances.api.data is None: - raise PlatformNotReady +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Glances sensors.""" + client = hass.data[DOMAIN][config_entry.entry_id] + name = config_entry.data[CONF_NAME] dev = [] - for resource in var_conf: - dev.append(GlancesSensor(glances, name, resource)) + + for sensor_type, sensor_details in SENSOR_TYPES.items(): + if not sensor_details[0] in client.api.data: + continue + if sensor_details[0] in client.api.data: + if sensor_details[0] == "fs": + # fs will provide a list of disks attached + for disk in client.api.data[sensor_details[0]]: + dev.append( + GlancesSensor( + client, + name, + disk["mnt_point"], + SENSOR_TYPES[sensor_type][1], + sensor_type, + SENSOR_TYPES[sensor_type], + ) + ) + elif sensor_details[0] == "sensors": + # sensors will provide temp for different devices + for sensor in client.api.data[sensor_details[0]]: + dev.append( + GlancesSensor( + client, + name, + sensor["label"], + SENSOR_TYPES[sensor_type][1], + sensor_type, + SENSOR_TYPES[sensor_type], + ) + ) + elif client.api.data[sensor_details[0]]: + dev.append( + GlancesSensor( + client, + name, + "", + SENSOR_TYPES[sensor_type][1], + sensor_type, + SENSOR_TYPES[sensor_type], + ) + ) async_add_entities(dev, True) @@ -96,139 +66,165 @@ async def async_setup_platform( class GlancesSensor(Entity): """Implementation of a Glances sensor.""" - def __init__(self, glances, name, sensor_type): + def __init__( + self, + glances_data, + name, + sensor_name_prefix, + sensor_name_suffix, + sensor_type, + sensor_details, + ): """Initialize the sensor.""" - self.glances = glances + self.glances_data = glances_data + self._sensor_name_prefix = sensor_name_prefix + self._sensor_name_suffix = sensor_name_suffix self._name = name self.type = sensor_type self._state = None - self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] + self.sensor_details = sensor_details + self.unsub_update = None @property def name(self): """Return the name of the sensor.""" - return '{} {}'.format(self._name, SENSOR_TYPES[self.type][0]) + return f"{self._name} {self._sensor_name_prefix} {self._sensor_name_suffix}" + + @property + def unique_id(self): + """Set unique_id for sensor.""" + return f"{self.glances_data.host}-{self.name}" @property def icon(self): """Icon to use in the frontend, if any.""" - return SENSOR_TYPES[self.type][2] + return self.sensor_details[3] @property def unit_of_measurement(self): """Return the unit the value is expressed in.""" - return self._unit_of_measurement + return self.sensor_details[2] @property def available(self): """Could the device be accessed during the last update call.""" - return self.glances.available + return self.glances_data.available @property def state(self): """Return the state of the resources.""" return self._state + @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.""" + self.unsub_update = async_dispatcher_connect( + self.hass, DATA_UPDATED, self._schedule_immediate_update + ) + + @callback + def _schedule_immediate_update(self): + self.async_schedule_update_ha_state(True) + + async def will_remove_from_hass(self): + """Unsubscribe from update dispatcher.""" + if self.unsub_update: + self.unsub_update() + self.unsub_update = None + async def async_update(self): """Get the latest data from REST API.""" - await self.glances.async_update() - value = self.glances.api.data + value = self.glances_data.api.data + if value is None: + return 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': + if self.sensor_details[0] == "fs": + for var in value["fs"]: + if var["mnt_point"] == self._sensor_name_prefix: + disk = var + break + if self.type == "disk_use_percent": + self._state = disk["percent"] + elif self.type == "disk_use": + self._state = round(disk["used"] / 1024 ** 3, 1) + elif self.type == "disk_free": + try: + self._state = round(disk["free"] / 1024 ** 3, 1) + except KeyError: + self._state = round( + (disk["size"] - disk["used"]) / 1024 ** 3, 1, + ) + elif self.type == "sensor_temp": + for sensor in value["sensors"]: + if sensor["label"] == self._sensor_name_prefix: + self._state = sensor["value"] + break + 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'] + 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': + 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 == "docker_active": count = 0 try: - for container in value['docker']['containers']: - if container['Status'] == 'running' or \ - 'Up' in container['Status']: + 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': + 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'] + 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': + 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) + 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/glances/strings.json b/homeassistant/components/glances/strings.json new file mode 100644 index 0000000000000..ae8ab0357f312 --- /dev/null +++ b/homeassistant/components/glances/strings.json @@ -0,0 +1,32 @@ +{ + "config": { + "step": { + "user": { + "title": "Setup Glances", + "data": { + "name": "Name", + "host": "Host", + "username": "Username", + "password": "Password", + "port": "Port", + "version": "Glances API Version (2 or 3)", + "ssl": "Use SSL/TLS to connect to the Glances system", + "verify_ssl": "Verify the certification of the system" + } + } + }, + "error": { + "cannot_connect": "Unable to connect to host", + "wrong_version": "Version not supported (2 or 3 only)" + }, + "abort": { "already_configured": "Host is already configured." } + }, + "options": { + "step": { + "init": { + "description": "Configure options for Glances", + "data": { "scan_interval": "Update frequency" } + } + } + } +} diff --git a/homeassistant/components/glances/translations/bg.json b/homeassistant/components/glances/translations/bg.json new file mode 100644 index 0000000000000..ef60201a57f12 --- /dev/null +++ b/homeassistant/components/glances/translations/bg.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "\u0410\u0434\u0440\u0435\u0441\u044a\u0442 \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d." + }, + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435 \u0441 \u0430\u0434\u0440\u0435\u0441\u0430", + "wrong_version": "\u0412\u0435\u0440\u0441\u0438\u044f\u0442\u0430 \u043d\u0435 \u0441\u0435 \u043f\u043e\u0434\u0434\u044a\u0440\u0436\u0430 (\u043f\u043e\u0434\u0434\u044a\u0440\u0436\u0430\u043d\u0438 \u0432\u0435\u0440\u0441\u0438\u0438: 2 \u0438\u043b\u0438 3)" + }, + "step": { + "user": { + "data": { + "host": "\u0410\u0434\u0440\u0435\u0441", + "name": "\u0418\u043c\u0435", + "password": "\u041f\u0430\u0440\u043e\u043b\u0430", + "port": "\u041f\u043e\u0440\u0442", + "ssl": "\u0418\u0437\u043f\u043e\u043b\u0437\u0432\u0430\u0439\u0442\u0435 SSL/TLS, \u0437\u0430 \u0434\u0430 \u0441\u0435 \u0441\u0432\u044a\u0440\u0436\u0435\u0442\u0435 \u043a\u044a\u043c \u0441\u0438\u0441\u0442\u0435\u043c\u0430\u0442\u0430 Glances", + "username": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435", + "verify_ssl": "\u041f\u0440\u043e\u0432\u0435\u0440\u043a\u0430 \u043d\u0430 \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u0446\u0438\u0440\u0430\u043d\u0435\u0442\u043e \u043d\u0430 \u0441\u0438\u0441\u0442\u0435\u043c\u0430\u0442\u0430", + "version": "Glances API \u0432\u0435\u0440\u0441\u0438\u044f (2 \u0438\u043b\u0438 3)" + }, + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043d\u0430 Glances" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "\u0427\u0435\u0441\u0442\u043e\u0442\u0430 \u043d\u0430 \u0430\u043a\u0442\u0443\u0430\u043b\u0438\u0437\u0438\u0440\u0430\u043d\u0435" + }, + "description": "\u041a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u0435 \u043d\u0430 \u043e\u043f\u0446\u0438\u0438 \u0437\u0430 Glances" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/glances/translations/ca.json b/homeassistant/components/glances/translations/ca.json new file mode 100644 index 0000000000000..7da63024f8b39 --- /dev/null +++ b/homeassistant/components/glances/translations/ca.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "L'amfitri\u00f3 ja est\u00e0 configurat." + }, + "error": { + "cannot_connect": "No s'ha pogut connectar amb l'amfitri\u00f3", + "wrong_version": "Versi\u00f3 no compatible (2 o 3 necess\u00e0ria)" + }, + "step": { + "user": { + "data": { + "host": "Amfitri\u00f3", + "name": "Nom", + "password": "Contrasenya", + "port": "Port", + "ssl": "Utilitza SSL/TLS per connectar-te al sistema Glances", + "username": "Nom d'usuari", + "verify_ssl": "Verifica la certificaci\u00f3 del sistema", + "version": "Versi\u00f3 de l'API de Glances (2 o 3)" + }, + "title": "Configuraci\u00f3 de Glances" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Freq\u00fc\u00e8ncia d'actualitzaci\u00f3" + }, + "description": "Opcions de configuraci\u00f3 de Glances" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/glances/translations/da.json b/homeassistant/components/glances/translations/da.json new file mode 100644 index 0000000000000..995ae9d3bba55 --- /dev/null +++ b/homeassistant/components/glances/translations/da.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "V\u00e6rten er allerede konfigureret." + }, + "error": { + "cannot_connect": "Kunne ikke oprette forbindelse til v\u00e6rt", + "wrong_version": "Version underst\u00f8ttes ikke (kun 2 eller 3)" + }, + "step": { + "user": { + "data": { + "host": "V\u00e6rt", + "name": "Navn", + "password": "Adgangskode", + "port": "Port", + "ssl": "Brug SSL/TLS til at oprette forbindelse til Glances-systemet", + "username": "Brugernavn", + "verify_ssl": "Bekr\u00e6ft certificering af systemet", + "version": "Glances API version (2 eller 3)" + }, + "title": "Ops\u00e6tning af Glances" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Opdateringsfrekvens" + }, + "description": "Konfigurationsindstillinger for Glances" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/glances/translations/de.json b/homeassistant/components/glances/translations/de.json new file mode 100644 index 0000000000000..69c34907f19b6 --- /dev/null +++ b/homeassistant/components/glances/translations/de.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "Host ist bereits konfiguriert." + }, + "error": { + "cannot_connect": "Verbindung zum Host nicht m\u00f6glich", + "wrong_version": "Version nicht unterst\u00fctzt (nur 2 oder 3)" + }, + "step": { + "user": { + "data": { + "host": "Host", + "name": "Name", + "password": "Passwort", + "port": "Port", + "ssl": "Verwende SSL / TLS, um eine Verbindung zum Glances-System herzustellen", + "username": "Benutzername", + "verify_ssl": "\u00dcberpr\u00fcfe die Zertifizierung des Systems", + "version": "Glances API-Version (2 oder 3)" + }, + "title": "Glances einrichten" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Aktualisierungsfrequenz" + }, + "description": "Konfiguriere die Optionen f\u00fcr Glances" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/glances/translations/en.json b/homeassistant/components/glances/translations/en.json new file mode 100644 index 0000000000000..0330e8cef653b --- /dev/null +++ b/homeassistant/components/glances/translations/en.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "Host is already configured." + }, + "error": { + "cannot_connect": "Unable to connect to host", + "wrong_version": "Version not supported (2 or 3 only)" + }, + "step": { + "user": { + "data": { + "host": "Host", + "name": "Name", + "password": "Password", + "port": "Port", + "ssl": "Use SSL/TLS to connect to the Glances system", + "username": "Username", + "verify_ssl": "Verify the certification of the system", + "version": "Glances API Version (2 or 3)" + }, + "title": "Setup Glances" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Update frequency" + }, + "description": "Configure options for Glances" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/glances/translations/es-419.json b/homeassistant/components/glances/translations/es-419.json new file mode 100644 index 0000000000000..6debc6da6c167 --- /dev/null +++ b/homeassistant/components/glances/translations/es-419.json @@ -0,0 +1,30 @@ +{ + "config": { + "error": { + "cannot_connect": "No se puede conectar al host", + "wrong_version": "Versi\u00f3n no compatible (2 o 3 solamente)" + }, + "step": { + "user": { + "data": { + "name": "Nombre", + "password": "Contrase\u00f1a", + "port": "Puerto", + "username": "Nombre de usuario", + "verify_ssl": "Verificar la certificaci\u00f3n del sistema", + "version": "Versi\u00f3n de API de Glances (2 o 3)" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Frecuencia de actualizaci\u00f3n" + }, + "description": "Configurar opciones para Glances" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/glances/translations/es.json b/homeassistant/components/glances/translations/es.json new file mode 100644 index 0000000000000..16b768fd922a0 --- /dev/null +++ b/homeassistant/components/glances/translations/es.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "El host ya est\u00e1 configurado." + }, + "error": { + "cannot_connect": "No se puede conectar al host", + "wrong_version": "Versi\u00f3n no soportada (s\u00f3lo 2 o 3)" + }, + "step": { + "user": { + "data": { + "host": "Host", + "name": "Nombre", + "password": "Contrase\u00f1a", + "port": "Puerto", + "ssl": "Utilice SSL/TLS para conectarse al sistema Glances", + "username": "Usuario", + "verify_ssl": "Verificar la certificaci\u00f3n del sistema", + "version": "Versi\u00f3n API Glances (2 o 3)" + }, + "title": "Configurar Glances" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Frecuencia de actualizaci\u00f3n" + }, + "description": "Configurar opciones para Glances" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/glances/translations/fi.json b/homeassistant/components/glances/translations/fi.json new file mode 100644 index 0000000000000..43ccf405d145f --- /dev/null +++ b/homeassistant/components/glances/translations/fi.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "user": { + "data": { + "name": "Nimi", + "password": "Salasana", + "port": "portti" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/glances/translations/fr.json b/homeassistant/components/glances/translations/fr.json new file mode 100644 index 0000000000000..cc9be2d6ce86d --- /dev/null +++ b/homeassistant/components/glances/translations/fr.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "L'h\u00f4te est d\u00e9j\u00e0 configur\u00e9." + }, + "error": { + "cannot_connect": "Impossible de se connecter \u00e0 l'h\u00f4te", + "wrong_version": "Version non prise en charge (2 ou 3 uniquement)" + }, + "step": { + "user": { + "data": { + "host": "H\u00f4te", + "name": "Nom", + "password": "Mot de passe", + "port": "Port", + "ssl": "Utiliser SSL / TLS pour se connecter au syst\u00e8me Glances", + "username": "Nom d'utilisateur", + "verify_ssl": "V\u00e9rifier la certification du syst\u00e8me", + "version": "Glances API Version (2 ou 3)" + }, + "title": "Installation de Glances" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Fr\u00e9quence de mise \u00e0 jour" + }, + "description": "Configurer les options pour Glances" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/glances/translations/hu.json b/homeassistant/components/glances/translations/hu.json new file mode 100644 index 0000000000000..ebb44dc1d6e08 --- /dev/null +++ b/homeassistant/components/glances/translations/hu.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "Kiszolg\u00e1l\u00f3 m\u00e1r konfigur\u00e1lva van." + }, + "error": { + "cannot_connect": "Nem lehet csatlakozni a kiszolg\u00e1l\u00f3hoz", + "wrong_version": "Nem t\u00e1mogatott verzi\u00f3 (2 vagy 3 csak)" + }, + "step": { + "user": { + "data": { + "host": "Kiszolg\u00e1l\u00f3", + "name": "N\u00e9v", + "password": "Jelsz\u00f3", + "port": "Port", + "ssl": "Az SSL / TLS haszn\u00e1lat\u00e1val csatlakozzon a Glances rendszerhez", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v", + "verify_ssl": "A rendszer tan\u00fas\u00edt\u00e1s\u00e1nak ellen\u0151rz\u00e9se", + "version": "Glances API-verzi\u00f3 (2 vagy 3)" + }, + "title": "Glances Be\u00e1ll\u00edt\u00e1sa" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Friss\u00edt\u00e9si gyakoris\u00e1g" + }, + "description": "A Glances be\u00e1ll\u00edt\u00e1sainak konfigur\u00e1l\u00e1sa" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/glances/translations/it.json b/homeassistant/components/glances/translations/it.json new file mode 100644 index 0000000000000..7e8d5af6d8fd7 --- /dev/null +++ b/homeassistant/components/glances/translations/it.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "L'host \u00e8 gi\u00e0 configurato." + }, + "error": { + "cannot_connect": "Impossibile connettersi all'host", + "wrong_version": "Versione non supportata (solo 2 o 3)" + }, + "step": { + "user": { + "data": { + "host": "Host", + "name": "Nome", + "password": "Password", + "port": "Porta", + "ssl": "Utilizzare SSL/TLS per connettersi al sistema Glances", + "username": "Nome utente", + "verify_ssl": "Verificare la certificazione del sistema", + "version": "Glances API Version (2 o 3)" + }, + "title": "Impostare Glances" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Frequenza di aggiornamento" + }, + "description": "Configura le opzioni per Glances" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/glances/translations/ko.json b/homeassistant/components/glances/translations/ko.json new file mode 100644 index 0000000000000..2cf0aa1d59519 --- /dev/null +++ b/homeassistant/components/glances/translations/ko.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "\ud638\uc2a4\ud2b8\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + }, + "error": { + "cannot_connect": "\ud638\uc2a4\ud2b8\uc5d0 \uc5f0\uacb0\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4", + "wrong_version": "\ud574\ub2f9 \ubc84\uc804\uc740 \uc9c0\uc6d0\ub418\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4 (2 \ub610\ub294 3\ub9cc \uc9c0\uc6d0)" + }, + "step": { + "user": { + "data": { + "host": "\ud638\uc2a4\ud2b8", + "name": "\uc774\ub984", + "password": "\ube44\ubc00\ubc88\ud638", + "port": "\ud3ec\ud2b8", + "ssl": "SSL/TLS \ub97c \uc0ac\uc6a9\ud558\uc5ec Glances \uc2dc\uc2a4\ud15c\uc5d0 \uc5f0\uacb0", + "username": "\uc0ac\uc6a9\uc790 \uc774\ub984", + "verify_ssl": "\uc2dc\uc2a4\ud15c \uc778\uc99d \ud655\uc778", + "version": "Glances API \ubc84\uc804 (2 \ub610\ub294 3)" + }, + "title": "Glances \uc124\uce58\ud558\uae30" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "\uc5c5\ub370\uc774\ud2b8 \ube48\ub3c4" + }, + "description": "Glances \uc635\uc158 \uad6c\uc131" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/glances/translations/lb.json b/homeassistant/components/glances/translations/lb.json new file mode 100644 index 0000000000000..4aba9293bd9fe --- /dev/null +++ b/homeassistant/components/glances/translations/lb.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "Apparat ass scho konfigur\u00e9iert" + }, + "error": { + "cannot_connect": "Kann sech net mam Server verbannen.", + "wrong_version": "Versioun net \u00ebnnerst\u00ebtzt (n\u00ebmmen 2 oder 3)" + }, + "step": { + "user": { + "data": { + "host": "Apparat", + "name": "Numm", + "password": "Passwuert", + "port": "Port", + "ssl": "Benotzt SSL/TLS fir sech mam Usiichte System ze verbannen", + "username": "Benotzernumm", + "verify_ssl": "Zertifikatioun vum System iwwerpr\u00e9iwen", + "version": "API Versioun vun den Usiichten (2 oder 3)" + }, + "title": "Usiichten konfigur\u00e9ieren" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Intervalle vun de Mise \u00e0 jour" + }, + "description": "Optioune konfigur\u00e9ieren fir d'Usiichten" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/glances/translations/nl.json b/homeassistant/components/glances/translations/nl.json new file mode 100644 index 0000000000000..c2f2b9d473a5b --- /dev/null +++ b/homeassistant/components/glances/translations/nl.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "Host is al geconfigureerd." + }, + "error": { + "cannot_connect": "Kan geen verbinding maken met host", + "wrong_version": "Versie niet ondersteund (alleen 2 of 3)" + }, + "step": { + "user": { + "data": { + "host": "Host", + "name": "Naam", + "password": "Wachtwoord", + "port": "Poort", + "ssl": "Gebruik SSL / TLS om verbinding te maken met het Glances-systeem", + "username": "Gebruikersnaam", + "verify_ssl": "Controleer de certificering van het systeem", + "version": "Glances API-versie (2 of 3)" + }, + "title": "Glances instellen" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Update frequentie" + }, + "description": "Configureer opties voor Glances" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/glances/translations/nn.json b/homeassistant/components/glances/translations/nn.json new file mode 100644 index 0000000000000..c392b228e8958 --- /dev/null +++ b/homeassistant/components/glances/translations/nn.json @@ -0,0 +1,3 @@ +{ + "title": "Glances" +} \ No newline at end of file diff --git a/homeassistant/components/glances/translations/no.json b/homeassistant/components/glances/translations/no.json new file mode 100644 index 0000000000000..dd593c4add6f0 --- /dev/null +++ b/homeassistant/components/glances/translations/no.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "Verten er allerede konfigurert." + }, + "error": { + "cannot_connect": "Kan ikke koble til vert", + "wrong_version": "Versjonen st\u00f8ttes ikke (bare 2 eller 3)" + }, + "step": { + "user": { + "data": { + "host": "Vert", + "name": "Navn", + "password": "Passord", + "port": "", + "ssl": "Bruk SSL / TLS for \u00e5 koble til Glances-systemet", + "username": "Brukernavn", + "verify_ssl": "Bekreft sertifiseringen av systemet", + "version": "Glances API-versjon (2 eller 3)" + }, + "title": "Oppsett av Glances" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Oppdater frekvens" + }, + "description": "Konfigurasjonsalternativer for Glances" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/glances/translations/pl.json b/homeassistant/components/glances/translations/pl.json new file mode 100644 index 0000000000000..25179e951ad90 --- /dev/null +++ b/homeassistant/components/glances/translations/pl.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "Host jest ju\u017c skonfigurowany." + }, + "error": { + "cannot_connect": "Nie mo\u017cna po\u0142\u0105czy\u0107 si\u0119 z hostem", + "wrong_version": "Wersja nieobs\u0142ugiwana (tylko 2 lub 3)" + }, + "step": { + "user": { + "data": { + "host": "Nazwa hosta lub adres IP", + "name": "Nazwa", + "password": "Has\u0142o", + "port": "Port", + "ssl": "U\u017cyj SSL/TLS, aby po\u0142\u0105czy\u0107 si\u0119 z systemem Glances", + "username": "Nazwa u\u017cytkownika", + "verify_ssl": "Sprawd\u017a certyfikacj\u0119 systemu", + "version": "Glances wersja API (2 lub 3)" + }, + "title": "Konfiguracja Glances" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Cz\u0119stotliwo\u015b\u0107 aktualizacji" + }, + "description": "Konfiguracja opcji dla Glances" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/glances/translations/pt-BR.json b/homeassistant/components/glances/translations/pt-BR.json new file mode 100644 index 0000000000000..05ea657c8b303 --- /dev/null +++ b/homeassistant/components/glances/translations/pt-BR.json @@ -0,0 +1,20 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "Nome de usu\u00e1rio" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Frequ\u00eancia de atualiza\u00e7\u00e3o" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/glances/translations/pt.json b/homeassistant/components/glances/translations/pt.json new file mode 100644 index 0000000000000..b46423599731a --- /dev/null +++ b/homeassistant/components/glances/translations/pt.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "Palavra-passe", + "username": "Nome de Utilizador" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/glances/translations/ru.json b/homeassistant/components/glances/translations/ru.json new file mode 100644 index 0000000000000..d87bcb536cf7c --- /dev/null +++ b/homeassistant/components/glances/translations/ru.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430." + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a \u0445\u043e\u0441\u0442\u0443.", + "wrong_version": "\u041f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u044e\u0442\u0441\u044f \u0442\u043e\u043b\u044c\u043a\u043e \u0432\u0435\u0440\u0441\u0438\u0438 2 \u0438 3." + }, + "step": { + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442", + "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435", + "password": "\u041f\u0430\u0440\u043e\u043b\u044c", + "port": "\u041f\u043e\u0440\u0442", + "ssl": "\u0418\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c SSL / TLS \u0434\u043b\u044f \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f", + "username": "\u041b\u043e\u0433\u0438\u043d", + "verify_ssl": "\u041f\u0440\u043e\u0432\u0435\u0440\u044f\u0442\u044c \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044e \u0441\u0438\u0441\u0442\u0435\u043c\u044b", + "version": "\u0412\u0435\u0440\u0441\u0438\u044f API Glances (2 \u0438\u043b\u0438 3)" + }, + "title": "Glances" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "\u0427\u0430\u0441\u0442\u043e\u0442\u0430 \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u044f" + }, + "description": "\u0414\u043e\u043f\u043e\u043b\u043d\u0438\u0442\u0435\u043b\u044c\u043d\u044b\u0435 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b Glances" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/glances/translations/sl.json b/homeassistant/components/glances/translations/sl.json new file mode 100644 index 0000000000000..081b9ebfda102 --- /dev/null +++ b/homeassistant/components/glances/translations/sl.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "Gostitelj je \u017ee konfiguriran." + }, + "error": { + "cannot_connect": "Ni mogo\u010de vzpostaviti povezave z gostiteljem", + "wrong_version": "Razli\u010dica ni podprta (samo 2 ali 3)" + }, + "step": { + "user": { + "data": { + "host": "Host", + "name": "Ime", + "password": "Geslo", + "port": "Vrata", + "ssl": "Za povezavo s sistemom Glances uporabite SSL/TLS", + "username": "Uporabni\u0161ko ime", + "verify_ssl": "Preverite veljavnost potrdila sistema", + "version": "Glances API Version (2 ali 3)" + }, + "title": "Nastavite Glances" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Pogostost posodabljanja" + }, + "description": "Konfiguracija mo\u017enosti za Glances" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/glances/translations/sv.json b/homeassistant/components/glances/translations/sv.json new file mode 100644 index 0000000000000..c4ead9e6aa6c5 --- /dev/null +++ b/homeassistant/components/glances/translations/sv.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "V\u00e4rden \u00e4r redan konfigurerad." + }, + "error": { + "cannot_connect": "Det g\u00e5r inte att ansluta till v\u00e4rden", + "wrong_version": "Version st\u00f6ds inte (endast 2 eller 3)" + }, + "step": { + "user": { + "data": { + "host": "V\u00e4rd", + "name": "Namn", + "password": "L\u00f6senord", + "port": "Port", + "ssl": "Anv\u00e4nd SSL / TLS f\u00f6r att ansluta till Glances-systemet", + "username": "Anv\u00e4ndarnamn", + "verify_ssl": "Verifiera certifieringen av systemet", + "version": "Glances API-version (2 eller 3)" + }, + "title": "St\u00e4ll in Glances" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Uppdateringsfrekvens" + }, + "description": "Konfigurera alternativ f\u00f6r Glances" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/glances/translations/th.json b/homeassistant/components/glances/translations/th.json new file mode 100644 index 0000000000000..718c857c490f5 --- /dev/null +++ b/homeassistant/components/glances/translations/th.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "\u0e0a\u0e37\u0e48\u0e2d\u0e1c\u0e39\u0e49\u0e43\u0e0a\u0e49" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/glances/translations/zh-Hant.json b/homeassistant/components/glances/translations/zh-Hant.json new file mode 100644 index 0000000000000..dd7c3711e3733 --- /dev/null +++ b/homeassistant/components/glances/translations/zh-Hant.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "\u4e3b\u6a5f\u7aef\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210\u3002" + }, + "error": { + "cannot_connect": "\u7121\u6cd5\u9023\u7dda\u81f3\u4e3b\u6a5f\u7aef", + "wrong_version": "\u7248\u672c\u4e0d\u652f\u63f4\uff08\u50c5 2 \u6216 3\uff09" + }, + "step": { + "user": { + "data": { + "host": "\u4e3b\u6a5f\u7aef", + "name": "\u540d\u7a31", + "password": "\u5bc6\u78bc", + "port": "\u901a\u8a0a\u57e0", + "ssl": "\u4f7f\u7528 SSL/TLS \u9023\u7dda\u81f3 Glances \u7cfb\u7d71", + "username": "\u4f7f\u7528\u8005\u540d\u7a31", + "verify_ssl": "\u9a57\u8b49\u7cfb\u7d71\u8a8d\u8b49", + "version": "Glances API \u7248\u672c\uff082 \u6216 3\uff09" + }, + "title": "\u8a2d\u5b9a Glances" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "\u66f4\u65b0\u983b\u7387" + }, + "description": "Glances \u8a2d\u5b9a\u9078\u9805" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/gntp/manifest.json b/homeassistant/components/gntp/manifest.json index 7315e3c7c849b..bd2a260facaf2 100644 --- a/homeassistant/components/gntp/manifest.json +++ b/homeassistant/components/gntp/manifest.json @@ -1,12 +1,7 @@ { "domain": "gntp", - "name": "Gntp", - "documentation": "https://www.home-assistant.io/components/gntp", - "requirements": [ - "gntp==1.0.3" - ], - "dependencies": [], - "codeowners": [ - "@robbiet480" - ] + "name": "Growl (GnGNTP)", + "documentation": "https://www.home-assistant.io/integrations/gntp", + "requirements": ["gntp==1.0.3"], + "codeowners": ["@robbiet480"] } diff --git a/homeassistant/components/gntp/notify.py b/homeassistant/components/gntp/notify.py index 005043c138494..5c05b097a1fd8 100644 --- a/homeassistant/components/gntp/notify.py +++ b/homeassistant/components/gntp/notify.py @@ -2,52 +2,67 @@ import logging import os +import gntp.errors +import gntp.notifier import voluptuous as vol +from homeassistant.components.notify import ( + ATTR_TITLE, + ATTR_TITLE_DEFAULT, + PLATFORM_SCHEMA, + BaseNotificationService, +) from homeassistant.const import CONF_PASSWORD, CONF_PORT import homeassistant.helpers.config_validation as cv -from homeassistant.components.notify import ( - ATTR_TITLE, ATTR_TITLE_DEFAULT, PLATFORM_SCHEMA, BaseNotificationService) - _LOGGER = logging.getLogger(__name__) -_GNTP_LOGGER = logging.getLogger('gntp') +_GNTP_LOGGER = logging.getLogger("gntp") _GNTP_LOGGER.setLevel(logging.ERROR) -CONF_APP_NAME = 'app_name' -CONF_APP_ICON = 'app_icon' -CONF_HOSTNAME = 'hostname' +CONF_APP_NAME = "app_name" +CONF_APP_ICON = "app_icon" +CONF_HOSTNAME = "hostname" -DEFAULT_APP_NAME = 'HomeAssistant' -DEFAULT_HOST = 'localhost' +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, -}) +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: + 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)) + return GNTPNotificationService( + config.get(CONF_APP_NAME), + app_icon, + config.get(CONF_HOSTNAME), + config.get(CONF_PASSWORD), + config.get(CONF_PORT), + ) class GNTPNotificationService(BaseNotificationService): @@ -55,15 +70,13 @@ class GNTPNotificationService(BaseNotificationService): 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 + port=port, ) try: self.gntp.register() @@ -73,6 +86,8 @@ def __init__(self, app_name, app_icon, hostname, password, port): 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) + 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 index 4a7e4ea980a4c..cdca99e030975 100644 --- a/homeassistant/components/goalfeed/__init__.py +++ b/homeassistant/components/goalfeed/__init__.py @@ -1,31 +1,36 @@ """Component for the Goalfeed service.""" import json +import pysher import requests import voluptuous as vol -import homeassistant.helpers.config_validation as cv from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +import homeassistant.helpers.config_validation as cv # Version downgraded due to regression in library # For details: https://github.com/nlsdfnbch/Pysher/issues/38 -DOMAIN = 'goalfeed' +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) +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' +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) @@ -34,24 +39,25 @@ def goal_handler(data): """Handle goal events.""" goal = json.loads(json.loads(data)) - hass.bus.fire('goal', event_data=goal) + 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() + "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) + 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 = pysher.Pusher( + GOALFEED_APP_ID, secure=False, port=8080, custom_host=GOALFEED_HOST + ) - pusher.connection.bind('pusher:connection_established', connect_handler) + 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 index 861abe0b462d9..d07c7c2df7efd 100644 --- a/homeassistant/components/goalfeed/manifest.json +++ b/homeassistant/components/goalfeed/manifest.json @@ -1,10 +1,7 @@ { "domain": "goalfeed", "name": "Goalfeed", - "documentation": "https://www.home-assistant.io/components/goalfeed", - "requirements": [ - "pysher==1.0.1" - ], - "dependencies": [], + "documentation": "https://www.home-assistant.io/integrations/goalfeed", + "requirements": ["pysher==1.0.1"], "codeowners": [] } diff --git a/homeassistant/components/gogogate2/cover.py b/homeassistant/components/gogogate2/cover.py index 610c131bda5bc..68babd3debe0b 100644 --- a/homeassistant/components/gogogate2/cover.py +++ b/homeassistant/components/gogogate2/cover.py @@ -1,33 +1,38 @@ """Support for Gogogate2 garage Doors.""" import logging +from pygogogate2 import Gogogate2API as pygogogate2 import voluptuous as vol -from homeassistant.components.cover import ( - CoverDevice, SUPPORT_OPEN, SUPPORT_CLOSE) +from homeassistant.components.cover import SUPPORT_CLOSE, SUPPORT_OPEN, CoverEntity from homeassistant.const import ( - CONF_USERNAME, CONF_PASSWORD, STATE_CLOSED, - CONF_IP_ADDRESS, CONF_NAME) + CONF_IP_ADDRESS, + CONF_NAME, + CONF_PASSWORD, + CONF_USERNAME, + STATE_CLOSED, +) import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -DEFAULT_NAME = 'gogogate2' +DEFAULT_NAME = "gogogate2" -NOTIFICATION_ID = 'gogogate2_notification' -NOTIFICATION_TITLE = 'Gogogate2 Cover Setup' +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, -}) +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) @@ -39,31 +44,28 @@ def setup_platform(hass, config, add_entities, discovery_info=None): try: devices = mygogogate2.get_devices() if devices is False: - raise ValueError( - "Username or Password is incorrect or no devices found") + raise ValueError("Username or Password is incorrect or no devices found") - add_entities(MyGogogate2Device( - mygogogate2, door, name) for door in devices) + 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), + (f"Error: {ex}
You will need to restart hass after fixing."), title=NOTIFICATION_TITLE, - notification_id=NOTIFICATION_ID) + notification_id=NOTIFICATION_ID, + ) -class MyGogogate2Device(CoverDevice): +class MyGogogate2Device(CoverEntity): """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.device_id = device["door"] + self._name = name or device["name"] + self._status = device["status"] self._available = None @property @@ -79,7 +81,7 @@ def is_closed(self): @property def device_class(self): """Return the class of this device, from component DEVICE_CLASSES.""" - return 'garage' + return "garage" @property def supported_features(self): diff --git a/homeassistant/components/gogogate2/manifest.json b/homeassistant/components/gogogate2/manifest.json index 3f3f2c25d0c78..829df5a1c37f6 100644 --- a/homeassistant/components/gogogate2/manifest.json +++ b/homeassistant/components/gogogate2/manifest.json @@ -1,10 +1,7 @@ { "domain": "gogogate2", "name": "Gogogate2", - "documentation": "https://www.home-assistant.io/components/gogogate2", - "requirements": [ - "pygogogate2==0.1.1" - ], - "dependencies": [], + "documentation": "https://www.home-assistant.io/integrations/gogogate2", + "requirements": ["pygogogate2==0.1.1"], "codeowners": [] } diff --git a/homeassistant/components/google/__init__.py b/homeassistant/components/google/__init__.py index e9bbf3f96cdd9..93afb43cf525d 100644 --- a/homeassistant/components/google/__init__.py +++ b/homeassistant/components/google/__init__.py @@ -1,77 +1,131 @@ """Support for Google - Calendar Event Devices.""" +from datetime import datetime, timedelta import logging import os -import yaml +from googleapiclient import discovery as google_discovery +import httplib2 +from oauth2client.client import ( + FlowExchangeError, + OAuth2DeviceCodeError, + OAuth2WebServerFlow, +) +from oauth2client.file import Storage import voluptuous as vol from voluptuous.error import Error as VoluptuousError +import yaml -import homeassistant.helpers.config_validation as cv -from homeassistant.setup import setup_component from homeassistant.helpers import discovery +import homeassistant.helpers.config_validation as cv 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 + '.{}' +DOMAIN = "google" +ENTITY_ID_FORMAT = DOMAIN + ".{}" -CONF_CLIENT_ID = 'client_id' -CONF_CLIENT_SECRET = 'client_secret' -CONF_TRACK_NEW = 'track_new_calendar' +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' +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 = '!!' - -NOTIFICATION_ID = 'google_calendar_notification' -NOTIFICATION_TITLE = 'Google Calendar Setup' +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_SCAN_CALENDARS = "scan_for_calendars" +SERVICE_FOUND_CALENDARS = "found_calendar" +SERVICE_ADD_EVENT = "add_event" -DATA_INDEX = 'google_calendars' +DATA_INDEX = "google_calendars" -YAML_DEVICES = '{}_calendars.yaml'.format(DOMAIN) -SCOPES = 'https://www.googleapis.com/auth/calendar.readonly' +YAML_DEVICES = f"{DOMAIN}_calendars.yaml" +SCOPES = "https://www.googleapis.com/auth/calendar" -TOKEN_FILE = '.{}.token'.format(DOMAIN) +TOKEN_FILE = f".{DOMAIN}.token" -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) +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): @@ -80,44 +134,41 @@ def do_authentication(hass, hass_config, config): 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.readonly', - redirect_uri='Home-Assistant.io', + 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), + f"Error: {err}
You will need to restart hass after fixing." "", title=NOTIFICATION_TITLE, - notification_id=NOTIFICATION_ID) + 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 + ( + f"In order to authorize Home-Assistant to view your calendars " + f'you must visit: {dev_flow.verification_url} and enter ' + f"code: {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', + "Authentication code expired, please restart " + "Home-Assistant and try again", title=NOTIFICATION_TITLE, - notification_id=NOTIFICATION_ID) + notification_id=NOTIFICATION_ID, + ) listener() try: @@ -131,12 +182,17 @@ def step2_exchange(now): 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) + ( + f"We are all setup now. Check {YAML_DEVICES} for calendars that have " + f"been found" + ), + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID, + ) listener = track_time_change( - hass, step2_exchange, second=range(0, 60, dev_flow.interval)) + hass, step2_exchange, second=range(0, 60, dev_flow.interval) + ) return True @@ -155,46 +211,108 @@ def setup(hass, config): if not os.path.isfile(token_file): do_authentication(hass, config, conf) else: - do_setup(hass, config, conf) + if not check_correct_scopes(token_file): + do_authentication(hass, config, conf) + else: + do_setup(hass, config, conf) return True -def setup_services(hass, hass_config, track_new_found_calendars, - calendar_service): +def check_correct_scopes(token_file): + """Check for the correct scopes in file.""" + tokenfile = open(token_file).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: + if hass.data[DATA_INDEX].get(calendar[CONF_CAL_ID]) 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]] + 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) + 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) + 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'] + calendars = cal_list.list().execute()["items"] for calendar in calendars: - calendar['track'] = track_new_found_calendars - hass.services.call(DOMAIN, SERVICE_FOUND_CALENDARS, - calendar) + 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_SCAN_CALENDARS, _scan_for_calendars) + DOMAIN, SERVICE_ADD_EVENT, _add_event, schema=ADD_EVENT_SERVICE_SCHEMA + ) return True @@ -204,17 +322,13 @@ def do_setup(hass, hass_config, config): 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) + 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) for calendar in hass.data[DATA_INDEX].values(): - discovery.load_platform(hass, 'calendar', DOMAIN, calendar, - hass_config) + discovery.load_platform(hass, "calendar", DOMAIN, calendar, hass_config) # Look for any new calendars hass.services.call(DOMAIN, SERVICE_SCAN_CALENDARS, None) @@ -230,27 +344,30 @@ def __init__(self, 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) + "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), - }] - }) + 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 @@ -262,8 +379,7 @@ def load_config(path): data = yaml.safe_load(file) for calendar in data: try: - calendars.update({calendar[CONF_CAL_ID]: - DEVICE_SCHEMA(calendar)}) + calendars.update({calendar[CONF_CAL_ID]: DEVICE_SCHEMA(calendar)}) except VoluptuousError as exception: # keep going _LOGGER.warning("Calendar Invalid Data: %s", exception) @@ -276,6 +392,6 @@ def load_config(path): def update_config(path, calendar): """Write the google_calendar_devices.yaml.""" - with open(path, 'a') as out: - out.write('\n') + 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 index 993c24d8653fe..8a6eb6446210b 100644 --- a/homeassistant/components/google/calendar.py +++ b/homeassistant/components/google/calendar.py @@ -1,20 +1,40 @@ """Support for Google Calendar Search binary sensors.""" +import copy from datetime import timedelta import logging -from homeassistant.components.calendar import CalendarEventDevice +from httplib2 import ServerNotFoundError # pylint: disable=import-error + +from homeassistant.components.calendar import ( + ENTITY_ID_FORMAT, + CalendarEventDevice, + calculate_offset, + is_offset_reached, +) +from homeassistant.helpers.entity import generate_entity_id 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) + CONF_CAL_ID, + CONF_DEVICE_ID, + CONF_ENTITIES, + CONF_IGNORE_AVAILABILITY, + CONF_MAX_RESULTS, + CONF_NAME, + CONF_OFFSET, + CONF_SEARCH, + CONF_TRACK, + DEFAULT_CONF_OFFSET, + TOKEN_FILE, + GoogleCalendarService, +) _LOGGER = logging.getLogger(__name__) DEFAULT_GOOGLE_SEARCH_PARAMS = { - 'orderBy': 'startTime', - 'maxResults': 5, - 'singleEvents': True, + "orderBy": "startTime", + "maxResults": 5, + "singleEvents": True, } MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) @@ -29,33 +49,76 @@ def setup_platform(hass, config, add_entities, disc_info=None): 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]]) + entities = [] + for data in disc_info[CONF_ENTITIES]: + if not data[CONF_TRACK]: + continue + entity_id = generate_entity_id( + ENTITY_ID_FORMAT, data[CONF_DEVICE_ID], hass=hass + ) + entity = GoogleCalendarEventDevice( + calendar_service, disc_info[CONF_CAL_ID], data, entity_id + ) + entities.append(entity) + + add_entities(entities, True) class GoogleCalendarEventDevice(CalendarEventDevice): """A calendar event device.""" - def __init__(self, hass, calendar_service, calendar, data): + def __init__(self, calendar_service, calendar, data, entity_id): """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) + self.data = GoogleCalendarData( + calendar_service, + calendar, + data.get(CONF_SEARCH), + data.get(CONF_IGNORE_AVAILABILITY), + data.get(CONF_MAX_RESULTS), + ) + self._event = None + self._name = data[CONF_NAME] + self._offset = data.get(CONF_OFFSET, DEFAULT_CONF_OFFSET) + self._offset_reached = False + self.entity_id = entity_id + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + return {"offset_reached": self._offset_reached} + + @property + def event(self): + """Return the next upcoming event.""" + return self._event + + @property + def name(self): + """Return the name of the entity.""" + return self._name 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) + def update(self): + """Update event data.""" + self.data.update() + event = copy.deepcopy(self.data.event) + if event is None: + self._event = event + return + event = calculate_offset(event, self._offset) + self._offset_reached = is_offset_reached(event) + self._event = event + class GoogleCalendarData: """Class to utilize calendar service object to get next event.""" - def __init__(self, calendar_service, calendar_id, search, - ignore_availability, max_results): + 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 @@ -65,42 +128,36 @@ def __init__(self, calendar_service, calendar_id, search, 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") + _LOGGER.error("Unable to connect to Google") return None, None params = dict(DEFAULT_GOOGLE_SEARCH_PARAMS) - params['calendarId'] = self.calendar_id + params["calendarId"] = self.calendar_id if self.max_results: - params['maxResults'] = self.max_results + params["maxResults"] = self.max_results if self.search: - params['q'] = 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) + 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') + 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) + result = await hass.async_add_executor_job(events.list(**params).execute) - items = result.get('items', []) + items = result.get("items", []) event_list = [] for item in items: - if (not self.ignore_availability - and 'transparency' in item.keys()): - if item['transparency'] == 'opaque': + if not self.ignore_availability and "transparency" in item.keys(): + if item["transparency"] == "opaque": event_list.append(item) else: event_list.append(item) @@ -111,19 +168,18 @@ def update(self): """Get the latest data.""" service, params = self._prepare_query() if service is None: - return False - params['timeMin'] = dt.now().isoformat('T') + return + params["timeMin"] = dt.now().isoformat("T") events = service.events() result = events.list(**params).execute() - items = result.get('items', []) + 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': + if not self.ignore_availability and "transparency" in item.keys(): + if item["transparency"] == "opaque": new_event = item break else: @@ -131,4 +187,3 @@ def update(self): break self.event = new_event - return True diff --git a/homeassistant/components/google/manifest.json b/homeassistant/components/google/manifest.json index 4c7e82ecfef42..1c14609f5086d 100644 --- a/homeassistant/components/google/manifest.json +++ b/homeassistant/components/google/manifest.json @@ -1,12 +1,11 @@ { "domain": "google", - "name": "Google", - "documentation": "https://www.home-assistant.io/components/google", + "name": "Google Calendars", + "documentation": "https://www.home-assistant.io/integrations/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 index 34eecb33fd5e4..702b8347676d6 100644 --- a/homeassistant/components/google/services.yaml +++ b/homeassistant/components/google/services.yaml @@ -2,3 +2,30 @@ 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' diff --git a/homeassistant/components/google_assistant/__init__.py b/homeassistant/components/google_assistant/__init__.py index c8078b7d9d228..2d848101def80 100644 --- a/homeassistant/components/google_assistant/__init__.py +++ b/homeassistant/components/google_assistant/__init__.py @@ -1,89 +1,124 @@ """Support for Actions on Google Assistant Smart Home Control.""" -import asyncio import logging -from typing import Dict, Any - -import aiohttp -import async_timeout +from typing import Any, Dict import voluptuous as vol # Typing imports -from homeassistant.core import HomeAssistant, ServiceCall - from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant, ServiceCall 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 + CONF_ALIASES, + CONF_ALLOW_UNLOCK, + CONF_API_KEY, + CONF_CLIENT_EMAIL, + CONF_ENTITY_CONFIG, + CONF_EXPOSE, + CONF_EXPOSE_BY_DEFAULT, + CONF_EXPOSED_DOMAINS, + CONF_PRIVATE_KEY, + CONF_PROJECT_ID, + CONF_REPORT_STATE, + CONF_ROOM_HINT, + CONF_SECURE_DEVICES_PIN, + CONF_SERVICE_ACCOUNT, + DEFAULT_EXPOSE_BY_DEFAULT, + DEFAULT_EXPOSED_DOMAINS, + DOMAIN, + SERVICE_REQUEST_SYNC, ) -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 +from .http import GoogleAssistantView, GoogleConfig + +from .const import EVENT_COMMAND_RECEIVED, EVENT_SYNC_RECEIVED # noqa: F401, isort:skip _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, -}) +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_SERVICE_ACCOUNT = vol.Schema( + { + vol.Required(CONF_PRIVATE_KEY): cv.string, + vol.Required(CONF_CLIENT_EMAIL): cv.string, + }, + extra=vol.ALLOW_EXTRA, +) + + +def _check_report_state(data): + if data[CONF_REPORT_STATE]: + if CONF_SERVICE_ACCOUNT not in data: + raise vol.Invalid( + "If report state is enabled, a service account must exist" + ) + return data + 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) + cv.deprecated(CONF_ALLOW_UNLOCK, invalidation_version="0.95"), + cv.deprecated(CONF_API_KEY, invalidation_version="0.105"), + 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, + vol.Optional(CONF_REPORT_STATE, default=False): cv.boolean, + vol.Optional(CONF_SERVICE_ACCOUNT): GOOGLE_SERVICE_ACCOUNT, + }, + extra=vol.PREVENT_EXTRA, + ), + _check_report_state, +) + +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) + + google_config = GoogleConfig(hass, config) + await google_config.async_initialize() + + hass.http.register_view(GoogleAssistantView(google_config)) + + if google_config.should_report_state: + google_config.async_enable_report_state() async def request_sync_service_handler(call: ServiceCall): """Handle request sync service calls.""" - websession = async_get_clientsession(hass) - try: - with async_timeout.timeout(15, loop=hass.loop): - 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: + agent_user_id = call.data.get("agent_user_id") or call.context.user_id + + if agent_user_id is None: + _LOGGER.warning( + "No agent_user_id supplied for request_sync. Call as a user or pass in user id as agent_user_id." + ) + return + + await google_config.async_sync_entities(agent_user_id) + + # Register service only if key is provided + if CONF_API_KEY in config or CONF_SERVICE_ACCOUNT in config: hass.services.async_register( - DOMAIN, SERVICE_REQUEST_SYNC, request_sync_service_handler) + 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 index 0f15d10f181d3..37fef6f2d794a 100644 --- a/homeassistant/components/google_assistant/const.py +++ b/homeassistant/components/google_assistant/const.py @@ -1,5 +1,6 @@ """Constants for Google Assistant.""" from homeassistant.components import ( + alarm_control_panel, binary_sensor, camera, climate, @@ -12,52 +13,73 @@ media_player, scene, script, + sensor, switch, vacuum, ) -DOMAIN = 'google_assistant' -GOOGLE_ASSISTANT_API_ENDPOINT = '/api/google_assistant' +DOMAIN = "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' +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" +CONF_REPORT_STATE = "report_state" +CONF_SERVICE_ACCOUNT = "service_account" +CONF_CLIENT_EMAIL = "client_email" +CONF_PRIVATE_KEY = "private_key" 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' + "climate", + "cover", + "fan", + "group", + "input_boolean", + "light", + "media_player", + "scene", + "script", + "switch", + "vacuum", + "lock", + "binary_sensor", + "sensor", + "alarm_control_panel", ] -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' -TYPE_MEDIA = PREFIX_TYPES + 'MEDIA' - -SERVICE_REQUEST_SYNC = 'request_sync' -HOMEGRAPH_URL = 'https://homegraph.googleapis.com/' -REQUEST_SYNC_BASE_URL = HOMEGRAPH_URL + 'v1/devices:requestSync' +PREFIX_TYPES = "action.devices.types." +TYPE_CAMERA = f"{PREFIX_TYPES}CAMERA" +TYPE_LIGHT = f"{PREFIX_TYPES}LIGHT" +TYPE_SWITCH = f"{PREFIX_TYPES}SWITCH" +TYPE_VACUUM = f"{PREFIX_TYPES}VACUUM" +TYPE_SCENE = f"{PREFIX_TYPES}SCENE" +TYPE_FAN = f"{PREFIX_TYPES}FAN" +TYPE_THERMOSTAT = f"{PREFIX_TYPES}THERMOSTAT" +TYPE_LOCK = f"{PREFIX_TYPES}LOCK" +TYPE_BLINDS = f"{PREFIX_TYPES}BLINDS" +TYPE_GARAGE = f"{PREFIX_TYPES}GARAGE" +TYPE_OUTLET = f"{PREFIX_TYPES}OUTLET" +TYPE_SENSOR = f"{PREFIX_TYPES}SENSOR" +TYPE_DOOR = f"{PREFIX_TYPES}DOOR" +TYPE_TV = f"{PREFIX_TYPES}TV" +TYPE_SPEAKER = f"{PREFIX_TYPES}SPEAKER" +TYPE_ALARM = f"{PREFIX_TYPES}SECURITYSYSTEM" + +SERVICE_REQUEST_SYNC = "request_sync" +HOMEGRAPH_URL = "https://homegraph.googleapis.com/" +HOMEGRAPH_SCOPE = "https://www.googleapis.com/auth/homegraph" +HOMEGRAPH_TOKEN_URL = "https://accounts.google.com/o/oauth2/token" +REQUEST_SYNC_BASE_URL = f"{HOMEGRAPH_URL}v1/devices:requestSync" +REPORT_STATE_BASE_URL = f"{HOMEGRAPH_URL}v1/devices:reportStateAndNotification" # Error codes used for SmartHomeError class # https://developers.google.com/actions/reference/smarthome/errors-exceptions @@ -65,20 +87,23 @@ 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_PROTOCOL_ERROR = "protocolError" +ERR_UNKNOWN_ERROR = "unknownError" +ERR_FUNCTION_NOT_SUPPORTED = "functionNotSupported" + +ERR_ALREADY_DISARMED = "alreadyDisarmed" +ERR_ALREADY_ARMED = "alreadyArmed" -ERR_CHALLENGE_NEEDED = 'challengeNeeded' -ERR_CHALLENGE_NOT_SETUP = 'challengeFailedNotSetup' -ERR_TOO_MANY_FAILED_ATTEMPTS = 'tooManyFailedAttempts' -ERR_PIN_INCORRECT = 'pinIncorrect' -ERR_USER_CANCELLED = 'userCancelled' +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' +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, @@ -89,11 +114,12 @@ input_boolean.DOMAIN: TYPE_SWITCH, light.DOMAIN: TYPE_LIGHT, lock.DOMAIN: TYPE_LOCK, - media_player.DOMAIN: TYPE_MEDIA, + media_player.DOMAIN: TYPE_SWITCH, scene.DOMAIN: TYPE_SCENE, script.DOMAIN: TYPE_SCENE, switch.DOMAIN: TYPE_SWITCH, vacuum.DOMAIN: TYPE_VACUUM, + alarm_control_panel.DOMAIN: TYPE_ALARM, } DEVICE_CLASS_TO_GOOGLE_TYPES = { @@ -102,15 +128,22 @@ (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_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, + (sensor.DOMAIN, sensor.DEVICE_CLASS_HUMIDITY): TYPE_SENSOR, } -CHALLENGE_ACK_NEEDED = 'ackNeeded' -CHALLENGE_PIN_NEEDED = 'pinNeeded' -CHALLENGE_FAILED_PIN_NEEDED = 'challengeFailedPinNeeded' +CHALLENGE_ACK_NEEDED = "ackNeeded" +CHALLENGE_PIN_NEEDED = "pinNeeded" +CHALLENGE_FAILED_PIN_NEEDED = "challengeFailedPinNeeded" + +STORE_AGENT_USER_IDS = "agent_user_ids" + +SOURCE_CLOUD = "cloud" +SOURCE_LOCAL = "local" + +NOT_EXPOSE_LOCAL = {TYPE_ALARM, TYPE_LOCK} diff --git a/homeassistant/components/google_assistant/error.py b/homeassistant/components/google_assistant/error.py index 3aef1e9408d5e..82c256067eb80 100644 --- a/homeassistant/components/google_assistant/error.py +++ b/homeassistant/components/google_assistant/error.py @@ -15,9 +15,7 @@ def __init__(self, code, msg): def to_response(self): """Convert to a response format.""" - return { - 'errorCode': self.code - } + return {"errorCode": self.code} class ChallengeNeeded(SmartHomeError): @@ -28,15 +26,12 @@ class ChallengeNeeded(SmartHomeError): def __init__(self, challenge_type): """Initialize challenge needed error.""" - super().__init__(ERR_CHALLENGE_NEEDED, - 'Challenge needed: {}'.format(challenge_type)) + super().__init__(ERR_CHALLENGE_NEEDED, f"Challenge needed: {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 - } + "errorCode": self.code, + "challengeNeeded": {"type": self.challenge_type}, } diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index 4d3f2855b31de..6c2fa3d82e1e1 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -1,44 +1,310 @@ """Helper classes for Google Assistant integration.""" +from abc import ABC, abstractmethod from asyncio import gather from collections.abc import Mapping +import logging +import pprint +from typing import List, Optional -from homeassistant.core import Context, callback +from aiohttp.web import json_response + +from homeassistant.components import webhook from homeassistant.const import ( - CONF_NAME, STATE_UNAVAILABLE, ATTR_SUPPORTED_FEATURES, - ATTR_DEVICE_CLASS + ATTR_DEVICE_CLASS, + ATTR_SUPPORTED_FEATURES, + CLOUD_NEVER_EXPOSED_ENTITIES, + CONF_NAME, + STATE_UNAVAILABLE, ) +from homeassistant.core import Context, HomeAssistant, State, callback +from homeassistant.helpers.event import async_call_later +from homeassistant.helpers.storage import Store from . import trait from .const import ( - DOMAIN_TO_GOOGLE_TYPES, CONF_ALIASES, ERR_FUNCTION_NOT_SUPPORTED, - DEVICE_CLASS_TO_GOOGLE_TYPES, CONF_ROOM_HINT, + CONF_ALIASES, + CONF_ROOM_HINT, + DEVICE_CLASS_TO_GOOGLE_TYPES, + DOMAIN, + DOMAIN_TO_GOOGLE_TYPES, + ERR_FUNCTION_NOT_SUPPORTED, + NOT_EXPOSE_LOCAL, + SOURCE_LOCAL, + STORE_AGENT_USER_IDS, ) from .error import SmartHomeError +SYNC_DELAY = 15 +_LOGGER = logging.getLogger(__name__) + -class Config: +class AbstractConfig(ABC): """Hold the configuration for Google Assistant.""" - def __init__(self, should_expose, - entity_config=None, secure_devices_pin=None, - agent_user_id=None): - """Initialize the configuration.""" - self.should_expose = should_expose - self.entity_config = entity_config or {} - self.secure_devices_pin = secure_devices_pin + _unsub_report_state = None + + def __init__(self, hass): + """Initialize abstract config.""" + self.hass = hass + self._store = None + self._google_sync_unsub = {} + self._local_sdk_active = False + + async def async_initialize(self): + """Perform async initialization of config.""" + self._store = GoogleConfigStore(self.hass) + await self._store.async_load() + + @property + def enabled(self): + """Return if Google is enabled.""" + return False + + @property + def entity_config(self): + """Return entity config.""" + return {} + + @property + def secure_devices_pin(self): + """Return entity config.""" + return None + + @property + def is_reporting_state(self): + """Return if we're actively reporting states.""" + return self._unsub_report_state is not None + + @property + def is_local_sdk_active(self): + """Return if we're actively accepting local messages.""" + return self._local_sdk_active + + @property + def should_report_state(self): + """Return if states should be proactively reported.""" + return False + + @property + def local_sdk_webhook_id(self): + """Return the local SDK webhook ID. + + Return None to disable the local SDK. + """ + return None + + @property + def local_sdk_user_id(self): + """Return the user ID to be used for actions received via the local SDK.""" + raise NotImplementedError + + @abstractmethod + def get_agent_user_id(self, context): + """Get agent user ID from context.""" + + @abstractmethod + def should_expose(self, state) -> bool: + """Return if entity should be exposed.""" + + def should_2fa(self, state): + """If an entity should have 2FA checked.""" + # pylint: disable=no-self-use + return True + + async def async_report_state(self, message, agent_user_id: str): + """Send a state report to Google.""" + raise NotImplementedError + + async def async_report_state_all(self, message): + """Send a state report to Google for all previously synced users.""" + jobs = [ + self.async_report_state(message, agent_user_id) + for agent_user_id in self._store.agent_user_ids + ] + await gather(*jobs) + + @callback + def async_enable_report_state(self): + """Enable proactive mode.""" + # Circular dep + # pylint: disable=import-outside-toplevel + from .report_state import async_enable_report_state + + if self._unsub_report_state is None: + self._unsub_report_state = async_enable_report_state(self.hass, self) + + @callback + def async_disable_report_state(self): + """Disable report state.""" + if self._unsub_report_state is not None: + self._unsub_report_state() + self._unsub_report_state = None + + async def async_sync_entities(self, agent_user_id: str): + """Sync all entities to Google.""" + # Remove any pending sync + self._google_sync_unsub.pop(agent_user_id, lambda: None)() + return await self._async_request_sync_devices(agent_user_id) + + async def async_sync_entities_all(self): + """Sync all entities to Google for all registered agents.""" + res = await gather( + *[ + self.async_sync_entities(agent_user_id) + for agent_user_id in self._store.agent_user_ids + ] + ) + return max(res, default=204) + + @callback + def async_schedule_google_sync(self, agent_user_id: str): + """Schedule a sync.""" + + async def _schedule_callback(_now): + """Handle a scheduled sync callback.""" + self._google_sync_unsub.pop(agent_user_id, None) + await self.async_sync_entities(agent_user_id) + + self._google_sync_unsub.pop(agent_user_id, lambda: None)() + + self._google_sync_unsub[agent_user_id] = async_call_later( + self.hass, SYNC_DELAY, _schedule_callback + ) + + @callback + def async_schedule_google_sync_all(self): + """Schedule a sync for all registered agents.""" + for agent_user_id in self._store.agent_user_ids: + self.async_schedule_google_sync(agent_user_id) + + async def _async_request_sync_devices(self, agent_user_id: str) -> int: + """Trigger a sync with Google. + + Return value is the HTTP status code of the sync request. + """ + raise NotImplementedError + + async def async_connect_agent_user(self, agent_user_id: str): + """Add an synced and known agent_user_id. + + Called when a completed sync response have been sent to Google. + """ + self._store.add_agent_user_id(agent_user_id) + + async def async_disconnect_agent_user(self, agent_user_id: str): + """Turn off report state and disable further state reporting. + + Called when the user disconnects their account from Google. + """ + self._store.pop_agent_user_id(agent_user_id) + + @callback + def async_enable_local_sdk(self): + """Enable the local SDK.""" + webhook_id = self.local_sdk_webhook_id + + if webhook_id is None: + return + + webhook.async_register( + self.hass, DOMAIN, "Local Support", webhook_id, self._handle_local_webhook, + ) + + self._local_sdk_active = True - # Agent User Id to use for query responses - self.agent_user_id = agent_user_id + @callback + def async_disable_local_sdk(self): + """Disable the local SDK.""" + if not self._local_sdk_active: + return + + webhook.async_unregister(self.hass, self.local_sdk_webhook_id) + self._local_sdk_active = False + + async def _handle_local_webhook(self, hass, webhook_id, request): + """Handle an incoming local SDK message.""" + # Circular dep + # pylint: disable=import-outside-toplevel + from . import smart_home + + payload = await request.json() + + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug("Received local message:\n%s\n", pprint.pformat(payload)) + + if not self.enabled: + return json_response(smart_home.turned_off_response(payload)) + + result = await smart_home.async_handle_message( + self.hass, self, self.local_sdk_user_id, payload, SOURCE_LOCAL + ) + + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug("Responding to local message:\n%s\n", pprint.pformat(result)) + + return json_response(result) + + +class GoogleConfigStore: + """A configuration store for google assistant.""" + + _STORAGE_VERSION = 1 + _STORAGE_KEY = DOMAIN + + def __init__(self, hass): + """Initialize a configuration store.""" + self._hass = hass + self._store = Store(hass, self._STORAGE_VERSION, self._STORAGE_KEY) + self._data = {STORE_AGENT_USER_IDS: {}} + + @property + def agent_user_ids(self): + """Return a list of connected agent user_ids.""" + return self._data[STORE_AGENT_USER_IDS] + + @callback + def add_agent_user_id(self, agent_user_id): + """Add an agent user id to store.""" + if agent_user_id not in self._data[STORE_AGENT_USER_IDS]: + self._data[STORE_AGENT_USER_IDS][agent_user_id] = {} + self._store.async_delay_save(lambda: self._data, 1.0) + + @callback + def pop_agent_user_id(self, agent_user_id): + """Remove agent user id from store.""" + if agent_user_id in self._data[STORE_AGENT_USER_IDS]: + self._data[STORE_AGENT_USER_IDS].pop(agent_user_id, None) + self._store.async_delay_save(lambda: self._data, 1.0) + + async def async_load(self): + """Store current configuration to disk.""" + data = await self._store.async_load() + if data: + self._data = data class RequestData: """Hold data associated with a particular request.""" - def __init__(self, config, user_id, request_id): + def __init__( + self, + config: AbstractConfig, + user_id: str, + source: str, + request_id: str, + devices: Optional[List[dict]], + ): """Initialize the request data.""" self.config = config + self.source = source self.request_id = request_id self.context = Context(user_id=user_id) + self.devices = devices + + @property + def is_local_request(self): + """Return if this is a local request.""" + return self.source == SOURCE_LOCAL def get_google_type(domain, device_class): @@ -51,7 +317,7 @@ def get_google_type(domain, device_class): class GoogleEntity: """Adaptation of Entity expressed in Google's terms.""" - def __init__(self, hass, config, state): + def __init__(self, hass: HomeAssistant, config: AbstractConfig, state: State): """Initialize a Google entity.""" self.hass = hass self.config = config @@ -74,64 +340,101 @@ def traits(self): 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)] + self._traits = [ + Trait(self.hass, state, self.config) + for Trait in trait.TRAITS + if Trait.supported(domain, features, device_class) + ] return self._traits - async def sync_serialize(self): + @callback + def should_expose(self): + """If entity should be exposed.""" + return self.config.should_expose(self.state) + + @callback + def should_expose_local(self) -> bool: + """Return if the entity should be exposed locally.""" + return ( + self.should_expose() + and get_google_type( + self.state.domain, self.state.attributes.get(ATTR_DEVICE_CLASS) + ) + not in NOT_EXPOSE_LOCAL + and not self.might_2fa() + ) + + @callback + def is_supported(self) -> bool: + """Return if the entity is supported by Google.""" + return bool(self.traits()) + + @callback + def might_2fa(self) -> bool: + """Return if the entity might encounter 2FA.""" + if not self.config.should_2fa(self.state): + return False + + return self.might_2fa_traits() + + @callback + def might_2fa_traits(self) -> bool: + """Return if the entity might encounter 2FA based on just traits.""" + 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, agent_user_id): """Serialize entity for a SYNC response. https://developers.google.com/actions/smarthome/create-app#actiondevicessync """ state = self.state - # When a state is unavailable, the attributes that describe - # capabilities will be stripped. For example, a light entity will miss - # the min/max mireds. Therefore they will be excluded from a sync. - if state.state == STATE_UNAVAILABLE: - return None - 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) - # If an empty string - if not name: - return None - traits = self.traits() - # Found no supported traits for this entity - if not traits: - return None - - device_type = get_google_type(domain, - device_class) + 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, + "id": state.entity_id, + "name": {"name": name}, + "attributes": {}, + "traits": [trait.name for trait in traits], + "willReportState": self.config.should_report_state, + "type": device_type, } # use aliases aliases = entity_config.get(CONF_ALIASES) if aliases: - device['name']['nicknames'] = aliases + device["name"]["nicknames"] = [name] + aliases + + if self.config.is_local_sdk_active and self.should_expose_local(): + device["otherDeviceIds"] = [{"deviceId": self.entity_id}] + device["customData"] = { + "webhookId": self.config.local_sdk_webhook_id, + "httpPort": self.hass.http.server_port, + "httpSSL": self.hass.config.api.use_ssl, + "baseUrl": self.hass.config.api.base_url, + "proxyDeviceId": agent_user_id, + } for trt in traits: - device['attributes'].update(trt.sync_attributes()) + device["attributes"].update(trt.sync_attributes()) room = entity_config.get(CONF_ROOM_HINT) if room: - device['roomHint'] = room + device["roomHint"] = room return device dev_reg, ent_reg, area_reg = await gather( @@ -150,7 +453,7 @@ async def sync_serialize(self): area_entry = area_reg.areas.get(device_entry.area_id) if area_entry and area_entry.name: - device['roomHint'] = area_entry.name + device["roomHint"] = area_entry.name return device @@ -163,23 +466,28 @@ def query_serialize(self): state = self.state if state.state == STATE_UNAVAILABLE: - return {'online': False} + return {"online": False} - attrs = {'online': True} + attrs = {"online": True} for trt in self.traits(): deep_update(attrs, trt.query_attributes()) return attrs + @callback + def reachable_device_serialize(self): + """Serialize entity for a REACHABLE_DEVICE response.""" + return {"verificationId": self.entity_id} + 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', {}) + 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): @@ -190,8 +498,8 @@ async def execute(self, data, command_payload): if not executed: raise SmartHomeError( ERR_FUNCTION_NOT_SUPPORTED, - 'Unable to execute {} for {}'.format(command, - self.state.entity_id)) + f"Unable to execute {command} for {self.state.entity_id}", + ) @callback def async_update(self): @@ -213,3 +521,19 @@ def deep_update(target, source): 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 index d385d742c7d18..7b75a36f8bb92 100644 --- a/homeassistant/components/google_assistant/http.py +++ b/homeassistant/components/google_assistant/http.py @@ -1,72 +1,234 @@ """Support for Google Actions Smart Home Control.""" +import asyncio +from datetime import timedelta import logging +from uuid import uuid4 +from aiohttp import ClientError, ClientResponseError from aiohttp.web import Request, Response +import jwt # Typing imports from homeassistant.components.http import HomeAssistantView -from homeassistant.core import callback -from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES +from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES, HTTP_INTERNAL_SERVER_ERROR +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.util import dt as dt_util from .const import ( - GOOGLE_ASSISTANT_API_ENDPOINT, - CONF_EXPOSE_BY_DEFAULT, - CONF_EXPOSED_DOMAINS, + CONF_API_KEY, + CONF_CLIENT_EMAIL, CONF_ENTITY_CONFIG, CONF_EXPOSE, + CONF_EXPOSE_BY_DEFAULT, + CONF_EXPOSED_DOMAINS, + CONF_PRIVATE_KEY, + CONF_REPORT_STATE, CONF_SECURE_DEVICES_PIN, + CONF_SERVICE_ACCOUNT, + GOOGLE_ASSISTANT_API_ENDPOINT, + HOMEGRAPH_SCOPE, + HOMEGRAPH_TOKEN_URL, + REPORT_STATE_BASE_URL, + REQUEST_SYNC_BASE_URL, + SOURCE_CLOUD, ) +from .helpers import AbstractConfig 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: +def _get_homegraph_jwt(time, iss, key): + now = int(time.timestamp()) + + jwt_raw = { + "iss": iss, + "scope": HOMEGRAPH_SCOPE, + "aud": HOMEGRAPH_TOKEN_URL, + "iat": now, + "exp": now + 3600, + } + return jwt.encode(jwt_raw, key, algorithm="RS256").decode("utf-8") + + +async def _get_homegraph_token(hass, jwt_signed): + headers = { + "Authorization": f"Bearer {jwt_signed}", + "Content-Type": "application/x-www-form-urlencoded", + } + data = { + "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", + "assertion": jwt_signed, + } + + session = async_get_clientsession(hass) + async with session.post(HOMEGRAPH_TOKEN_URL, headers=headers, data=data) as res: + res.raise_for_status() + return await res.json() + + +class GoogleConfig(AbstractConfig): + """Config for manual setup of Google.""" + + def __init__(self, hass, config): + """Initialize the config.""" + super().__init__(hass) + self._config = config + self._access_token = None + self._access_token_renew = None + + @property + def enabled(self): + """Return if Google is enabled.""" + return True + + @property + def entity_config(self): + """Return entity config.""" + return self._config.get(CONF_ENTITY_CONFIG) or {} + + @property + def secure_devices_pin(self): + """Return entity config.""" + return self._config.get(CONF_SECURE_DEVICES_PIN) + + @property + def should_report_state(self): + """Return if states should be proactively reported.""" + return self._config.get(CONF_REPORT_STATE) + + def should_expose(self, state) -> bool: + """Return if entity should be exposed.""" + expose_by_default = self._config.get(CONF_EXPOSE_BY_DEFAULT) + exposed_domains = self._config.get(CONF_EXPOSED_DOMAINS) + + if state.attributes.get("view") is not None: # Ignore entities that are views return False - if entity.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: + if state.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: return False - explicit_expose = \ - entity_config.get(entity.entity_id, {}).get(CONF_EXPOSE) + explicit_expose = self.entity_config.get(state.entity_id, {}).get(CONF_EXPOSE) - domain_exposed_by_default = \ - expose_by_default and entity.domain in exposed_domains + domain_exposed_by_default = ( + expose_by_default and state.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 + 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)) + def get_agent_user_id(self, context): + """Get agent user ID making request.""" + return context.user_id + + def should_2fa(self, state): + """If an entity should have 2FA checked.""" + return True + + async def _async_request_sync_devices(self, agent_user_id: str): + if CONF_API_KEY in self._config: + await self.async_call_homegraph_api_key( + REQUEST_SYNC_BASE_URL, {"agentUserId": agent_user_id} + ) + elif CONF_SERVICE_ACCOUNT in self._config: + await self.async_call_homegraph_api( + REQUEST_SYNC_BASE_URL, {"agentUserId": agent_user_id} + ) + else: + _LOGGER.error("No configuration for request_sync available") + + async def _async_update_token(self, force=False): + if CONF_SERVICE_ACCOUNT not in self._config: + _LOGGER.error("Trying to get homegraph api token without service account") + return + + now = dt_util.utcnow() + if not self._access_token or now > self._access_token_renew or force: + token = await _get_homegraph_token( + self.hass, + _get_homegraph_jwt( + now, + self._config[CONF_SERVICE_ACCOUNT][CONF_CLIENT_EMAIL], + self._config[CONF_SERVICE_ACCOUNT][CONF_PRIVATE_KEY], + ), + ) + self._access_token = token["access_token"] + self._access_token_renew = now + timedelta(seconds=token["expires_in"]) + + async def async_call_homegraph_api_key(self, url, data): + """Call a homegraph api with api key authentication.""" + websession = async_get_clientsession(self.hass) + try: + res = await websession.post( + url, params={"key": self._config.get(CONF_API_KEY)}, json=data + ) + _LOGGER.debug( + "Response on %s with data %s was %s", url, data, await res.text() + ) + res.raise_for_status() + return res.status + except ClientResponseError as error: + _LOGGER.error("Request for %s failed: %d", url, error.status) + return error.status + except (asyncio.TimeoutError, ClientError): + _LOGGER.error("Could not contact %s", url) + return HTTP_INTERNAL_SERVER_ERROR + + async def async_call_homegraph_api(self, url, data): + """Call a homegraph api with authentication.""" + session = async_get_clientsession(self.hass) + + async def _call(): + headers = { + "Authorization": f"Bearer {self._access_token}", + "X-GFE-SSL": "yes", + } + async with session.post(url, headers=headers, json=data) as res: + _LOGGER.debug( + "Response on %s with data %s was %s", url, data, await res.text() + ) + res.raise_for_status() + return res.status + + try: + await self._async_update_token() + try: + return await _call() + except ClientResponseError as error: + if error.status == 401: + _LOGGER.warning( + "Request for %s unauthorized, renewing token and retrying", url + ) + await self._async_update_token(True) + return await _call() + raise + except ClientResponseError as error: + _LOGGER.error("Request for %s failed: %d", url, error.status) + return error.status + except (asyncio.TimeoutError, ClientError): + _LOGGER.error("Could not contact %s", url) + return HTTP_INTERNAL_SERVER_ERROR + + async def async_report_state(self, message, agent_user_id: str): + """Send a state report to Google.""" + data = { + "requestId": uuid4().hex, + "agentUserId": agent_user_id, + "payload": message, + } + await self.async_call_homegraph_api(REPORT_STATE_BASE_URL, data) class GoogleAssistantView(HomeAssistantView): """Handle Google Assistant requests.""" url = GOOGLE_ASSISTANT_API_ENDPOINT - name = 'api:google_assistant' + name = "api:google_assistant" requires_auth = True def __init__(self, config): @@ -75,10 +237,12 @@ def __init__(self, config): async def post(self, request: Request) -> Response: """Handle Google Assistant requests.""" - message = await request.json() # type: dict + message: dict = await request.json() result = await async_handle_message( - request.app['hass'], + request.app["hass"], self.config, - request['hass_user'].id, - message) + request["hass_user"].id, + message, + SOURCE_CLOUD, + ) return self.json(result) diff --git a/homeassistant/components/google_assistant/manifest.json b/homeassistant/components/google_assistant/manifest.json index ff91693021654..eef58106bd0d3 100644 --- a/homeassistant/components/google_assistant/manifest.json +++ b/homeassistant/components/google_assistant/manifest.json @@ -1,10 +1,8 @@ { "domain": "google_assistant", - "name": "Google assistant", - "documentation": "https://www.home-assistant.io/components/google_assistant", - "requirements": [], - "dependencies": [ - "http" - ], - "codeowners": [] + "name": "Google Assistant", + "documentation": "https://www.home-assistant.io/integrations/google_assistant", + "dependencies": ["http"], + "after_dependencies": ["camera"], + "codeowners": ["@home-assistant/cloud"] } diff --git a/homeassistant/components/google_assistant/report_state.py b/homeassistant/components/google_assistant/report_state.py new file mode 100644 index 0000000000000..ad94436272114 --- /dev/null +++ b/homeassistant/components/google_assistant/report_state.py @@ -0,0 +1,76 @@ +"""Google Report State implementation.""" +import logging + +from homeassistant.const import MATCH_ALL +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.event import async_call_later + +from .error import SmartHomeError +from .helpers import AbstractConfig, GoogleEntity, async_get_entities + +# Time to wait until the homegraph updates +# https://github.com/actions-on-google/smart-home-nodejs/issues/196#issuecomment-439156639 +INITIAL_REPORT_DELAY = 60 + + +_LOGGER = logging.getLogger(__name__) + + +@callback +def async_enable_report_state(hass: HomeAssistant, google_config: AbstractConfig): + """Enable state reporting.""" + + async def async_entity_state_listener(changed_entity, old_state, new_state): + if not hass.is_running: + return + + if not new_state: + return + + if not google_config.should_expose(new_state): + return + + entity = GoogleEntity(hass, google_config, new_state) + + if not entity.is_supported(): + return + + try: + entity_data = entity.query_serialize() + except SmartHomeError as err: + _LOGGER.debug("Not reporting state for %s: %s", changed_entity, err.code) + return + + if old_state: + old_entity = GoogleEntity(hass, google_config, old_state) + + # Only report to Google if data that Google cares about has changed + if entity_data == old_entity.query_serialize(): + return + + _LOGGER.debug("Reporting state for %s: %s", changed_entity, entity_data) + + await google_config.async_report_state_all( + {"devices": {"states": {changed_entity: entity_data}}} + ) + + async def inital_report(_now): + """Report initially all states.""" + entities = {} + + for entity in async_get_entities(hass, google_config): + if not entity.should_expose(): + continue + + try: + entities[entity.entity_id] = entity.query_serialize() + except SmartHomeError: + continue + + await google_config.async_report_state_all({"devices": {"states": entities}}) + + async_call_later(hass, INITIAL_REPORT_DELAY, inital_report) + + return hass.helpers.event.async_track_state_change( + MATCH_ALL, async_entity_state_listener + ) diff --git a/homeassistant/components/google_assistant/smart_home.py b/homeassistant/components/google_assistant/smart_home.py index 1ec47bbedd60a..71e0d252fe1f6 100644 --- a/homeassistant/components/google_assistant/smart_home.py +++ b/homeassistant/components/google_assistant/smart_home.py @@ -1,189 +1,212 @@ """Support for Google Assistant Smart Home API.""" +import asyncio from itertools import product import logging +from homeassistant.const import ATTR_ENTITY_ID, __version__ from homeassistant.util.decorator import Registry -from homeassistant.const import ( - CLOUD_NEVER_EXPOSED_ENTITIES, ATTR_ENTITY_ID) - from .const import ( - ERR_PROTOCOL_ERROR, ERR_DEVICE_OFFLINE, ERR_UNKNOWN_ERROR, - EVENT_COMMAND_RECEIVED, EVENT_SYNC_RECEIVED, EVENT_QUERY_RECEIVED + ERR_DEVICE_OFFLINE, + ERR_PROTOCOL_ERROR, + ERR_UNKNOWN_ERROR, + EVENT_COMMAND_RECEIVED, + EVENT_QUERY_RECEIVED, + EVENT_SYNC_RECEIVED, ) -from .helpers import RequestData, GoogleEntity from .error import SmartHomeError +from .helpers import GoogleEntity, RequestData, async_get_entities HANDLERS = Registry() _LOGGER = logging.getLogger(__name__) -async def async_handle_message(hass, config, user_id, message): +async def async_handle_message(hass, config, user_id, message, source): """Handle incoming API messages.""" - request_id = message.get('requestId') # type: str - - data = RequestData(config, user_id, request_id) + data = RequestData( + config, user_id, source, message["requestId"], message.get("devices") + ) response = await _process(hass, data, message) - if response and 'errorCode' in response['payload']: - _LOGGER.error('Error handling message %s: %s', - message, response['payload']) + 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 + inputs: list = message.get("inputs") if len(inputs) != 1: return { - 'requestId': data.request_id, - 'payload': {'errorCode': ERR_PROTOCOL_ERROR} + "requestId": data.request_id, + "payload": {"errorCode": ERR_PROTOCOL_ERROR}, } - handler = HANDLERS.get(inputs[0].get('intent')) + handler = HANDLERS.get(inputs[0].get("intent")) if handler is None: return { - 'requestId': data.request_id, - 'payload': {'errorCode': ERR_PROTOCOL_ERROR} + "requestId": data.request_id, + "payload": {"errorCode": ERR_PROTOCOL_ERROR}, } try: - result = await handler(hass, data, inputs[0].get('payload')) + result = await handler(hass, data, inputs[0].get("payload")) except SmartHomeError as err: - return { - 'requestId': data.request_id, - 'payload': {'errorCode': err.code} - } + return {"requestId": data.request_id, "payload": {"errorCode": err.code}} except Exception: # pylint: disable=broad-except - _LOGGER.exception('Unexpected error') + _LOGGER.exception("Unexpected error") return { - 'requestId': data.request_id, - 'payload': {'errorCode': ERR_UNKNOWN_ERROR} + "requestId": data.request_id, + "payload": {"errorCode": ERR_UNKNOWN_ERROR}, } if result is None: return None - return {'requestId': data.request_id, 'payload': result} + + return {"requestId": data.request_id, "payload": result} -@HANDLERS.register('action.devices.SYNC') +@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 + https://developers.google.com/assistant/smarthome/develop/process-intents#SYNC """ hass.bus.async_fire( EVENT_SYNC_RECEIVED, - {'request_id': data.request_id}, - context=data.context) + {"request_id": data.request_id, "source": data.source}, + context=data.context, + ) - devices = [] - for state in hass.states.async_all(): - if state.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: - continue + agent_user_id = data.config.get_agent_user_id(data.context) - if not data.config.should_expose(state): - continue + devices = await asyncio.gather( + *( + entity.sync_serialize(agent_user_id) + for entity in async_get_entities(hass, data.config) + if entity.should_expose() + ) + ) - entity = GoogleEntity(hass, data.config, state) - serialized = await entity.sync_serialize() + response = {"agentUserId": agent_user_id, "devices": devices} - if serialized is None: - _LOGGER.debug("No mapping for %s domain", entity.state) - continue + await data.config.async_connect_agent_user(agent_user_id) - devices.append(serialized) - - response = { - 'agentUserId': data.config.agent_user_id or data.context.user_id, - 'devices': devices, - } + _LOGGER.debug("Syncing entities response: %s", response) return response -@HANDLERS.register('action.devices.QUERY') +@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 + https://developers.google.com/assistant/smarthome/develop/process-intents#QUERY """ devices = {} - for device in payload.get('devices', []): - devid = device['id'] + 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, + "request_id": data.request_id, ATTR_ENTITY_ID: devid, + "source": data.source, }, - context=data.context) + context=data.context, + ) if not state: # If we can't find a state, the device is offline - devices[devid] = {'online': False} + devices[devid] = {"online": False} continue entity = GoogleEntity(hass, data.config, state) devices[devid] = entity.query_serialize() - return {'devices': devices} + return {"devices": devices} -@HANDLERS.register('action.devices.EXECUTE') +async def _entity_execute(entity, data, executions): + """Execute all commands for an entity. + + Returns a dict if a special result needs to be set. + """ + for execution in executions: + try: + await entity.execute(data, execution) + except SmartHomeError as err: + return { + "ids": [entity.entity_id], + "status": "ERROR", + **err.to_response(), + } + + return None + + +@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 + https://developers.google.com/assistant/smarthome/develop/process-intents#EXECUTE """ entities = {} + executions = {} results = {} - for command in payload['commands']: - for device, execution in product(command['devices'], - command['execution']): - entity_id = device['id'] + 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, + "request_id": data.request_id, ATTR_ENTITY_ID: entity_id, - 'execution': execution + "execution": execution, + "source": data.source, }, - context=data.context) + 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 + if entity_id in entities: + executions[entity_id].append(execution) + continue - entities[entity_id] = GoogleEntity(hass, data.config, state) + state = hass.states.get(entity_id) - try: - await entities[entity_id].execute(data, execution) - except SmartHomeError as err: + if state is None: results[entity_id] = { - 'ids': [entity_id], - 'status': 'ERROR', - **err.to_response() + "ids": [entity_id], + "status": "ERROR", + "errorCode": ERR_DEVICE_OFFLINE, } + continue + + entities[entity_id] = GoogleEntity(hass, data.config, state) + executions[entity_id] = [execution] + + execute_results = await asyncio.gather( + *[ + _entity_execute(entities[entity_id], data, executions[entity_id]) + for entity_id in executions + ] + ) + + for entity_id, result in zip(executions, execute_results): + if result is not None: + results[entity_id] = result final_results = list(results.values()) @@ -193,27 +216,68 @@ async def handle_devices_execute(hass, data, payload): entity.async_update() - final_results.append({ - 'ids': [entity.entity_id], - 'status': 'SUCCESS', - 'states': entity.query_serialize(), - }) + final_results.append( + { + "ids": [entity.entity_id], + "status": "SUCCESS", + "states": entity.query_serialize(), + } + ) - return {'commands': final_results} + return {"commands": final_results} -@HANDLERS.register('action.devices.DISCONNECT') -async def async_devices_disconnect(hass, data, payload): +@HANDLERS.register("action.devices.DISCONNECT") +async def async_devices_disconnect(hass, data: RequestData, payload): """Handle action.devices.DISCONNECT request. - https://developers.google.com/actions/smarthome/create#actiondevicesdisconnect + https://developers.google.com/assistant/smarthome/develop/process-intents#DISCONNECT """ + await data.config.async_disconnect_agent_user(data.context.user_id) return None +@HANDLERS.register("action.devices.IDENTIFY") +async def async_devices_identify(hass, data: RequestData, payload): + """Handle action.devices.IDENTIFY request. + + https://developers.google.com/assistant/smarthome/develop/local#implement_the_identify_handler + """ + return { + "device": { + "id": data.config.get_agent_user_id(data.context), + "isLocalOnly": True, + "isProxy": True, + "deviceInfo": { + "hwVersion": "UNKNOWN_HW_VERSION", + "manufacturer": "Home Assistant", + "model": "Home Assistant", + "swVersion": __version__, + }, + } + } + + +@HANDLERS.register("action.devices.REACHABLE_DEVICES") +async def async_devices_reachable(hass, data: RequestData, payload): + """Handle action.devices.REACHABLE_DEVICES request. + + https://developers.google.com/actions/smarthome/create#actiondevicesdisconnect + """ + google_ids = {dev["id"] for dev in (data.devices or [])} + + return { + "devices": [ + entity.reachable_device_serialize() + for entity in async_get_entities(hass, data.config) + if entity.entity_id in google_ids and entity.should_expose_local() + ] + } + + def turned_off_response(message): """Return a device turned off response.""" return { - 'requestId': message.get('requestId'), - 'payload': {'errorCode': 'deviceTurnedOff'} + "requestId": message.get("requestId"), + "payload": {"errorCode": "deviceTurnedOff"}, } diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index cb2bf688ad0e7..ab04589623554 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -2,86 +2,111 @@ import logging from homeassistant.components import ( + alarm_control_panel, binary_sensor, camera, cover, - group, fan, + group, input_boolean, - media_player, light, lock, + media_player, scene, script, + sensor, switch, vacuum, ) from homeassistant.components.climate import const as climate from homeassistant.const import ( - ATTR_ENTITY_ID, + ATTR_ASSUMED_STATE, + ATTR_CODE, ATTR_DEVICE_CLASS, + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + ATTR_TEMPERATURE, + SERVICE_ALARM_ARM_AWAY, + SERVICE_ALARM_ARM_CUSTOM_BYPASS, + SERVICE_ALARM_ARM_HOME, + SERVICE_ALARM_ARM_NIGHT, + SERVICE_ALARM_DISARM, + SERVICE_ALARM_TRIGGER, SERVICE_TURN_OFF, SERVICE_TURN_ON, + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_CUSTOM_BYPASS, + STATE_ALARM_ARMED_HOME, + STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_DISARMED, + STATE_ALARM_PENDING, + STATE_ALARM_TRIGGERED, STATE_LOCKED, STATE_OFF, STATE_ON, + STATE_UNAVAILABLE, + STATE_UNKNOWN, 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, + CHALLENGE_PIN_NEEDED, + ERR_ALREADY_ARMED, + ERR_ALREADY_DISARMED, + ERR_CHALLENGE_NOT_SETUP, + ERR_FUNCTION_NOT_SUPPORTED, + ERR_NOT_SUPPORTED, + ERR_VALUE_OUT_OF_RANGE, ) -from .error import SmartHomeError, ChallengeNeeded +from .error import ChallengeNeeded, SmartHomeError _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' +PREFIX_TRAITS = "action.devices.traits." +TRAIT_CAMERA_STREAM = f"{PREFIX_TRAITS}CameraStream" +TRAIT_ONOFF = f"{PREFIX_TRAITS}OnOff" +TRAIT_DOCK = f"{PREFIX_TRAITS}Dock" +TRAIT_STARTSTOP = f"{PREFIX_TRAITS}StartStop" +TRAIT_BRIGHTNESS = f"{PREFIX_TRAITS}Brightness" +TRAIT_COLOR_SETTING = f"{PREFIX_TRAITS}ColorSetting" +TRAIT_SCENE = f"{PREFIX_TRAITS}Scene" +TRAIT_TEMPERATURE_SETTING = f"{PREFIX_TRAITS}TemperatureSetting" +TRAIT_LOCKUNLOCK = f"{PREFIX_TRAITS}LockUnlock" +TRAIT_FANSPEED = f"{PREFIX_TRAITS}FanSpeed" +TRAIT_MODES = f"{PREFIX_TRAITS}Modes" +TRAIT_OPENCLOSE = f"{PREFIX_TRAITS}OpenClose" +TRAIT_VOLUME = f"{PREFIX_TRAITS}Volume" +TRAIT_ARMDISARM = f"{PREFIX_TRAITS}ArmDisarm" +TRAIT_HUMIDITY_SETTING = f"{PREFIX_TRAITS}HumiditySetting" + +PREFIX_COMMANDS = "action.devices.commands." +COMMAND_ONOFF = f"{PREFIX_COMMANDS}OnOff" +COMMAND_GET_CAMERA_STREAM = f"{PREFIX_COMMANDS}GetCameraStream" +COMMAND_DOCK = f"{PREFIX_COMMANDS}Dock" +COMMAND_STARTSTOP = f"{PREFIX_COMMANDS}StartStop" +COMMAND_PAUSEUNPAUSE = f"{PREFIX_COMMANDS}PauseUnpause" +COMMAND_BRIGHTNESS_ABSOLUTE = f"{PREFIX_COMMANDS}BrightnessAbsolute" +COMMAND_COLOR_ABSOLUTE = f"{PREFIX_COMMANDS}ColorAbsolute" +COMMAND_ACTIVATE_SCENE = f"{PREFIX_COMMANDS}ActivateScene" COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT = ( - PREFIX_COMMANDS + 'ThermostatTemperatureSetpoint') + f"{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' + f"{PREFIX_COMMANDS}ThermostatTemperatureSetRange" +) +COMMAND_THERMOSTAT_SET_MODE = f"{PREFIX_COMMANDS}ThermostatSetMode" +COMMAND_LOCKUNLOCK = f"{PREFIX_COMMANDS}LockUnlock" +COMMAND_FANSPEED = f"{PREFIX_COMMANDS}SetFanSpeed" +COMMAND_MODES = f"{PREFIX_COMMANDS}SetModes" +COMMAND_OPENCLOSE = f"{PREFIX_COMMANDS}OpenClose" +COMMAND_SET_VOLUME = f"{PREFIX_COMMANDS}setVolume" +COMMAND_VOLUME_RELATIVE = f"{PREFIX_COMMANDS}volumeRelative" +COMMAND_ARMDISARM = f"{PREFIX_COMMANDS}ArmDisarm" TRAITS = [] @@ -95,8 +120,8 @@ def register_trait(trait): def _google_temp_unit(units): """Return Google temperature unit.""" if units == TEMP_FAHRENHEIT: - return 'F' - return 'C' + return "F" + return "C" class _Trait: @@ -104,6 +129,11 @@ class _Trait: 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 @@ -135,9 +165,7 @@ class BrightnessTrait(_Trait): """ name = TRAIT_BRIGHTNESS - commands = [ - COMMAND_BRIGHTNESS_ABSOLUTE - ] + commands = [COMMAND_BRIGHTNESS_ABSOLUTE] @staticmethod def supported(domain, features, device_class): @@ -159,7 +187,9 @@ def query_attributes(self): if domain == light.DOMAIN: brightness = self.state.attributes.get(light.ATTR_BRIGHTNESS) if brightness is not None: - response['brightness'] = int(100 * (brightness / 255)) + response["brightness"] = int(100 * (brightness / 255)) + else: + response["brightness"] = 0 return response @@ -169,10 +199,15 @@ async def execute(self, command, data, params, challenge): if domain == light.DOMAIN: await self.hass.services.async_call( - light.DOMAIN, light.SERVICE_TURN_ON, { + light.DOMAIN, + light.SERVICE_TURN_ON, + { ATTR_ENTITY_ID: self.state.entity_id, - light.ATTR_BRIGHTNESS_PCT: params['brightness'] - }, blocking=True, context=data.context) + light.ATTR_BRIGHTNESS_PCT: params["brightness"], + }, + blocking=True, + context=data.context, + ) @register_trait @@ -183,9 +218,7 @@ class CameraStreamTrait(_Trait): """ name = TRAIT_CAMERA_STREAM - commands = [ - COMMAND_GET_CAMERA_STREAM - ] + commands = [COMMAND_GET_CAMERA_STREAM] stream_info = None @@ -200,11 +233,9 @@ def supported(domain, features, device_class): def sync_attributes(self): """Return stream attributes for a sync request.""" return { - 'cameraStreamSupportedProtocols': [ - "hls", - ], - 'cameraStreamNeedAuthToken': False, - 'cameraStreamNeedDrmEncryption': False, + "cameraStreamSupportedProtocols": ["hls"], + "cameraStreamNeedAuthToken": False, + "cameraStreamNeedDrmEncryption": False, } def query_attributes(self): @@ -214,9 +245,10 @@ def query_attributes(self): 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.state.entity_id, "hls" + ) self.stream_info = { - 'cameraStreamAccessUrl': self.hass.config.api.base_url + url + "cameraStreamAccessUrl": self.hass.config.api.base_url + url } @@ -228,9 +260,7 @@ class OnOffTrait(_Trait): """ name = TRAIT_ONOFF - commands = [ - COMMAND_ONOFF - ] + commands = [COMMAND_ONOFF] @staticmethod def supported(domain, features, device_class): @@ -250,7 +280,7 @@ def sync_attributes(self): def query_attributes(self): """Return OnOff query attributes.""" - return {'on': self.state.state != STATE_OFF} + return {"on": self.state.state != STATE_OFF} async def execute(self, command, data, params, challenge): """Execute an OnOff command.""" @@ -258,15 +288,19 @@ async def execute(self, command, data, params, challenge): if domain == group.DOMAIN: service_domain = HA_DOMAIN - service = SERVICE_TURN_ON if params['on'] else SERVICE_TURN_OFF + 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 + 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) + await self.hass.services.async_call( + service_domain, + service, + {ATTR_ENTITY_ID: self.state.entity_id}, + blocking=True, + context=data.context, + ) @register_trait @@ -277,9 +311,7 @@ class ColorSettingTrait(_Trait): """ name = TRAIT_COLOR_SETTING - commands = [ - COMMAND_COLOR_ABSOLUTE - ] + commands = [COMMAND_COLOR_ABSOLUTE] @staticmethod def supported(domain, features, device_class): @@ -287,8 +319,7 @@ def supported(domain, features, device_class): if domain != light.DOMAIN: return False - return (features & light.SUPPORT_COLOR_TEMP or - features & light.SUPPORT_COLOR) + return features & light.SUPPORT_COLOR_TEMP or features & light.SUPPORT_COLOR def sync_attributes(self): """Return color temperature attributes for a sync request.""" @@ -297,18 +328,18 @@ def sync_attributes(self): response = {} if features & light.SUPPORT_COLOR: - response['colorModel'] = 'hsv' + 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)), + # Min Kelvin 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 @@ -322,72 +353,87 @@ def query_attributes(self): 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, + 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) + _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) + color["temperatureK"] = color_util.color_temperature_mired_to_kelvin( + temp + ) response = {} if color: - response['color'] = color + response["color"] = color return response async def execute(self, command, data, params, challenge): """Execute a color temperature command.""" - if 'temperature' in params['color']: + if "temperature" in params["color"]: temp = color_util.color_temperature_kelvin_to_mired( - params['color']['temperature']) + 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)) + f"Temperature should be between {min_temp} and {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) + 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']: + 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']) + hex_value = f"{params['color']['spectrumRGB']:06x}" color = color_util.color_RGB_to_hs( - *color_util.rgb_hex_to_rgb_list(hex_value)) + *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) + 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 + 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, { + 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) + light.ATTR_HS_COLOR: [color["hue"], saturation], + light.ATTR_BRIGHTNESS: brightness, + }, + blocking=True, + context=data.context, + ) @register_trait @@ -398,9 +444,7 @@ class SceneTrait(_Trait): """ name = TRAIT_SCENE - commands = [ - COMMAND_ACTIVATE_SCENE - ] + commands = [COMMAND_ACTIVATE_SCENE] @staticmethod def supported(domain, features, device_class): @@ -420,10 +464,12 @@ 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) + self.state.domain, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: self.state.entity_id}, + blocking=self.state.domain != script.DOMAIN, + context=data.context, + ) @register_trait @@ -434,9 +480,7 @@ class DockTrait(_Trait): """ name = TRAIT_DOCK - commands = [ - COMMAND_DOCK - ] + commands = [COMMAND_DOCK] @staticmethod def supported(domain, features, device_class): @@ -449,14 +493,17 @@ def sync_attributes(self): def query_attributes(self): """Return dock query attributes.""" - return {'isDocked': self.state.state == vacuum.STATE_DOCKED} + 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) + self.state.domain, + vacuum.SERVICE_RETURN_TO_BASE, + {ATTR_ENTITY_ID: self.state.entity_id}, + blocking=True, + context=data.context, + ) @register_trait @@ -467,10 +514,7 @@ class StartStopTrait(_Trait): """ name = TRAIT_STARTSTOP - commands = [ - COMMAND_STARTSTOP, - COMMAND_PAUSEUNPAUSE - ] + commands = [COMMAND_STARTSTOP, COMMAND_PAUSEUNPAUSE] @staticmethod def supported(domain, features, device_class): @@ -479,41 +523,55 @@ def supported(domain, features, device_class): 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} + 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, + "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']: + 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) + 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) + 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']: + 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) + 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) + self.state.domain, + vacuum.SERVICE_START, + {ATTR_ENTITY_ID: self.state.entity_id}, + blocking=True, + context=data.context, + ) @register_trait @@ -531,103 +589,154 @@ class TemperatureSettingTrait(_Trait): ] # 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' + hvac_to_google = { + climate.HVAC_MODE_HEAT: "heat", + climate.HVAC_MODE_COOL: "cool", + climate.HVAC_MODE_OFF: "off", + climate.HVAC_MODE_AUTO: "auto", + climate.HVAC_MODE_HEAT_COOL: "heatcool", + climate.HVAC_MODE_FAN_ONLY: "fan-only", + climate.HVAC_MODE_DRY: "dry", } - google_to_hass = {value: key for key, value in hass_to_google.items()} + google_to_hvac = {value: key for key, value in hvac_to_google.items()} + + preset_to_google = {climate.PRESET_ECO: "eco"} + google_to_preset = {value: key for key, value in preset_to_google.items()} @staticmethod def supported(domain, features, device_class): """Test if state is supported.""" - if domain != climate.DOMAIN: - return False + if domain == climate.DOMAIN: + return True - 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.""" + @property + def climate_google_modes(self): + """Return supported Google modes.""" modes = [] - supported = self.state.attributes.get(ATTR_SUPPORTED_FEATURES) + attrs = self.state.attributes - if supported & climate.SUPPORT_ON_OFF != 0: - modes.append(STATE_OFF) - modes.append(STATE_ON) + for mode in attrs.get(climate.ATTR_HVAC_MODES, []): + google_mode = self.hvac_to_google.get(mode) + if google_mode and google_mode not in modes: + modes.append(google_mode) - if supported & climate.SUPPORT_OPERATION_MODE != 0: - for mode in self.state.attributes.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) + for preset in attrs.get(climate.ATTR_PRESET_MODES, []): + google_mode = self.preset_to_google.get(preset) + if google_mode and google_mode not in modes: + modes.append(google_mode) - return { - 'availableThermostatModes': ','.join(modes), - 'thermostatTemperatureUnit': _google_temp_unit( - self.hass.config.units.temperature_unit) - } + return modes - def query_attributes(self): - """Return temperature point and modes query attributes.""" - attrs = self.state.attributes + 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 = self.climate_google_modes - operation = attrs.get(climate.ATTR_OPERATION_MODE) - supported = self.state.attributes.get(ATTR_SUPPORTED_FEATURES) + # Some integrations don't support modes (e.g. opentherm), but Google doesn't + # support changing the temperature if we don't have any modes. If there's + # only one Google doesn't support changing it, so the default mode here is + # only cosmetic. + if len(modes) == 0: + modes.append("heat") - 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' + if "off" in modes and any( + mode in modes for mode in ("heatcool", "heat", "cool") + ): + modes.append("on") + 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 not in (STATE_UNKNOWN, STATE_UNAVAILABLE): + response["thermostatTemperatureAmbient"] = round( + temp_util.convert(float(current_temp), unit, TEMP_CELSIUS), 1 + ) + + elif domain == climate.DOMAIN: + operation = self.state.state + preset = attrs.get(climate.ATTR_PRESET_MODE) + supported = attrs.get(ATTR_SUPPORTED_FEATURES, 0) + + if preset in self.preset_to_google: + response["thermostatMode"] = self.preset_to_google[preset] + else: + response["thermostatMode"] = self.hvac_to_google.get(operation) + + 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_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) + current_humidity = attrs.get(climate.ATTR_CURRENT_HUMIDITY) + if current_humidity is not None: + response["thermostatHumidityAmbient"] = current_humidity + + if operation in (climate.HVAC_MODE_AUTO, climate.HVAC_MODE_HEAT_COOL): + if supported & climate.SUPPORT_TARGET_TEMPERATURE_RANGE: + 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: - 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) + 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] @@ -635,84 +744,170 @@ async def execute(self, command, data, params, challenge): if command == COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT: temp = temp_util.convert( - params['thermostatTemperatureSetpoint'], TEMP_CELSIUS, - unit) + 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)) + f"Temperature should be between {min_temp} and {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) + 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) + 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)) + ( + f"Upper bound for temperature range should be between " + f"{min_temp} and {max_temp}" + ), + ) temp_low = temp_util.convert( - params['thermostatTemperatureSetpointLow'], TEMP_CELSIUS, - unit) + 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)) + ( + f"Lower bound for temperature range should be between " + f"{min_temp} and {max_temp}" + ), + ) supported = self.state.attributes.get(ATTR_SUPPORTED_FEATURES) - svc_data = { - ATTR_ENTITY_ID: self.state.entity_id, - } + svc_data = {ATTR_ENTITY_ID: self.state.entity_id} - if(supported & climate.SUPPORT_TARGET_TEMPERATURE_HIGH and - supported & climate.SUPPORT_TARGET_TEMPERATURE_LOW): + if supported & climate.SUPPORT_TARGET_TEMPERATURE_RANGE: 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) + climate.DOMAIN, + climate.SERVICE_SET_TEMPERATURE, + svc_data, + blocking=True, + context=data.context, + ) elif command == COMMAND_THERMOSTAT_SET_MODE: - target_mode = params['thermostatMode'] + 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): + if target_mode == "on": await self.hass.services.async_call( climate.DOMAIN, - (SERVICE_TURN_ON - if target_mode == STATE_ON - else SERVICE_TURN_OFF), + SERVICE_TURN_ON, {ATTR_ENTITY_ID: self.state.entity_id}, - blocking=True, context=data.context) - elif supported & climate.SUPPORT_OPERATION_MODE: + blocking=True, + context=data.context, + ) + return + + if target_mode == "off": + await self.hass.services.async_call( + climate.DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: self.state.entity_id}, + blocking=True, + context=data.context, + ) + return + + if target_mode in self.google_to_preset: await self.hass.services.async_call( - climate.DOMAIN, climate.SERVICE_SET_OPERATION_MODE, { + climate.DOMAIN, + climate.SERVICE_SET_PRESET_MODE, + { + climate.ATTR_PRESET_MODE: self.google_to_preset[target_mode], ATTR_ENTITY_ID: self.state.entity_id, - climate.ATTR_OPERATION_MODE: - self.google_to_hass[target_mode], - }, blocking=True, context=data.context) + }, + blocking=True, + context=data.context, + ) + return + + await self.hass.services.async_call( + climate.DOMAIN, + climate.SERVICE_SET_HVAC_MODE, + { + ATTR_ENTITY_ID: self.state.entity_id, + climate.ATTR_HVAC_MODE: self.google_to_hvac[target_mode], + }, + blocking=True, + context=data.context, + ) + + +@register_trait +class HumiditySettingTrait(_Trait): + """Trait to offer humidity setting functionality. + + https://developers.google.com/actions/smarthome/traits/humiditysetting + """ + + name = TRAIT_HUMIDITY_SETTING + commands = [] + + @staticmethod + def supported(domain, features, device_class): + """Test if state is supported.""" + return domain == sensor.DOMAIN and device_class == sensor.DEVICE_CLASS_HUMIDITY + + def sync_attributes(self): + """Return humidity attributes for a sync request.""" + response = {} + attrs = self.state.attributes + domain = self.state.domain + if domain == sensor.DOMAIN: + device_class = attrs.get(ATTR_DEVICE_CLASS) + if device_class == sensor.DEVICE_CLASS_HUMIDITY: + response["queryOnlyHumiditySetting"] = True + + return response + + def query_attributes(self): + """Return humidity query attributes.""" + response = {} + attrs = self.state.attributes + domain = self.state.domain + if domain == sensor.DOMAIN: + device_class = attrs.get(ATTR_DEVICE_CLASS) + if device_class == sensor.DEVICE_CLASS_HUMIDITY: + current_humidity = self.state.state + if current_humidity not in (STATE_UNKNOWN, STATE_UNAVAILABLE): + response["humidityAmbientPercent"] = round(float(current_humidity)) + + return response + + async def execute(self, command, data, params, challenge): + """Execute a humidity command.""" + domain = self.state.domain + if domain == sensor.DOMAIN: + raise SmartHomeError( + ERR_NOT_SUPPORTED, "Execute is not supported by sensor" + ) @register_trait @@ -723,34 +918,133 @@ class LockUnlockTrait(_Trait): """ name = TRAIT_LOCKUNLOCK - commands = [ - COMMAND_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} + return {"isLocked": self.state.state == STATE_LOCKED} async def execute(self, command, data, params, challenge): """Execute an LockUnlock command.""" - if params['lock']: + if params["lock"]: service = lock.SERVICE_LOCK else: - _verify_pin_challenge(data, challenge) + _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) + await self.hass.services.async_call( + lock.DOMAIN, + service, + {ATTR_ENTITY_ID: self.state.entity_id}, + blocking=True, + context=data.context, + ) + + +@register_trait +class ArmDisArmTrait(_Trait): + """Trait to Arm or Disarm a Security System. + + https://developers.google.com/actions/smarthome/traits/armdisarm + """ + + name = TRAIT_ARMDISARM + commands = [COMMAND_ARMDISARM] + + state_to_service = { + STATE_ALARM_ARMED_HOME: SERVICE_ALARM_ARM_HOME, + STATE_ALARM_ARMED_AWAY: SERVICE_ALARM_ARM_AWAY, + STATE_ALARM_ARMED_NIGHT: SERVICE_ALARM_ARM_NIGHT, + STATE_ALARM_ARMED_CUSTOM_BYPASS: SERVICE_ALARM_ARM_CUSTOM_BYPASS, + STATE_ALARM_TRIGGERED: SERVICE_ALARM_TRIGGER, + } + + @staticmethod + def supported(domain, features, device_class): + """Test if state is supported.""" + return domain == alarm_control_panel.DOMAIN + + @staticmethod + def might_2fa(domain, features, device_class): + """Return if the trait might ask for 2FA.""" + return True + + def sync_attributes(self): + """Return ArmDisarm attributes for a sync request.""" + response = {} + levels = [] + for state in self.state_to_service: + # level synonyms are generated from state names + # 'armed_away' becomes 'armed away' or 'away' + level_synonym = [state.replace("_", " ")] + if state != STATE_ALARM_TRIGGERED: + level_synonym.append(state.split("_")[1]) + + level = { + "level_name": state, + "level_values": [{"level_synonym": level_synonym, "lang": "en"}], + } + levels.append(level) + response["availableArmLevels"] = {"levels": levels, "ordered": False} + return response + + def query_attributes(self): + """Return ArmDisarm query attributes.""" + if "post_pending_state" in self.state.attributes: + armed_state = self.state.attributes["post_pending_state"] + else: + armed_state = self.state.state + response = {"isArmed": armed_state in self.state_to_service} + if response["isArmed"]: + response.update({"currentArmLevel": armed_state}) + return response + + async def execute(self, command, data, params, challenge): + """Execute an ArmDisarm command.""" + if params["arm"] and not params.get("cancel"): + if self.state.state == params["armLevel"]: + raise SmartHomeError(ERR_ALREADY_ARMED, "System is already armed") + if self.state.attributes["code_arm_required"]: + _verify_pin_challenge(data, self.state, challenge) + service = self.state_to_service[params["armLevel"]] + # disarm the system without asking for code when + # 'cancel' arming action is received while current status is pending + elif ( + params["arm"] + and params.get("cancel") + and self.state.state == STATE_ALARM_PENDING + ): + service = SERVICE_ALARM_DISARM + else: + if self.state.state == STATE_ALARM_DISARMED: + raise SmartHomeError(ERR_ALREADY_DISARMED, "System is already disarmed") + _verify_pin_challenge(data, self.state, challenge) + service = SERVICE_ALARM_DISARM + + await self.hass.services.async_call( + alarm_control_panel.DOMAIN, + service, + { + ATTR_ENTITY_ID: self.state.entity_id, + ATTR_CODE: data.config.secure_devices_pin, + }, + blocking=True, + context=data.context, + ) @register_trait @@ -761,17 +1055,13 @@ class FanSpeedTrait(_Trait): """ name = TRAIT_FANSPEED - commands = [ - COMMAND_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' - ] + 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 @@ -791,20 +1081,18 @@ def sync_attributes(self): continue speed = { "speed_name": mode, - "speed_values": [{ - "speed_synonym": self.speed_synonyms.get(mode), - "lang": 'en' - }] + "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) + "availableFanSpeeds": {"speeds": speeds, "ordered": True}, + "reversible": bool( + self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + & fan.SUPPORT_DIRECTION + ), } def query_attributes(self): @@ -814,19 +1102,21 @@ def query_attributes(self): speed = attrs.get(fan.ATTR_SPEED) if speed is not None: - response['on'] = speed != fan.SPEED_OFF - response['online'] = True - response['currentFanSpeedSetting'] = speed + 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) + 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 @@ -837,92 +1127,11 @@ class ModesTrait(_Trait): """ name = TRAIT_MODES - commands = [ - COMMAND_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'] + SYNONYMS = { + "input source": ["input source", "input", "source"], + "sound mode": ["sound mode", "effects"], } @staticmethod @@ -931,48 +1140,52 @@ def supported(domain, features, device_class): if domain != media_player.DOMAIN: return False - return features & media_player.SUPPORT_SELECT_SOURCE + return ( + features & media_player.SUPPORT_SELECT_SOURCE + or features & media_player.SUPPORT_SELECT_SOUND_MODE + ) 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" - }], + + def _generate(name, settings): + mode = { + "name": name, + "name_values": [ + {"name_synonym": self.SYNONYMS.get(name, [name]), "lang": "en"} + ], "settings": [], - "ordered": False + "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( + for setting in settings: + mode["settings"].append( { - "setting_name": src, - "setting_values": [{ - "setting_synonym": synonyms, - "lang": "en" - }] + "setting_name": setting, + "setting_values": [ + { + "setting_synonym": self.SYNONYMS.get( + setting, [setting] + ), + "lang": "en", + } + ], } ) - if sources: - modes.append(sources) - payload = {'availableModes': modes} + return mode + + attrs = self.state.attributes + modes = [] + if media_player.ATTR_INPUT_SOURCE_LIST in attrs: + modes.append( + _generate("input source", attrs[media_player.ATTR_INPUT_SOURCE_LIST]) + ) + + if media_player.ATTR_SOUND_MODE_LIST in attrs: + modes.append( + _generate("sound mode", attrs[media_player.ATTR_SOUND_MODE_LIST]) + ) + + payload = {"availableModes": modes} return payload @@ -982,36 +1195,48 @@ def query_attributes(self): 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 media_player.ATTR_INPUT_SOURCE_LIST in attrs: + mode_settings["input source"] = attrs.get(media_player.ATTR_INPUT_SOURCE) + + if media_player.ATTR_SOUND_MODE_LIST in attrs: + mode_settings["sound mode"] = attrs.get(media_player.ATTR_SOUND_MODE) + if mode_settings: - response['on'] = self.state.state != STATE_OFF - response['online'] = True - response['currentModeSettings'] = 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)) + settings = params.get("updateModeSettings") + requested_source = settings.get("input source") + sound_mode = settings.get("sound mode") 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: requested_source, + }, + blocking=True, + context=data.context, + ) - 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) + if sound_mode: + await self.hass.services.async_call( + media_player.DOMAIN, + media_player.SERVICE_SELECT_SOUND_MODE, + { + ATTR_ENTITY_ID: self.state.entity_id, + media_player.ATTR_SOUND_MODE: sound_mode, + }, + blocking=True, + context=data.context, + ) @register_trait @@ -1021,10 +1246,15 @@ class OpenCloseTrait(_Trait): https://developers.google.com/actions/smarthome/traits/openclose """ + # Cover device classes that require 2FA + COVER_2FA = ( + cover.DEVICE_CLASS_DOOR, + cover.DEVICE_CLASS_GARAGE, + cover.DEVICE_CLASS_GATE, + ) + name = TRAIT_OPENCLOSE - commands = [ - COMMAND_OPENCLOSE - ] + commands = [COMMAND_OPENCLOSE] override_position = None @@ -1042,11 +1272,16 @@ def supported(domain, features, device_class): 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 + response["queryOnlyOpenClose"] = True return response def query_attributes(self): @@ -1055,37 +1290,37 @@ def query_attributes(self): response = {} if self.override_position is not None: - response['openPercent'] = self.override_position + 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') + ERR_NOT_SUPPORTED, "Querying state is not supported" + ) if self.state.state == STATE_UNKNOWN: raise SmartHomeError( - ERR_NOT_SUPPORTED, - 'Querying state is not supported') + 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 + response["openPercent"] = position elif self.state.state != cover.STATE_CLOSED: - response['openPercent'] = 100 + response["openPercent"] = 100 else: - response['openPercent'] = 0 + response["openPercent"] = 0 elif domain == binary_sensor.DOMAIN: if self.state.state == STATE_ON: - response['openPercent'] = 100 + response["openPercent"] = 100 else: - response['openPercent'] = 0 + response["openPercent"] = 0 return response @@ -1096,35 +1331,40 @@ async def execute(self, command, data, params, challenge): if domain == cover.DOMAIN: svc_params = {ATTR_ENTITY_ID: self.state.entity_id} - if params['openPercent'] == 0: + if params["openPercent"] == 0: service = cover.SERVICE_CLOSE_COVER should_verify = False - elif params['openPercent'] == 100: + 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): + 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'] + svc_params[cover.ATTR_POSITION] = params["openPercent"] else: raise SmartHomeError( - ERR_FUNCTION_NOT_SUPPORTED, - 'Setting a position is not supported') + ERR_FUNCTION_NOT_SUPPORTED, "Setting a position is not supported" + ) - if (should_verify and - self.state.attributes.get(ATTR_DEVICE_CLASS) - in (cover.DEVICE_CLASS_DOOR, - cover.DEVICE_CLASS_GARAGE)): - _verify_pin_challenge(data, challenge) + 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) + 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'] + if ( + self.state.attributes.get(ATTR_ASSUMED_STATE) + or self.state.state == STATE_UNKNOWN + ): + self.override_position = params["openPercent"] @register_trait @@ -1135,10 +1375,7 @@ class VolumeTrait(_Trait): """ name = TRAIT_VOLUME - commands = [ - COMMAND_SET_VOLUME, - COMMAND_VOLUME_RELATIVE, - ] + commands = [COMMAND_SET_VOLUME, COMMAND_VOLUME_RELATIVE] @staticmethod def supported(domain, features, device_class): @@ -1156,40 +1393,44 @@ 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) + 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) + response["currentVolume"] = int(level * 100) + response["isMuted"] = bool(muted) return response async def _execute_set_volume(self, data, params): - level = params['volumeLevel'] + level = params["volumeLevel"] await self.hass.services.async_call( media_player.DOMAIN, - media_player.SERVICE_VOLUME_SET, { + 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) + 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) + 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, { + 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) + 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.""" @@ -1198,26 +1439,28 @@ async def execute(self, command, data, params, challenge): elif command == COMMAND_VOLUME_RELATIVE: await self._execute_volume_relative(data, params) else: - raise SmartHomeError( - ERR_NOT_SUPPORTED, 'Command not supported') + raise SmartHomeError(ERR_NOT_SUPPORTED, "Command not supported") -def _verify_pin_challenge(data, challenge): +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') + raise SmartHomeError(ERR_CHALLENGE_NOT_SETUP, "Challenge is not set up") if not challenge: raise ChallengeNeeded(CHALLENGE_PIN_NEEDED) - pin = challenge.get('pin') + pin = challenge.get("pin") if pin != data.config.secure_devices_pin: raise ChallengeNeeded(CHALLENGE_FAILED_PIN_NEEDED) -def _verify_ack_challenge(data, challenge): - """Verify a pin challenge.""" - if not challenge or not challenge.get('ack'): +def _verify_ack_challenge(data, state, challenge): + """Verify an ack challenge.""" + if not data.config.should_2fa(state): + return + 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..12d761786d3a4 --- /dev/null +++ b/homeassistant/components/google_cloud/manifest.json @@ -0,0 +1,7 @@ +{ + "domain": "google_cloud", + "name": "Google Cloud Platform", + "documentation": "https://www.home-assistant.io/integrations/google_cloud", + "requirements": ["google-cloud-texttospeech==0.4.0"], + "codeowners": ["@lufton"] +} diff --git a/homeassistant/components/google_cloud/tts.py b/homeassistant/components/google_cloud/tts.py new file mode 100644 index 0000000000000..96bd9e93919e6 --- /dev/null +++ b/homeassistant/components/google_cloud/tts.py @@ -0,0 +1,270 @@ +"""Support for the Google Cloud TTS service.""" +import asyncio +import logging +import os + +import async_timeout +from google.cloud import texttospeech +import voluptuous as vol + +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 = [ + "ar-XA", + "bn-IN", + "cmn-CN", + "cs-CZ", + "da-DK", + "de-DE", + "el-GR", + "en-AU", + "en-GB", + "en-IN", + "en-US", + "es-ES", + "fi-FI", + "fil-PH", + "fr-CA", + "fr-FR", + "gu-IN", + "hi-IN", + "hu-HU", + "id-ID", + "it-IT", + "ja-JP", + "kn-IN", + "ko-KR", + "ml-IN", + "nb-NO", + "nl-NL", + "pl-PL", + "pt-BR", + "pt-PT", + "ru-RU", + "sk-SK", + "sv-SE", + "ta-IN", + "te-IN", + "th-TH", + "tr-TR", + "uk-UA", + "vi-VN", +] +DEFAULT_LANG = "en-US" + +DEFAULT_GENDER = "NEUTRAL" + +VOICE_REGEX = r"[a-z]{2,3}-[A-Z]{2}-(Standard|Wavenet)-[A-Z]|" +DEFAULT_VOICE = "" + +DEFAULT_ENCODING = "MP3" + +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, discovery_info=None): + """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 occurred 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 index f884e46cc4c11..ae6cb5c70d5d0 100644 --- a/homeassistant/components/google_domains/__init__.py +++ b/homeassistant/components/google_domains/__init__.py @@ -7,28 +7,30 @@ import async_timeout import voluptuous as vol +from homeassistant.const import CONF_DOMAIN, CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME 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' +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) +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): @@ -41,47 +43,41 @@ async def async_setup(hass, config): session = hass.helpers.aiohttp_client.async_get_clientsession() result = await _update_google_domains( - hass, session, domain, user, password, timeout) + 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) + await _update_google_domains(hass, session, domain, user, password, timeout) - hass.helpers.event.async_track_time_interval( - update_domain_interval, INTERVAL) + hass.helpers.event.async_track_time_interval(update_domain_interval, INTERVAL) return True -async def _update_google_domains( - hass, session, domain, user, password, timeout): +async def _update_google_domains(hass, session, domain, user, password, timeout): """Update Google Domains.""" - url = UPDATE_URL.format(user, password) + url = f"https://{user}:{password}@domains.google.com/nic/update" - params = { - 'hostname': domain - } + params = {"hostname": domain} try: - with async_timeout.timeout(timeout, loop=hass.loop): + with async_timeout.timeout(timeout): resp = await session.get(url, params=params) body = await resp.text() - if body.startswith('good') or body.startswith('nochg'): + if body.startswith("good") or body.startswith("nochg"): return True - _LOGGER.warning('Updating Google Domains failed: %s => %s', - domain, body) + _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) + _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 index 190e5860ee60f..3372bb3f97df5 100644 --- a/homeassistant/components/google_domains/manifest.json +++ b/homeassistant/components/google_domains/manifest.json @@ -1,8 +1,6 @@ { "domain": "google_domains", - "name": "Google domains", - "documentation": "https://www.home-assistant.io/components/google_domains", - "requirements": [], - "dependencies": [], + "name": "Google Domains", + "documentation": "https://www.home-assistant.io/integrations/google_domains", "codeowners": [] } diff --git a/homeassistant/components/google_maps/device_tracker.py b/homeassistant/components/google_maps/device_tracker.py index 5788392190aa8..7b48c12cc93d4 100644 --- a/homeassistant/components/google_maps/device_tracker.py +++ b/homeassistant/components/google_maps/device_tracker.py @@ -2,36 +2,40 @@ from datetime import timedelta import logging +from locationsharinglib import Service +from locationsharinglib.locationsharinglibexceptions import InvalidCookies import voluptuous as vol -from homeassistant.components.device_tracker import ( - PLATFORM_SCHEMA, SOURCE_TYPE_GPS) +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) + ATTR_BATTERY_CHARGING, + ATTR_BATTERY_LEVEL, + ATTR_ID, + CONF_SCAN_INTERVAL, + CONF_USERNAME, +) 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 +from homeassistant.util import dt as dt_util, slugify _LOGGER = logging.getLogger(__name__) -ATTR_ADDRESS = 'address' -ATTR_FULL_NAME = 'full_name' -ATTR_LAST_SEEN = 'last_seen' -ATTR_NICKNAME = 'nickname' +ATTR_ADDRESS = "address" +ATTR_FULL_NAME = "full_name" +ATTR_LAST_SEEN = "last_seen" +ATTR_NICKNAME = "nickname" -CONF_MAX_GPS_ACCURACY = 'max_gps_accuracy' +CONF_MAX_GPS_ACCURACY = "max_gps_accuracy" -CREDENTIALS_FILE = '.google_maps_location_sharing.cookies' +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), -}) +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + 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): @@ -45,53 +49,67 @@ class GoogleMapsScanner: 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] + self.scan_interval = config.get(CONF_SCAN_INTERVAL) or timedelta(seconds=60) + self._prev_seen = {} + credfile = f"{hass.config.path(CREDENTIALS_FILE)}.{slugify(self.username)}" try: - credfile = "{}.{}".format(hass.config.path(CREDENTIALS_FILE), - slugify(self.username)) - self.service = Service(self.username, self.password, credfile) + self.service = Service(credfile, self.username) self._update_info() - track_time_interval( - hass, self._update_info, MIN_TIME_BETWEEN_SCANS) + track_time_interval(hass, self._update_info, self.scan_interval) self.success_init = True - except InvalidUser: - _LOGGER.error("You have specified invalid login credentials") + except InvalidCookies: + _LOGGER.error( + "The cookie file provided does not provide a valid session. Please create another one and try again." + ) 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)) + dev_id = f"google_maps_{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) + 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 + + last_seen = dt_util.as_utc(person.datetime) + if last_seen < self._prev_seen.get(dev_id, last_seen): + _LOGGER.warning( + "Ignoring %s update because timestamp " + "is older than last timestamp", + person.nickname, + ) + _LOGGER.debug("%s < %s", last_seen, self._prev_seen[dev_id]) continue + self._prev_seen[dev_id] = last_seen 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_LAST_SEEN: last_seen, ATTR_NICKNAME: person.nickname, ATTR_BATTERY_CHARGING: person.charging, - ATTR_BATTERY_LEVEL: person.battery_level + ATTR_BATTERY_LEVEL: person.battery_level, } self.see( dev_id=dev_id, diff --git a/homeassistant/components/google_maps/manifest.json b/homeassistant/components/google_maps/manifest.json index 7d6aeeef041bb..62791c212f9ca 100644 --- a/homeassistant/components/google_maps/manifest.json +++ b/homeassistant/components/google_maps/manifest.json @@ -1,10 +1,7 @@ { "domain": "google_maps", - "name": "Google maps", - "documentation": "https://www.home-assistant.io/components/google_maps", - "requirements": [ - "locationsharinglib==3.0.11" - ], - "dependencies": [], + "name": "Google Maps", + "documentation": "https://www.home-assistant.io/integrations/google_maps", + "requirements": ["locationsharinglib==4.1.0"], "codeowners": [] } diff --git a/homeassistant/components/google_pubsub/__init__.py b/homeassistant/components/google_pubsub/__init__.py index 8aaa7a17ac44c..bc7811a7a8f0c 100644 --- a/homeassistant/components/google_pubsub/__init__.py +++ b/homeassistant/components/google_pubsub/__init__.py @@ -5,42 +5,47 @@ import os from typing import Any, Dict +from google.cloud import pubsub_v1 import voluptuous as vol -from homeassistant.const import ( - EVENT_STATE_CHANGED, STATE_UNAVAILABLE, STATE_UNKNOWN) +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' +DOMAIN = "google_pubsub" -CONF_PROJECT_ID = 'project_id' -CONF_TOPIC_NAME = 'topic_name' -CONF_SERVICE_PRINCIPAL = 'credentials_json' -CONF_FILTER = 'filter' +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) +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]) + 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") @@ -48,29 +53,28 @@ def setup(hass: HomeAssistant, yaml_config: Dict[str, Any]): entities_filter = config[CONF_FILTER] - publisher = (pubsub_v1 - .PublisherClient - .from_service_account_json(service_principal_path) - ) + publisher = pubsub_v1.PublisherClient.from_service_account_json( + service_principal_path + ) - topic_path = publisher.topic_path(project_id, # pylint: disable=E1101 - topic_name) + topic_path = publisher.topic_path( # pylint: disable=no-member + project_id, 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)): + 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') + data = json.dumps(obj=as_dict, default=encoder.encode).encode("utf-8") publisher.publish(topic_path, data=data) @@ -85,7 +89,7 @@ class DateTimeJSONEncoder(json.JSONEncoder): Additionally add encoding for datetime objects as isoformat. """ - def default(self, o): # pylint: disable=E0202 + def default(self, o): # pylint: disable=method-hidden """Implement encoding logic.""" if isinstance(o, datetime.datetime): return o.isoformat() diff --git a/homeassistant/components/google_pubsub/manifest.json b/homeassistant/components/google_pubsub/manifest.json index ff61ad0e05df5..c879788f2c080 100644 --- a/homeassistant/components/google_pubsub/manifest.json +++ b/homeassistant/components/google_pubsub/manifest.json @@ -1,10 +1,7 @@ { "domain": "google_pubsub", - "name": "Google pubsub", - "documentation": "https://www.home-assistant.io/components/google_pubsub", - "requirements": [ - "google-cloud-pubsub==0.39.1" - ], - "dependencies": [], + "name": "Google Pub/Sub", + "documentation": "https://www.home-assistant.io/integrations/google_pubsub", + "requirements": ["google-cloud-pubsub==0.39.1"], "codeowners": [] } diff --git a/homeassistant/components/google_translate/manifest.json b/homeassistant/components/google_translate/manifest.json index cb3cd350c04a9..452a5352aac1e 100644 --- a/homeassistant/components/google_translate/manifest.json +++ b/homeassistant/components/google_translate/manifest.json @@ -1,12 +1,7 @@ { "domain": "google_translate", - "name": "Google Translate", - "documentation": "https://www.home-assistant.io/components/google_translate", - "requirements": [ - "gTTS-token==1.1.3" - ], - "dependencies": [], - "codeowners": [ - "@awarecan" - ] + "name": "Google Translate Text-to-Speech", + "documentation": "https://www.home-assistant.io/integrations/google_translate", + "requirements": ["gTTS-token==1.1.3"], + "codeowners": ["@awarecan"] } diff --git a/homeassistant/components/google_translate/tts.py b/homeassistant/components/google_translate/tts.py index 4d988bed21cfc..36543e0515efc 100644 --- a/homeassistant/components/google_translate/tts.py +++ b/homeassistant/components/google_translate/tts.py @@ -6,10 +6,12 @@ import aiohttp from aiohttp.hdrs import REFERER, USER_AGENT import async_timeout +from gtts_token import gtts_token import voluptuous as vol import yarl from homeassistant.components.tts import CONF_LANG, PLATFORM_SCHEMA, Provider +from homeassistant.const import HTTP_OK from homeassistant.helpers.aiohttp_client import async_get_clientsession _LOGGER = logging.getLogger(__name__) @@ -18,21 +20,70 @@ 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' + "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' +DEFAULT_LANG = "en" -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_LANG, default=DEFAULT_LANG): vol.In(SUPPORT_LANGUAGES), -}) +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + {vol.Optional(CONF_LANG, default=DEFAULT_LANG): vol.In(SUPPORT_LANGUAGES)} +) -async def async_get_engine(hass, config): +async def async_get_engine(hass, config, discovery_info=None): """Set up Google speech component.""" return GoogleProvider(hass, config[CONF_LANG]) @@ -46,11 +97,13 @@ def __init__(self, hass, lang): 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"), + 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' + self.name = "Google" @property def default_language(self): @@ -64,38 +117,36 @@ def supported_languages(self): 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'' + data = b"" for idx, part in enumerate(message_parts): - part_token = await self.hass.async_add_job( - token.calculate_token, part) + 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), + "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, loop=self.hass.loop): + with async_timeout.timeout(10): request = await websession.get( - GOOGLE_SPEECH_URL, params=url_param, - headers=self.headers + 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) + if request.status != HTTP_OK: + _LOGGER.error( + "Error %d on load URL %s", request.status, request.url + ) return None, None data += await request.read() @@ -103,7 +154,7 @@ async def async_get_tts_audio(self, message, language, options=None): _LOGGER.error("Timeout for google speech") return None, None - return 'mp3', data + return "mp3", data @staticmethod def _split_message_to_parts(message): @@ -113,13 +164,13 @@ def _split_message_to_parts(message): punc = "!()[]?.,;:" punc_list = [re.escape(c) for c in punc] - pattern = '|'.join(punc_list) + 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) + idx = fullstring.rfind(" ", 0, MESSAGE_SIZE) return [fullstring[:idx]] + split_by_space(fullstring[idx:]) return [fullstring] diff --git a/homeassistant/components/google_travel_time/manifest.json b/homeassistant/components/google_travel_time/manifest.json index eaa168332a63d..8f235cf994789 100644 --- a/homeassistant/components/google_travel_time/manifest.json +++ b/homeassistant/components/google_travel_time/manifest.json @@ -1,12 +1,7 @@ { "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" - ] + "name": "Google Maps Travel Time", + "documentation": "https://www.home-assistant.io/integrations/google_travel_time", + "requirements": ["googlemaps==2.5.1"], + "codeowners": ["@robbiet480"] } diff --git a/homeassistant/components/google_travel_time/sensor.py b/homeassistant/components/google_travel_time/sensor.py index ef4fc76f53ea1..098c6d2d59c9a 100644 --- a/homeassistant/components/google_travel_time/sensor.py +++ b/homeassistant/components/google_travel_time/sensor.py @@ -1,73 +1,134 @@ """Support for Google travel time sensors.""" +from datetime import datetime, timedelta import logging -from datetime import datetime -from datetime import timedelta +import googlemaps 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) + ATTR_ATTRIBUTION, + ATTR_LATITUDE, + ATTR_LONGITUDE, + CONF_API_KEY, + CONF_MODE, + CONF_NAME, + EVENT_HOMEASSISTANT_START, + TIME_MINUTES, +) from homeassistant.helpers import location +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__) -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' +ATTRIBUTION = "Powered by Google" + +CONF_DESTINATION = "destination" +CONF_OPTIONS = "options" +CONF_ORIGIN = "origin" +CONF_TRAVEL_MODE = "travel_mode" + +DEFAULT_NAME = "Google Travel Time" + +SCAN_INTERVAL = 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)) + 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) @@ -75,6 +136,7 @@ def convert_time_to_utc(timestr): 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. @@ -84,28 +146,31 @@ def run_setup(event): hass.data.setdefault(DATA_KEY, []) options = config.get(CONF_OPTIONS) - if options.get('units') is None: - options['units'] = hass.config.units.name + 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!") + 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) + formatted_name = f"{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, name, api_key, origin, destination, options + ) hass.data[DATA_KEY].append(sensor) if sensor.valid_api_connection: @@ -123,27 +188,26 @@ def __init__(self, hass, name, api_key, origin, destination, options): self._hass = hass self._name = name self._options = options - self._unit_of_measurement = 'min' + self._unit_of_measurement = TIME_MINUTES self._matrix = None self.valid_api_connection = True # Check if location is a trackable entity - if origin.split('.', 1)[0] in TRACKABLE_DOMAINS: + 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: + 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) + _LOGGER.error(exp) self.valid_api_connection = False return @@ -153,11 +217,11 @@ def state(self): 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) + _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 @@ -173,14 +237,17 @@ def device_state_attributes(self): 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'] + 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"] + res["origin"] = self._origin + res["destination"] = self._destination + res[ATTR_ATTRIBUTION] = ATTRIBUTION return res @property @@ -188,31 +255,28 @@ 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) + 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 + options_copy["departure_time"] = dtime elif atime is None: - options_copy['departure_time'] = 'now' + options_copy["departure_time"] = "now" - if atime is not None and ':' in atime: - options_copy['arrival_time'] = convert_time_to_utc(atime) + 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 + 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, "_origin_entity_id"): + self._origin = self._get_location_from_entity(self._origin_entity_id) - if hasattr(self, '_destination_entity_id'): + if hasattr(self, "_destination_entity_id"): self._destination = self._get_location_from_entity( self._destination_entity_id ) @@ -222,7 +286,8 @@ def update(self): if self._destination is not None and self._origin is not None: self._matrix = self._client.distance_matrix( - self._origin, self._destination, **options_copy) + self._origin, self._destination, **options_copy + ) def _get_location_from_entity(self, entity_id): """Get the location from the entity state or attributes.""" @@ -241,8 +306,7 @@ def _get_location_from_entity(self, entity_id): 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 + "%s is in %s, getting zone location", entity_id, zone_entity.entity_id ) return self._get_location_from_attributes(zone_entity) @@ -257,12 +321,12 @@ def _get_location_from_entity(self, entity_id): 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)) + return f"{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: + 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/manifest.json b/homeassistant/components/google_wifi/manifest.json index 6e840458207e1..285152239d38c 100644 --- a/homeassistant/components/google_wifi/manifest.json +++ b/homeassistant/components/google_wifi/manifest.json @@ -1,8 +1,6 @@ { "domain": "google_wifi", - "name": "Google wifi", - "documentation": "https://www.home-assistant.io/components/google_wifi", - "requirements": [], - "dependencies": [], + "name": "Google Wifi", + "documentation": "https://www.home-assistant.io/integrations/google_wifi", "codeowners": [] } diff --git a/homeassistant/components/google_wifi/sensor.py b/homeassistant/components/google_wifi/sensor.py index 202e2a0eb466b..9dfa26fab75c6 100644 --- a/homeassistant/components/google_wifi/sensor.py +++ b/homeassistant/components/google_wifi/sensor.py @@ -1,74 +1,60 @@ """Support for retrieving status info from Google Wifi/OnHub routers.""" -import logging from datetime import timedelta +import logging -import voluptuous as vol import requests +import voluptuous as vol -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) + CONF_HOST, + CONF_MONITORED_CONDITIONS, + CONF_NAME, + STATE_UNKNOWN, + TIME_DAYS, +) +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity -from homeassistant.util import Throttle +from homeassistant.util import Throttle, dt _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' +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' +DEFAULT_HOST = "testwifi.here" +DEFAULT_NAME = "google_wifi" -ENDPOINT = '/api/v1/status' +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'], + ["software", "softwareVersion"], None, - 'mdi:restart' + "mdi:checkbox-marked-circle-outline", ], - ATTR_LOCAL_IP: [ - ['wan', 'localIpAddress'], - None, - 'mdi:access-point-network' - ], - ATTR_STATUS: [ - ['wan', 'online'], - None, - 'mdi:google' - ] + ATTR_NEW_VERSION: [["software", "updateNewVersion"], None, "mdi:update"], + ATTR_UPTIME: [["system", "uptime"], TIME_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, -}) +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): @@ -102,7 +88,7 @@ def __init__(self, api, name, variable): @property def name(self): """Return the name of the sensor.""" - return '{}_{}'.format(self._name, self._var_name) + return f"{self._name}_{self._var_name}" @property def icon(self): @@ -138,9 +124,9 @@ class GoogleWifiAPI: def __init__(self, host, conditions): """Initialize the data object.""" - uri = 'http://' - resource = "{}{}{}".format(uri, host, ENDPOINT) - self._request = requests.Request('GET', resource).prepare() + uri = "http://" + resource = f"{uri}{host}{ENDPOINT}" + self._request = requests.Request("GET", resource).prepare() self.raw_data = None self.conditions = conditions self.data = { @@ -149,7 +135,7 @@ def __init__(self, host, conditions): ATTR_UPTIME: STATE_UNKNOWN, ATTR_LAST_RESTART: STATE_UNKNOWN, ATTR_LOCAL_IP: STATE_UNKNOWN, - ATTR_STATUS: STATE_UNKNOWN + ATTR_STATUS: STATE_UNKNOWN, } self.available = True self.update() @@ -178,28 +164,28 @@ def data_format(self): 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' + 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') + 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' + sensor_value = "Online" else: - sensor_value = 'Offline' + sensor_value = "Offline" elif attr_key == ATTR_LOCAL_IP: - if not self.raw_data['wan']['online']: + 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) + _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 deleted file mode 100644 index 073081a963428..0000000000000 --- a/homeassistant/components/googlehome/__init__.py +++ /dev/null @@ -1,106 +0,0 @@ -"""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 deleted file mode 100644 index 3b6bc5d341c6c..0000000000000 --- a/homeassistant/components/googlehome/device_tracker.py +++ /dev/null @@ -1,79 +0,0 @@ -"""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 deleted file mode 100644 index 107e7d634f0f0..0000000000000 --- a/homeassistant/components/googlehome/manifest.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "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 deleted file mode 100644 index 088f4352fa3a8..0000000000000 --- a/homeassistant/components/googlehome/sensor.py +++ /dev/null @@ -1,95 +0,0 @@ -"""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/manifest.json b/homeassistant/components/gpmdp/manifest.json index 98ab8035023d0..c2128b27eeba2 100644 --- a/homeassistant/components/gpmdp/manifest.json +++ b/homeassistant/components/gpmdp/manifest.json @@ -1,10 +1,8 @@ { "domain": "gpmdp", - "name": "Gpmdp", - "documentation": "https://www.home-assistant.io/components/gpmdp", - "requirements": [ - "websocket-client==0.54.0" - ], + "name": "Google Play Music Desktop Player (GPMDP)", + "documentation": "https://www.home-assistant.io/integrations/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 index 76253d32db837..2fa227f0953d9 100644 --- a/homeassistant/components/gpmdp/media_player.py +++ b/homeassistant/components/gpmdp/media_player.py @@ -5,96 +5,133 @@ import time import voluptuous as vol +from websocket import _exceptions, create_connection -from homeassistant.components.media_player import ( - MediaPlayerDevice, PLATFORM_SCHEMA) +from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerEntity 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) + 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) + 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_HOST = "localhost" +DEFAULT_NAME = "GPM Desktop Player" DEFAULT_PORT = 5672 -GPMDP_CONFIG_FILE = 'gpmpd.conf' +GPMDP_CONFIG_FILE = "gpmpd.conf" -SUPPORT_GPMDP = SUPPORT_PAUSE | SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | \ - SUPPORT_SEEK | SUPPORT_VOLUME_SET | SUPPORT_PLAY +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} +PLAYBACK_DICT = {"0": STATE_PAUSED, "1": STATE_PAUSED, "2": STATE_PLAYING} # Stopped -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, -}) +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: + if "gpmdp" in _CONFIGURING: configurator.notify_errors( - _CONFIGURING['gpmdp'], "Failed to register, please try again.") + _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'] - })) + 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': + if msg["channel"] != "connect": continue - if msg['payload'] != "CODE_REQUIRED": + if msg["payload"] != "CODE_REQUIRED": continue - pin = callback_data.get('pin') - websocket.send(json.dumps({'namespace': 'connect', - 'method': 'connect', - 'arguments': ['Home Assistant', pin]})) + 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") + 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': + code = tmpmsg["payload"] + if code == "CODE_REQUIRED": continue - setup_gpmdp(hass, config, code, - add_entities_callback) + 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.send( + json.dumps( + { + "namespace": "connect", + "method": "connect", + "arguments": ["Home Assistant", code], + } + ) + ) websocket.close() break - _CONFIGURING['gpmdp'] = configurator.request_config( - DEFAULT_NAME, gpmdp_configuration_callback, + _CONFIGURING["gpmdp"] = configurator.request_config( + DEFAULT_NAME, + gpmdp_configuration_callback, description=( - 'Enter the pin that is displayed in the ' - 'Google Play Music Desktop Player.'), + "Enter the pin that is displayed in the " + "Google Play Music Desktop Player." + ), submit_caption="Submit", - fields=[{'id': 'pin', 'name': 'Pin Code', 'type': 'number'}] + fields=[{"id": "pin", "name": "Pin Code", "type": "number"}], ) @@ -103,15 +140,15 @@ def setup_gpmdp(hass, config, code, add_entities): name = config.get(CONF_NAME) host = config.get(CONF_HOST) port = config.get(CONF_PORT) - url = 'ws://{}:{}'.format(host, port) + url = f"ws://{host}:{port}" if not code: request_configuration(hass, config, url, add_entities) return - if 'gpmdp' in _CONFIGURING: + if "gpmdp" in _CONFIGURING: configurator = hass.components.configurator - configurator.request_done(_CONFIGURING.pop('gpmdp')) + configurator.request_done(_CONFIGURING.pop("gpmdp")) add_entities([GPMDP(name, url, code)], True) @@ -120,9 +157,9 @@ 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') + code = codeconfig.get("CODE") elif discovery_info is not None: - if 'gpmdp' in _CONFIGURING: + if "gpmdp" in _CONFIGURING: return code = None else: @@ -130,12 +167,12 @@ def setup_platform(hass, config, add_entities, discovery_info=None): setup_gpmdp(hass, config, code, add_entities) -class GPMDP(MediaPlayerDevice): +class GPMDP(MediaPlayerEntity): """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 @@ -156,40 +193,51 @@ def get_ws(self): 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]}) + msg = json.dumps( + { + "namespace": "connect", + "method": "connect", + "arguments": ["Home Assistant", self._authorization_code], + } + ) self._ws.send(msg) - except (socket.timeout, ConnectionRefusedError, - ConnectionResetError): + 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})) + 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: + if "requestID" in msg: + if msg["requestID"] == self._request_id: return msg - except (ConnectionRefusedError, ConnectionResetError, - _exceptions.WebSocketTimeoutException, - _exceptions.WebSocketProtocolException, - _exceptions.WebSocketPayloadException, - _exceptions.WebSocketConnectionClosedException): + except ( + ConnectionRefusedError, + ConnectionResetError, + _exceptions.WebSocketTimeoutException, + _exceptions.WebSocketProtocolException, + _exceptions.WebSocketPayloadException, + _exceptions.WebSocketConnectionClosedException, + ): self._ws = None def update(self): @@ -197,22 +245,22 @@ def update(self): time.sleep(1) try: self._available = True - playstate = self.send_gpmdp_msg('playback', 'getPlaybackState') + 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') + 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') + 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') + 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 + self._volume = volume_data["value"] / 100 except OSError: self._available = False @@ -273,21 +321,21 @@ def supported_features(self): def media_next_track(self): """Send media_next command to media player.""" - self.send_gpmdp_msg('playback', 'forward', False) + 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) + 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.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.send_gpmdp_msg("playback", "playPause", False) self._status = STATE_PAUSED self.schedule_update_ha_state() @@ -296,9 +344,15 @@ def media_seek(self, position): websocket = self.get_ws() if websocket is None: return - websocket.send(json.dumps({'namespace': 'playback', - 'method': 'setCurrentTime', - 'arguments': [position*1000]})) + websocket.send( + json.dumps( + { + "namespace": "playback", + "method": "setCurrentTime", + "arguments": [position * 1000], + } + ) + ) self.schedule_update_ha_state() def volume_up(self): @@ -322,7 +376,13 @@ def set_volume_level(self, volume): websocket = self.get_ws() if websocket is None: return - websocket.send(json.dumps({'namespace': 'volume', - 'method': 'setVolume', - 'arguments': [volume*100]})) + websocket.send( + json.dumps( + { + "namespace": "volume", + "method": "setVolume", + "arguments": [volume * 100], + } + ) + ) self.schedule_update_ha_state() diff --git a/homeassistant/components/gpsd/manifest.json b/homeassistant/components/gpsd/manifest.json index b35d5cb1850c6..2a2bf0ffd3661 100644 --- a/homeassistant/components/gpsd/manifest.json +++ b/homeassistant/components/gpsd/manifest.json @@ -1,12 +1,7 @@ { "domain": "gpsd", - "name": "Gpsd", - "documentation": "https://www.home-assistant.io/components/gpsd", - "requirements": [ - "gps3==0.33.3" - ], - "dependencies": [], - "codeowners": [ - "@fabaff" - ] + "name": "GPSD", + "documentation": "https://www.home-assistant.io/integrations/gpsd", + "requirements": ["gps3==0.33.3"], + "codeowners": ["@fabaff"] } diff --git a/homeassistant/components/gpsd/sensor.py b/homeassistant/components/gpsd/sensor.py index cccf59a822a34..ea238269e598e 100644 --- a/homeassistant/components/gpsd/sensor.py +++ b/homeassistant/components/gpsd/sensor.py @@ -1,32 +1,40 @@ """Support for GPSD.""" import logging +import socket +from gps3.agps3threaded import AGPS3mechanism 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 + ATTR_LATITUDE, + ATTR_LONGITUDE, + ATTR_MODE, + CONF_HOST, + CONF_NAME, + CONF_PORT, +) import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) -ATTR_CLIMB = 'climb' -ATTR_ELEVATION = 'elevation' -ATTR_GPS_TIME = 'gps_time' -ATTR_MODE = 'mode' -ATTR_SPEED = 'speed' +ATTR_CLIMB = "climb" +ATTR_ELEVATION = "elevation" +ATTR_GPS_TIME = "gps_time" +ATTR_SPEED = "speed" -DEFAULT_HOST = 'localhost' -DEFAULT_NAME = 'GPS' +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, -}) +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): @@ -44,14 +52,13 @@ def setup_platform(hass, config, add_entities, discovery_info=None): # 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: + except OSError: _LOGGER.error("Not able to connect to GPSD") return False @@ -63,8 +70,6 @@ class GpsdSensor(Entity): 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 diff --git a/homeassistant/components/gpslogger/.translations/bg.json b/homeassistant/components/gpslogger/.translations/bg.json deleted file mode 100644 index 6f06d5c00c628..0000000000000 --- a/homeassistant/components/gpslogger/.translations/bg.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "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 deleted file mode 100644 index 2d3b08d236ee7..0000000000000 --- a/homeassistant/components/gpslogger/.translations/ca.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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 deleted file mode 100644 index f79a9f5d739df..0000000000000 --- a/homeassistant/components/gpslogger/.translations/cs.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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 deleted file mode 100644 index 6d5c2185718a3..0000000000000 --- a/homeassistant/components/gpslogger/.translations/da.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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 deleted file mode 100644 index 82c1dfa3e53b5..0000000000000 --- a/homeassistant/components/gpslogger/.translations/de.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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 deleted file mode 100644 index ad8f978bc5925..0000000000000 --- a/homeassistant/components/gpslogger/.translations/en.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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 deleted file mode 100644 index 960198eb04ef5..0000000000000 --- a/homeassistant/components/gpslogger/.translations/es-419.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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 deleted file mode 100644 index 7b90a5c5caa2b..0000000000000 --- a/homeassistant/components/gpslogger/.translations/es.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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 deleted file mode 100644 index ae2b217771216..0000000000000 --- a/homeassistant/components/gpslogger/.translations/fr.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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 deleted file mode 100644 index 2d1dcad217417..0000000000000 --- a/homeassistant/components/gpslogger/.translations/hu.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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 deleted file mode 100644 index aab8edbe44a0a..0000000000000 --- a/homeassistant/components/gpslogger/.translations/it.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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 deleted file mode 100644 index 2c8881034ff5f..0000000000000 --- a/homeassistant/components/gpslogger/.translations/ko.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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 deleted file mode 100644 index 78df911c8689b..0000000000000 --- a/homeassistant/components/gpslogger/.translations/lb.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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 deleted file mode 100644 index 4956cf52f267d..0000000000000 --- a/homeassistant/components/gpslogger/.translations/nl.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "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 deleted file mode 100644 index 836b5c8bc687e..0000000000000 --- a/homeassistant/components/gpslogger/.translations/no.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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 deleted file mode 100644 index 726ec2ad9b278..0000000000000 --- a/homeassistant/components/gpslogger/.translations/pl.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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 deleted file mode 100644 index 4dcfda527534a..0000000000000 --- a/homeassistant/components/gpslogger/.translations/pt.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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 deleted file mode 100644 index 366cb1735d59c..0000000000000 --- a/homeassistant/components/gpslogger/.translations/ru.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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 deleted file mode 100644 index 8e205bef437af..0000000000000 --- a/homeassistant/components/gpslogger/.translations/sl.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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 deleted file mode 100644 index 3a927a70e61e9..0000000000000 --- a/homeassistant/components/gpslogger/.translations/sv.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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 deleted file mode 100644 index f99efa91c6196..0000000000000 --- a/homeassistant/components/gpslogger/.translations/zh-Hans.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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 deleted file mode 100644 index c9d98da1afcf0..0000000000000 --- a/homeassistant/components/gpslogger/.translations/zh-Hant.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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 index 6887b85d02d68..aa95d17cbfc12 100644 --- a/homeassistant/components/gpslogger/__init__.py +++ b/homeassistant/components/gpslogger/__init__.py @@ -1,29 +1,39 @@ """Support for GPSLogger.""" import logging -import voluptuous as vol from aiohttp import web +import voluptuous as vol -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.components.device_tracker import ( + ATTR_BATTERY, + DOMAIN as DEVICE_TRACKER, +) +from homeassistant.const import ( + ATTR_LATITUDE, + ATTR_LONGITUDE, + CONF_WEBHOOK_ID, + HTTP_OK, + HTTP_UNPROCESSABLE_ENTITY, +) from homeassistant.helpers import config_entry_flow +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER + +from .const import ( + ATTR_ACCURACY, + ATTR_ACTIVITY, + ATTR_ALTITUDE, + ATTR_DEVICE, + ATTR_DIRECTION, + ATTR_PROVIDER, + ATTR_SPEED, + DOMAIN, +) _LOGGER = logging.getLogger(__name__) -DOMAIN = 'gpslogger' -TRACKER_UPDATE = '{}_tracker_update'.format(DOMAIN) +TRACKER_UPDATE = f"{DOMAIN}_tracker_update" -ATTR_ALTITUDE = 'altitude' -ATTR_ACCURACY = 'accuracy' -ATTR_ACTIVITY = 'activity' -ATTR_DEVICE = 'device' -ATTR_DIRECTION = 'direction' -ATTR_PROVIDER = 'provider' -ATTR_SPEED = 'speed' DEFAULT_ACCURACY = 200 DEFAULT_BATTERY = -1 @@ -31,25 +41,28 @@ def _id(value: str) -> str: """Coerce id by removing '-'.""" - return value.replace('-', '') + 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), -}) +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 @@ -58,36 +71,36 @@ async def handle_webhook(hass, webhook_id, 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 - ) + 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) + ATTR_ACTIVITY: data.get(ATTR_ACTIVITY), } device = data[ATTR_DEVICE] async_dispatcher_send( - hass, TRACKER_UPDATE, device, + 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 + data[ATTR_BATTERY], + data[ATTR_ACCURACY], + attrs, ) + return web.Response(text=f"Setting location for {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) + DOMAIN, "GPSLogger", entry.data[CONF_WEBHOOK_ID], handle_webhook + ) hass.async_create_task( hass.config_entries.async_forward_entry_setup(entry, DEVICE_TRACKER) @@ -98,19 +111,10 @@ async def async_setup_entry(hass, entry): 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 - - -config_entry_flow.register_webhook_flow( - DOMAIN, - 'GPSLogger Webhook', - { - 'docs_url': 'https://www.home-assistant.io/components/gpslogger/' - } -) diff --git a/homeassistant/components/gpslogger/config_flow.py b/homeassistant/components/gpslogger/config_flow.py new file mode 100644 index 0000000000000..ef90a8d16074f --- /dev/null +++ b/homeassistant/components/gpslogger/config_flow.py @@ -0,0 +1,10 @@ +"""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/integrations/gpslogger/"}, +) diff --git a/homeassistant/components/gpslogger/const.py b/homeassistant/components/gpslogger/const.py new file mode 100644 index 0000000000000..48dc9e7a4315b --- /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 index 6796782108351..d294b07ebc7c3 100644 --- a/homeassistant/components/gpslogger/device_tracker.py +++ b/homeassistant/components/gpslogger/device_tracker.py @@ -1,37 +1,177 @@ """Support for the GPSLogger device tracking.""" import logging -from homeassistant.components.device_tracker import ( - DOMAIN as DEVICE_TRACKER_DOMAIN) +from homeassistant.components.device_tracker import SOURCE_TYPE_GPS +from homeassistant.components.device_tracker.config_entry import TrackerEntity +from homeassistant.const import ( + ATTR_BATTERY_LEVEL, + ATTR_GPS_ACCURACY, + ATTR_LATITUDE, + ATTR_LONGITUDE, +) +from homeassistant.core import callback +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 GPSLOGGER_DOMAIN, TRACKER_UPDATE +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__) -DATA_KEY = '{}.{}'.format(GPSLOGGER_DOMAIN, DEVICE_TRACKER_DOMAIN) - -async def async_setup_entry(hass: HomeAssistantType, entry, async_see): +async def async_setup_entry(hass: HomeAssistantType, entry, async_add_entities): """Configure a dispatcher connection based on a config entry.""" - async def _set_location(device, gps_location, battery, accuracy, attrs): - """Fire HA event to set location.""" - await async_see( - dev_id=device, - gps=gps_location, - battery=battery, - gps_accuracy=accuracy, - attributes=attrs + + @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(TrackerEntity, 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 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 ) - hass.data[DATA_KEY] = async_dispatcher_connect( - hass, TRACKER_UPDATE, _set_location - ) - return True + # 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 -async def async_unload_entry(hass: HomeAssistantType, entry): - """Unload the config entry and remove the dispatcher connection.""" - hass.data[DATA_KEY]() - return True + 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 index 2d2166c1bb171..9afbed0d684bb 100644 --- a/homeassistant/components/gpslogger/manifest.json +++ b/homeassistant/components/gpslogger/manifest.json @@ -1,10 +1,8 @@ { "domain": "gpslogger", - "name": "Gpslogger", - "documentation": "https://www.home-assistant.io/components/gpslogger", - "requirements": [], - "dependencies": [ - "webhook" - ], + "name": "GPSLogger", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/gpslogger", + "dependencies": ["webhook"], "codeowners": [] } diff --git a/homeassistant/components/gpslogger/strings.json b/homeassistant/components/gpslogger/strings.json index d5641ef5db819..f3d4344cd49c8 100644 --- a/homeassistant/components/gpslogger/strings.json +++ b/homeassistant/components/gpslogger/strings.json @@ -1,6 +1,5 @@ { "config": { - "title": "GPSLogger Webhook", "step": { "user": { "title": "Set up the GPSLogger Webhook", @@ -15,4 +14,4 @@ "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/gpslogger/translations/bg.json b/homeassistant/components/gpslogger/translations/bg.json new file mode 100644 index 0000000000000..56843e752c929 --- /dev/null +++ b/homeassistant/components/gpslogger/translations/bg.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Home Assistant \u0442\u0440\u044f\u0431\u0432\u0430 \u0434\u0430 \u0435 \u0434\u043e\u0441\u0442\u044a\u043f\u0435\u043d \u043e\u0442 \u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442 \u0437\u0430 \u0434\u0430 \u043f\u043e\u043b\u0443\u0447\u0430\u0432\u0430 \u0441\u044a\u043e\u0431\u0449\u0435\u043d\u0438\u044f \u043e\u0442 GPSLogger.", + "one_instance_allowed": "\u041d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u0430 \u0435 \u0441\u0430\u043c\u043e \u0435\u0434\u043d\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f." + }, + "create_entry": { + "default": "\u0417\u0430 \u0434\u0430 \u0438\u0437\u043f\u0440\u0430\u0449\u0430\u0442\u0435 \u0441\u044a\u0431\u0438\u0442\u0438\u044f \u0434\u043e Home Assistant, \u0449\u0435 \u0442\u0440\u044f\u0431\u0432\u0430 \u0434\u0430 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u0435 \u0444\u0443\u043d\u043a\u0446\u0438\u044f\u0442\u0430 webhook \u0432 GPSLogger. \n\n \u041f\u043e\u043f\u044a\u043b\u043d\u0435\u0442\u0435 \u0441\u043b\u0435\u0434\u043d\u0430\u0442\u0430 \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044f: \n\n - URL: ` {webhook_url} ` \n - Method: POST \n\n \u0412\u0438\u0436\u0442\u0435 [\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u0446\u0438\u044f\u0442\u0430]({docs_url}) \u0437\u0430 \u043f\u043e\u0432\u0435\u0447\u0435 \u043f\u043e\u0434\u0440\u043e\u0431\u043d\u043e\u0441\u0442\u0438." + }, + "step": { + "user": { + "description": "\u0421\u0438\u0433\u0443\u0440\u043d\u0438 \u043b\u0438 \u0441\u0442\u0435, \u0447\u0435 \u0438\u0441\u043a\u0430\u0442\u0435 \u0434\u0430 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u0435 GPSLogger Webhook?", + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0432\u0430\u043d\u0435 \u043d\u0430 GPSLogger Webhook" + } + } + } +} \ 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..aebd839a83710 --- /dev/null +++ b/homeassistant/components/gpslogger/translations/ca.json @@ -0,0 +1,17 @@ +{ + "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 de GPSLogger?", + "title": "Configuraci\u00f3 del Webhook de 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..d3420c011ca29 --- /dev/null +++ b/homeassistant/components/gpslogger/translations/cs.json @@ -0,0 +1,17 @@ +{ + "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" + } + } + } +} \ 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..e14b3a9ceb031 --- /dev/null +++ b/homeassistant/components/gpslogger/translations/da.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Din Home Assistant-instans skal v\u00e6re tilg\u00e6ngelig 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 h\u00e6ndelser til Home Assistant skal du konfigurere webhook-funktionen i GPSLogger.\n\n Udfyld f\u00f8lgende oplysninger: \n\n - Webadresse: `{webhook_url}`\n - Metode: POST\n \nSe [dokumentationen]({docs_url}) for yderligere oplysninger." + }, + "step": { + "user": { + "description": "Er du sikker p\u00e5 at du vil konfigurere GPSLogger Webhook?", + "title": "Konfigurer 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..3aab63489c36e --- /dev/null +++ b/homeassistant/components/gpslogger/translations/de.json @@ -0,0 +1,17 @@ +{ + "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\u00f6chtest du den GPSLogger Webhook wirklich einrichten?", + "title": "GPSLogger Webhook einrichten" + } + } + } +} \ 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..46bbb23148324 --- /dev/null +++ b/homeassistant/components/gpslogger/translations/en.json @@ -0,0 +1,17 @@ +{ + "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" + } + } + } +} \ 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..a6d855dc758e1 --- /dev/null +++ b/homeassistant/components/gpslogger/translations/es-419.json @@ -0,0 +1,17 @@ +{ + "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" + } + } + } +} \ 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..8ac817cacbfe9 --- /dev/null +++ b/homeassistant/components/gpslogger/translations/es.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Tu Home Assistant debe ser accesible desde Internet para recibir mensajes de GPSLogger.", + "one_instance_allowed": "S\u00f3lo 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" + } + } + } +} \ 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..65db2a5a30008 --- /dev/null +++ b/homeassistant/components/gpslogger/translations/fr.json @@ -0,0 +1,17 @@ +{ + "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" + } + } + } +} \ 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..6080cf11c5efb --- /dev/null +++ b/homeassistant/components/gpslogger/translations/hu.json @@ -0,0 +1,17 @@ +{ + "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" + } + } + } +} \ 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..8c26f19ac5b75 --- /dev/null +++ b/homeassistant/components/gpslogger/translations/it.json @@ -0,0 +1,17 @@ +{ + "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" + } + } + } +} \ 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..3c010cb65b9f0 --- /dev/null +++ b/homeassistant/components/gpslogger/translations/ko.json @@ -0,0 +1,17 @@ +{ + "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 \uc6f9 \ud6c5\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 \uc6f9 \ud6c5\uc744 \uc124\uc815\ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?", + "title": "GPSLogger \uc6f9 \ud6c5 \uc124\uc815\ud558\uae30" + } + } + } +} \ 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..a69ac61bc988e --- /dev/null +++ b/homeassistant/components/gpslogger/translations/lb.json @@ -0,0 +1,17 @@ +{ + "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" + } + } + } +} \ 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..c5cb3b737db74 --- /dev/null +++ b/homeassistant/components/gpslogger/translations/nl.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Uw Home Assistant-instantie moet via internet toegankelijk zijn om berichten van GPSLogger te ontvangen.", + "one_instance_allowed": "Slechts \u00e9\u00e9n instantie is nodig." + }, + "create_entry": { + "default": "Om evenementen naar Home Assistant te verzenden, moet u de webhook-functie instellen in GPSLogger. \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 je zeker dat je de GPSLogger Webhook wilt instellen?", + "title": "Configureer de 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..2b3dc0f67fc2f --- /dev/null +++ b/homeassistant/components/gpslogger/translations/no.json @@ -0,0 +1,17 @@ +{ + "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 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" + } + } + } +} \ 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..76cac00025f50 --- /dev/null +++ b/homeassistant/components/gpslogger/translations/pl.json @@ -0,0 +1,17 @@ +{ + "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": "Na pewno chcesz skonfigurowa\u0107 Geofency?", + "title": "Konfiguracja Geofency Webhook" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/gpslogger/translations/pt-BR.json b/homeassistant/components/gpslogger/translations/pt-BR.json new file mode 100644 index 0000000000000..6362914f52a18 --- /dev/null +++ b/homeassistant/components/gpslogger/translations/pt-BR.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Sua inst\u00e2ncia do Home Assistant precisa estar acess\u00edvel na Internet para receber mensagens do GPSLogger.", + "one_instance_allowed": "Apenas uma \u00fanica inst\u00e2ncia \u00e9 necess\u00e1ria." + }, + "create_entry": { + "default": "Para enviar eventos para o Home Assistant, voc\u00ea precisar\u00e1 configurar o recurso webhook no GPSLogger. \n\n Preencha as seguintes informa\u00e7\u00f5es: \n\n - URL: ` {webhook_url} ` \n - M\u00e9todo: POST \n\n Veja [a documenta\u00e7\u00e3o] ( {docs_url} ) para mais detalhes." + }, + "step": { + "user": { + "description": "Tem a certeza que deseja configurar o GPSLogger Webhook?", + "title": "Configurar o GPSLogger 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..de430273ba527 --- /dev/null +++ b/homeassistant/components/gpslogger/translations/pt.json @@ -0,0 +1,17 @@ +{ + "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" + } + } + } +} \ 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..fabf247759013 --- /dev/null +++ b/homeassistant/components/gpslogger/translations/ru.json @@ -0,0 +1,17 @@ +{ + "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 Webhook \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 [\u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u044f\u043c\u0438]({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" + } + } + } +} \ 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..1d33f4506990a --- /dev/null +++ b/homeassistant/components/gpslogger/translations/sl.json @@ -0,0 +1,17 @@ +{ + "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" + } + } + } +} \ 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..db53fcff9732c --- /dev/null +++ b/homeassistant/components/gpslogger/translations/sv.json @@ -0,0 +1,17 @@ +{ + "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" + } + } + } +} \ 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..f6934cb5e4f81 --- /dev/null +++ b/homeassistant/components/gpslogger/translations/zh-Hans.json @@ -0,0 +1,17 @@ +{ + "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" + } + } + } +} \ 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..9e410084f8187 --- /dev/null +++ b/homeassistant/components/gpslogger/translations/zh-Hant.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Home Assistant \u7269\u4ef6\u5fc5\u9808\u80fd\u5920\u7531\u7db2\u969b\u7db2\u8def\u5b58\u53d6\uff0c\u65b9\u80fd\u63a5\u53d7 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" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/graphite/__init__.py b/homeassistant/components/graphite/__init__.py index e3f9e359f5a32..00dabd59d8f85 100644 --- a/homeassistant/components/graphite/__init__.py +++ b/homeassistant/components/graphite/__init__.py @@ -7,26 +7,36 @@ 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) + 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_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) +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): @@ -41,7 +51,7 @@ def setup(hass, config): sock.connect((host, port)) sock.shutdown(2) _LOGGER.debug("Connection to Graphite possible") - except socket.error: + except OSError: _LOGGER.error("Not able to connect to Graphite") return False @@ -54,12 +64,12 @@ class GraphiteFeeder(threading.Thread): def __init__(self, hass, host, port, prefix): """Initialize the feeder.""" - super(GraphiteFeeder, self).__init__(daemon=True) + super().__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._prefix = prefix.rstrip(".") self._queue = queue.Queue() self._quit_object = object() self._we_started = False @@ -67,8 +77,7 @@ def __init__(self, hass, host, port, prefix): 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) + _LOGGER.debug("Graphite feeding to %s:%i initialized", self._host, self._port) def start_listen(self, event): """Start event-processing thread.""" @@ -87,16 +96,15 @@ def event_listener(self, event): _LOGGER.debug("Received event") self._queue.put(event) else: - _LOGGER.error( - "Graphite feeder thread has died, not queuing event") + _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.sendall(data.encode("ascii")) + sock.send(b"\n") sock.close() def _report_attributes(self, entity_id, new_state): @@ -104,22 +112,23 @@ def _report_attributes(self, entity_id, new_state): now = time.time() things = dict(new_state.attributes) try: - things['state'] = state.state_as_number(new_state) + 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))] + 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)) + self._send_to_graphite("\n".join(lines)) except socket.gaierror: _LOGGER.error("Unable to connect to host %s", self._host) - except socket.error: + except OSError: _LOGGER.exception("Failed to send data to graphite") def run(self): @@ -130,19 +139,19 @@ def run(self): _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']) + 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']) + 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) + _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 index a5eefc5af0437..4fed461907780 100644 --- a/homeassistant/components/graphite/manifest.json +++ b/homeassistant/components/graphite/manifest.json @@ -1,8 +1,6 @@ { "domain": "graphite", "name": "Graphite", - "documentation": "https://www.home-assistant.io/components/graphite", - "requirements": [], - "dependencies": [], + "documentation": "https://www.home-assistant.io/integrations/graphite", "codeowners": [] } diff --git a/homeassistant/components/greeneye_monitor/__init__.py b/homeassistant/components/greeneye_monitor/__init__.py index 0f12c3cd47945..697a96649ab4a 100644 --- a/homeassistant/components/greeneye_monitor/__init__.py +++ b/homeassistant/components/greeneye_monitor/__init__.py @@ -1,113 +1,124 @@ """Support for monitoring a GreenEye Monitor energy monitor.""" import logging +from greeneye import Monitors import voluptuous as vol from homeassistant.const import ( CONF_NAME, CONF_PORT, CONF_TEMPERATURE_UNIT, - EVENT_HOMEASSISTANT_STOP) + EVENT_HOMEASSISTANT_STOP, + TIME_HOURS, + TIME_MINUTES, + TIME_SECONDS, +) 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), -}) +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" +CONF_VOLTAGE_SENSORS = "voltage" + +DATA_GREENEYE_MONITOR = "greeneye_monitor" +DOMAIN = "greeneye_monitor" + +SENSOR_TYPE_CURRENT = "current_sensor" +SENSOR_TYPE_PULSE_COUNTER = "pulse_counter" +SENSOR_TYPE_TEMPERATURE = "temperature_sensor" +SENSOR_TYPE_VOLTAGE = "voltage_sensor" + +TEMPERATURE_UNIT_CELSIUS = "C" + +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] + ), + } +) + +VOLTAGE_SENSOR_SCHEMA = vol.Schema( + {vol.Required(CONF_NUMBER): vol.Range(1, 48), vol.Required(CONF_NAME): cv.string} +) + +VOLTAGE_SENSORS_SCHEMA = vol.All(cv.ensure_list, [VOLTAGE_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_SECONDS): vol.Any( + TIME_SECONDS, TIME_MINUTES, TIME_HOURS + ), + } +) 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, -}) +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( +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, -}) + "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, + vol.Optional(CONF_VOLTAGE_SENSORS, default=[]): VOLTAGE_SENSORS_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, -}) +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) +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 @@ -124,49 +135,63 @@ async def close_server(*args): all_sensors = [] for monitor_config in server_config[CONF_MONITORS]: monitor_serial_number = { - CONF_MONITOR_SERIAL_NUMBER: monitor_config[CONF_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] + all_sensors.append( + { + CONF_SENSOR_TYPE: SENSOR_TYPE_CURRENT, + **monitor_serial_number, + **channel_config, + } + ) + + voltage_configs = monitor_config[CONF_VOLTAGE_SENSORS] + for voltage_config in voltage_configs: + all_sensors.append( + { + CONF_SENSOR_TYPE: SENSOR_TYPE_VOLTAGE, + **monitor_serial_number, + **voltage_config, + } + ) + + sensor_configs = monitor_config[CONF_TEMPERATURE_SENSORS] if sensor_configs: temperature_unit = { - CONF_TEMPERATURE_UNIT: sensor_configs[CONF_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, - }) + 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, - }) + 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") + _LOGGER.error( + "Configuration must specify at least one " + "channel, voltage, pulse counter or temperature sensor" + ) return False - hass.async_create_task(async_load_platform( - hass, - 'sensor', - DOMAIN, - all_sensors, - config)) + 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 index 7bfb87ede474e..304233438c574 100644 --- a/homeassistant/components/greeneye_monitor/manifest.json +++ b/homeassistant/components/greeneye_monitor/manifest.json @@ -1,10 +1,7 @@ { "domain": "greeneye_monitor", - "name": "Greeneye monitor", - "documentation": "https://www.home-assistant.io/components/greeneye_monitor", - "requirements": [ - "greeneye_monitor==1.0" - ], - "dependencies": [], - "codeowners": [] + "name": "GreenEye Monitor (GEM)", + "documentation": "https://www.home-assistant.io/integrations/greeneye_monitor", + "requirements": ["greeneye_monitor==2.0"], + "codeowners": ["@jkeljo"] } diff --git a/homeassistant/components/greeneye_monitor/sensor.py b/homeassistant/components/greeneye_monitor/sensor.py index 499d5351ad4e0..42f7c0334b3ca 100644 --- a/homeassistant/components/greeneye_monitor/sensor.py +++ b/homeassistant/components/greeneye_monitor/sensor.py @@ -1,7 +1,15 @@ """Support for the sensors in a GreenEye Monitor.""" import logging -from homeassistant.const import CONF_NAME, CONF_TEMPERATURE_UNIT, POWER_WATT +from homeassistant.const import ( + CONF_NAME, + CONF_TEMPERATURE_UNIT, + POWER_WATT, + TIME_HOURS, + TIME_MINUTES, + TIME_SECONDS, + VOLT, +) from homeassistant.helpers.entity import Entity from . import ( @@ -16,28 +24,23 @@ SENSOR_TYPE_CURRENT, SENSOR_TYPE_PULSE_COUNTER, SENSOR_TYPE_TEMPERATURE, - TIME_UNIT_HOUR, - TIME_UNIT_MINUTE, - TIME_UNIT_SECOND, + SENSOR_TYPE_VOLTAGE, ) _LOGGER = logging.getLogger(__name__) -DATA_PULSES = 'pulses' -DATA_WATT_SECONDS = 'watt_seconds' +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' +COUNTER_ICON = "mdi:counter" +CURRENT_SENSOR_ICON = "mdi:flash" +TEMPERATURE_ICON = "mdi:thermometer" +VOLTAGE_ICON = "mdi:current-ac" -async def async_setup_platform( - hass, - config, - async_add_entities, - discovery_info=None): +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 @@ -46,25 +49,42 @@ async def async_setup_platform( 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])) + 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])) + 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])) + entities.append( + TemperatureSensor( + sensor[CONF_MONITOR_SERIAL_NUMBER], + sensor[CONF_NUMBER], + sensor[CONF_NAME], + sensor[CONF_TEMPERATURE_UNIT], + ) + ) + elif sensor_type == SENSOR_TYPE_VOLTAGE: + entities.append( + VoltageSensor( + sensor[CONF_MONITOR_SERIAL_NUMBER], + sensor[CONF_NUMBER], + sensor[CONF_NAME], + ) + ) async_add_entities(entities) @@ -88,11 +108,7 @@ def should_poll(self): @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, - ) + return f"{self._monitor_serial_number}-{self._sensor_type}-{self._number}" @property def name(self): @@ -114,7 +130,7 @@ def _on_new_monitor(self, *args): async def async_will_remove_from_hass(self): """Remove listener from the sensor.""" if self._sensor: - self._sensor.remove_listener(self._schedule_update) + self._sensor.remove_listener(self.async_write_ha_state) else: monitors = self.hass.data[DATA_GREENEYE_MONITOR] monitors.remove_listener(self._on_new_monitor) @@ -125,23 +141,20 @@ def _try_connect_to_monitor(self, monitors): return False self._sensor = self._get_sensor(monitor) - self._sensor.add_listener(self._schedule_update) + self._sensor.add_listener(self.async_write_ha_state) 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) + super().__init__(monitor_serial_number, name, "current", number) self._net_metering = net_metering def _get_sensor(self, monitor): @@ -176,24 +189,23 @@ def device_state_attributes(self): else: watt_seconds = self._sensor.absolute_watt_seconds - return { - DATA_WATT_SECONDS: 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): + 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) + 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 @@ -212,27 +224,26 @@ def state(self): 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) + 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: + if self._time_unit == TIME_SECONDS: return 1 - if self._time_unit == TIME_UNIT_MINUTE: + if self._time_unit == TIME_MINUTES: return 60 - if self._time_unit == TIME_UNIT_HOUR: + if self._time_unit == TIME_HOURS: 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, - ) + return f"{self._counted_quantity}/{self._time_unit}" @property def device_state_attributes(self): @@ -240,9 +251,7 @@ def device_state_attributes(self): if not self._sensor: return None - return { - DATA_PULSES: self._sensor.pulses - } + return {DATA_PULSES: self._sensor.pulses} class TemperatureSensor(GEMSensor): @@ -250,7 +259,7 @@ class TemperatureSensor(GEMSensor): def __init__(self, monitor_serial_number, number, name, unit): """Construct the entity.""" - super().__init__(monitor_serial_number, name, 'temp', number) + super().__init__(monitor_serial_number, name, "temp", number) self._unit = unit def _get_sensor(self, monitor): @@ -273,3 +282,33 @@ def state(self): def unit_of_measurement(self): """Return the unit of measurement for this sensor (user specified).""" return self._unit + + +class VoltageSensor(GEMSensor): + """Entity showing voltage.""" + + def __init__(self, monitor_serial_number, number, name): + """Construct the entity.""" + super().__init__(monitor_serial_number, name, "volts", number) + + def _get_sensor(self, monitor): + """Wire the updates to the monitor itself, since there is no voltage element in the API.""" + return monitor + + @property + def icon(self): + """Return the icon that should represent this sensor in the UI.""" + return VOLTAGE_ICON + + @property + def state(self): + """Return the current voltage being reported by this sensor.""" + if not self._sensor: + return None + + return self._sensor.voltage + + @property + def unit_of_measurement(self): + """Return the unit of measurement for this sensor.""" + return VOLT diff --git a/homeassistant/components/greenwave/light.py b/homeassistant/components/greenwave/light.py index a8418a01ac2ee..41e4b99b6c61f 100644 --- a/homeassistant/components/greenwave/light.py +++ b/homeassistant/components/greenwave/light.py @@ -1,63 +1,67 @@ """Support for Greenwave Reality (TCP Connected) lights.""" -import logging from datetime import timedelta +import logging +import os +import greenwavereality as greenwave import voluptuous as vol from homeassistant.components.light import ( - ATTR_BRIGHTNESS, PLATFORM_SCHEMA, SUPPORT_BRIGHTNESS, Light) + ATTR_BRIGHTNESS, + PLATFORM_SCHEMA, + SUPPORT_BRIGHTNESS, + LightEntity, +) 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' +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, -}) +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') + 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') + token = greenwave.grab_token(host, "hass", "homeassistant") except PermissionError: - _LOGGER.error('The Gateway Is Not In Sync Mode') + _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()) + add_entities( + GreenwaveLight(device, host, token, GatewayData(host, token)) + for device in bulbs.values() + ) -class GreenwaveLight(Light): +class GreenwaveLight(LightEntity): """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._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) @@ -91,28 +95,23 @@ def is_on(self): 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) + 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._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'] + self._name = bulbs[self._did]["name"] class GatewayData: @@ -120,7 +119,6 @@ class GatewayData: 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) @@ -133,6 +131,5 @@ def greenwave(self): @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 index 1032b5eaf2a2e..b0076058833f8 100644 --- a/homeassistant/components/greenwave/manifest.json +++ b/homeassistant/components/greenwave/manifest.json @@ -1,10 +1,7 @@ { "domain": "greenwave", - "name": "Greenwave", - "documentation": "https://www.home-assistant.io/components/greenwave", - "requirements": [ - "greenwavereality==0.5.1" - ], - "dependencies": [], + "name": "Greenwave Reality", + "documentation": "https://www.home-assistant.io/integrations/greenwave", + "requirements": ["greenwavereality==0.5.1"], "codeowners": [] } diff --git a/homeassistant/components/griddy/__init__.py b/homeassistant/components/griddy/__init__.py new file mode 100644 index 0000000000000..fb5079b00f8c6 --- /dev/null +++ b/homeassistant/components/griddy/__init__.py @@ -0,0 +1,96 @@ +"""The Griddy Power integration.""" +import asyncio +from datetime import timedelta +import logging + +from griddypower.async_api import LOAD_ZONES, AsyncGriddy +import voluptuous as vol + +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import CONF_LOADZONE, DOMAIN, UPDATE_INTERVAL + +_LOGGER = logging.getLogger(__name__) + +CONFIG_SCHEMA = vol.Schema( + {DOMAIN: vol.Schema({vol.Required(CONF_LOADZONE): vol.In(LOAD_ZONES)})}, + extra=vol.ALLOW_EXTRA, +) + +PLATFORMS = ["sensor"] + + +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the Griddy Power component.""" + + hass.data.setdefault(DOMAIN, {}) + conf = config.get(DOMAIN) + + if not conf: + return True + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_LOADZONE: conf.get(CONF_LOADZONE)}, + ) + ) + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up Griddy Power from a config entry.""" + + entry_data = entry.data + + async_griddy = AsyncGriddy( + aiohttp_client.async_get_clientsession(hass), + settlement_point=entry_data[CONF_LOADZONE], + ) + + async def async_update_data(): + """Fetch data from API endpoint.""" + return await async_griddy.async_getnow() + + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name="Griddy getnow", + update_method=async_update_data, + update_interval=timedelta(seconds=UPDATE_INTERVAL), + ) + + await coordinator.async_refresh() + + if not coordinator.last_update_success: + raise ConfigEntryNotReady + + hass.data[DOMAIN][entry.entry_id] = coordinator + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ] + ) + ) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/griddy/config_flow.py b/homeassistant/components/griddy/config_flow.py new file mode 100644 index 0000000000000..56284384ee0a1 --- /dev/null +++ b/homeassistant/components/griddy/config_flow.py @@ -0,0 +1,75 @@ +"""Config flow for Griddy Power integration.""" +import asyncio +import logging + +from aiohttp import ClientError +from griddypower.async_api import LOAD_ZONES, AsyncGriddy +import voluptuous as vol + +from homeassistant import config_entries, core, exceptions +from homeassistant.helpers import aiohttp_client + +from .const import CONF_LOADZONE +from .const import DOMAIN # pylint:disable=unused-import + +_LOGGER = logging.getLogger(__name__) + +DATA_SCHEMA = vol.Schema({vol.Required(CONF_LOADZONE): vol.In(LOAD_ZONES)}) + + +async def validate_input(hass: core.HomeAssistant, data): + """Validate the user input allows us to connect. + + Data has the keys from DATA_SCHEMA with values provided by the user. + """ + client_session = aiohttp_client.async_get_clientsession(hass) + + try: + await AsyncGriddy( + client_session, settlement_point=data[CONF_LOADZONE] + ).async_getnow() + except (asyncio.TimeoutError, ClientError): + raise CannotConnect + + # Return info that you want to store in the config entry. + return {"title": f"Load Zone {data[CONF_LOADZONE]}"} + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Griddy Power.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + info = None + if user_input is not None: + try: + info = await validate_input(self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + if "base" not in errors: + await self.async_set_unique_id(user_input[CONF_LOADZONE]) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=info["title"], data=user_input) + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + async def async_step_import(self, user_input): + """Handle import.""" + await self.async_set_unique_id(user_input[CONF_LOADZONE]) + self._abort_if_unique_id_configured() + + return await self.async_step_user(user_input) + + +class CannotConnect(exceptions.HomeAssistantError): + """Error to indicate we cannot connect.""" diff --git a/homeassistant/components/griddy/const.py b/homeassistant/components/griddy/const.py new file mode 100644 index 0000000000000..034567a806e15 --- /dev/null +++ b/homeassistant/components/griddy/const.py @@ -0,0 +1,7 @@ +"""Constants for the Griddy Power integration.""" + +DOMAIN = "griddy" + +UPDATE_INTERVAL = 90 + +CONF_LOADZONE = "loadzone" diff --git a/homeassistant/components/griddy/manifest.json b/homeassistant/components/griddy/manifest.json new file mode 100644 index 0000000000000..1e31b1b7aa832 --- /dev/null +++ b/homeassistant/components/griddy/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "griddy", + "name": "Griddy Power", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/griddy", + "requirements": ["griddypower==0.1.0"], + "codeowners": ["@bdraco"] +} diff --git a/homeassistant/components/griddy/sensor.py b/homeassistant/components/griddy/sensor.py new file mode 100644 index 0000000000000..9a58e5e328679 --- /dev/null +++ b/homeassistant/components/griddy/sensor.py @@ -0,0 +1,75 @@ +"""Support for August sensors.""" +import logging + +from homeassistant.const import ENERGY_KILO_WATT_HOUR +from homeassistant.helpers.entity import Entity + +from .const import CONF_LOADZONE, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the August sensors.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id] + + settlement_point = config_entry.data[CONF_LOADZONE] + + async_add_entities([GriddyPriceSensor(settlement_point, coordinator)], True) + + +class GriddyPriceSensor(Entity): + """Representation of an August sensor.""" + + def __init__(self, settlement_point, coordinator): + """Initialize the sensor.""" + self._coordinator = coordinator + self._settlement_point = settlement_point + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return f"¢/{ENERGY_KILO_WATT_HOUR}" + + @property + def name(self): + """Device Name.""" + return f"{self._settlement_point} Price Now" + + @property + def icon(self): + """Device Ice.""" + return "mdi:currency-usd" + + @property + def unique_id(self): + """Device Uniqueid.""" + return f"{self._settlement_point}_price_now" + + @property + def available(self): + """Return True if entity is available.""" + return self._coordinator.last_update_success + + @property + def state(self): + """Get the current price.""" + return round(float(self._coordinator.data.now.price_cents_kwh), 4) + + @property + def should_poll(self): + """Return False, updates are controlled via coordinator.""" + return False + + async def async_update(self): + """Update the entity. + + Only used by the generic entity update service. + """ + await self._coordinator.async_request_refresh() + + async def async_added_to_hass(self): + """Subscribe to updates.""" + self.async_on_remove( + self._coordinator.async_add_listener(self.async_write_ha_state) + ) diff --git a/homeassistant/components/griddy/strings.json b/homeassistant/components/griddy/strings.json new file mode 100644 index 0000000000000..d8ccb94fae729 --- /dev/null +++ b/homeassistant/components/griddy/strings.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "cannot_connect": "Failed to connect, please try again", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "description": "Your Load Zone is in your Griddy account under \u201cAccount > Meter > Load Zone.\u201d", + "data": { "loadzone": "Load Zone (Settlement Point)" }, + "title": "Setup your Griddy Load Zone" + } + }, + "abort": { "already_configured": "This Load Zone is already configured" } + } +} diff --git a/homeassistant/components/griddy/translations/ca.json b/homeassistant/components/griddy/translations/ca.json new file mode 100644 index 0000000000000..cb363a7dfabe5 --- /dev/null +++ b/homeassistant/components/griddy/translations/ca.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Aquesta zona de c\u00e0rrega ja est\u00e0 configurada" + }, + "error": { + "cannot_connect": "No s'ha pogut connectar, torna-ho a provar", + "unknown": "Error inesperat" + }, + "step": { + "user": { + "data": { + "loadzone": "Zona de c\u00e0rrega (Load Zone)" + }, + "description": "La teva zona de c\u00e0rrega (Load Zone) est\u00e0 al teu compte de Griddy v\u00e9s a \"Account > Meter > Load Zone\".", + "title": "Configuraci\u00f3 de la zona de c\u00e0rrega (Load Zone) de Griddy" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/griddy/translations/da.json b/homeassistant/components/griddy/translations/da.json new file mode 100644 index 0000000000000..639633dc14c11 --- /dev/null +++ b/homeassistant/components/griddy/translations/da.json @@ -0,0 +1,3 @@ +{ + "title": "Griddy" +} \ No newline at end of file diff --git a/homeassistant/components/griddy/translations/de.json b/homeassistant/components/griddy/translations/de.json new file mode 100644 index 0000000000000..ad6a6e10ab051 --- /dev/null +++ b/homeassistant/components/griddy/translations/de.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Diese Ladezone ist bereits konfiguriert" + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen, versuchen Sie es erneut", + "unknown": "Unerwarteter Fehler" + }, + "step": { + "user": { + "data": { + "loadzone": "Ladezone (Abwicklungspunkt)" + }, + "description": "Ihre Ladezone befindet sich in Ihrem Griddy-Konto unter \"Konto > Messger\u00e4t > Ladezone\".", + "title": "Richten Sie Ihre Griddy Ladezone ein" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/griddy/translations/en.json b/homeassistant/components/griddy/translations/en.json new file mode 100644 index 0000000000000..bb1e217133a69 --- /dev/null +++ b/homeassistant/components/griddy/translations/en.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "This Load Zone is already configured" + }, + "error": { + "cannot_connect": "Failed to connect, please try again", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "loadzone": "Load Zone (Settlement Point)" + }, + "description": "Your Load Zone is in your Griddy account under \u201cAccount > Meter > Load Zone.\u201d", + "title": "Setup your Griddy Load Zone" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/griddy/translations/es.json b/homeassistant/components/griddy/translations/es.json new file mode 100644 index 0000000000000..a3727721b2d55 --- /dev/null +++ b/homeassistant/components/griddy/translations/es.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Esta Zona de Carga ya est\u00e1 configurada" + }, + "error": { + "cannot_connect": "No se ha podido conectar, por favor, int\u00e9ntalo de nuevo.", + "unknown": "Error inesperado" + }, + "step": { + "user": { + "data": { + "loadzone": "Zona de Carga (Punto del Asentamiento)" + }, + "description": "Tu Zona de Carga est\u00e1 en tu cuenta de Griddy en \"Account > Meter > Load Zone\"", + "title": "Configurar tu Zona de Carga de Griddy" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/griddy/translations/fr.json b/homeassistant/components/griddy/translations/fr.json new file mode 100644 index 0000000000000..845c0968827c9 --- /dev/null +++ b/homeassistant/components/griddy/translations/fr.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "Cette zone de chargement est d\u00e9j\u00e0 configur\u00e9e" + }, + "error": { + "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer", + "unknown": "Erreur inattendue" + }, + "step": { + "user": { + "data": { + "loadzone": "Zone de charge (point d'\u00e9tablissement)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/griddy/translations/it.json b/homeassistant/components/griddy/translations/it.json new file mode 100644 index 0000000000000..2b573170e6908 --- /dev/null +++ b/homeassistant/components/griddy/translations/it.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Questa Zona di Carico \u00e8 gi\u00e0 configurata" + }, + "error": { + "cannot_connect": "Impossibile connettersi, si prega di riprovare", + "unknown": "Errore imprevisto" + }, + "step": { + "user": { + "data": { + "loadzone": "Zona di Carico (Punto di insediamento)" + }, + "description": "La tua Zona di Carico si trova nel tuo account Griddy in \"Account > Meter > Load zone\".", + "title": "Configurazione della Zona di Carico Griddy" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/griddy/translations/ko.json b/homeassistant/components/griddy/translations/ko.json new file mode 100644 index 0000000000000..a17db380aa01b --- /dev/null +++ b/homeassistant/components/griddy/translations/ko.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "\uc774 \uc804\ub825 \uacf5\uae09 \uc9c0\uc5ed\uc740 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, + "error": { + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.", + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" + }, + "step": { + "user": { + "data": { + "loadzone": "\uc804\ub825 \uacf5\uae09 \uc9c0\uc5ed (\uc815\uc0b0\uc810)" + }, + "description": "\uc804\ub825 \uacf5\uae09 \uc9c0\uc5ed\uc740 Griddy \uacc4\uc815\uc758 \"Account > Meter > Load Zone\"\uc5d0\uc11c \ud655\uc778\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.", + "title": "Griddy \uc804\ub825 \uacf5\uae09 \uc9c0\uc5ed \uc124\uc815\ud558\uae30" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/griddy/translations/lb.json b/homeassistant/components/griddy/translations/lb.json new file mode 100644 index 0000000000000..2e2458cb8edb0 --- /dev/null +++ b/homeassistant/components/griddy/translations/lb.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "D\u00ebs Lued Zon ass scho konfigur\u00e9iert" + }, + "error": { + "cannot_connect": "Feeler beim verbannen, prob\u00e9iert w.e.g. nach emol.", + "unknown": "Onerwaarte Feeler" + }, + "step": { + "user": { + "data": { + "loadzone": "Lued Zone (Punkt vum R\u00e9glement)" + }, + "description": "Deng Lued Zon ass an dengem Griddy Kont enner \"Account > Meter > Load Zone.\"", + "title": "Griddy Lued Zon ariichten" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/griddy/translations/nl.json b/homeassistant/components/griddy/translations/nl.json new file mode 100644 index 0000000000000..9227d4702abae --- /dev/null +++ b/homeassistant/components/griddy/translations/nl.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "already_configured": "Deze laadzone is al geconfigureerd" + }, + "error": { + "cannot_connect": "Verbinding mislukt, probeer het opnieuw", + "unknown": "Onverwachte fout" + }, + "step": { + "user": { + "description": "Uw Load Zone staat op uw Griddy account onder \"Account > Meter > Load Zone\".", + "title": "Stel uw Griddy Load Zone in" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/griddy/translations/no.json b/homeassistant/components/griddy/translations/no.json new file mode 100644 index 0000000000000..000b5dae3067a --- /dev/null +++ b/homeassistant/components/griddy/translations/no.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Denne Load Zone er allerede konfigurert" + }, + "error": { + "cannot_connect": "Klarte ikke \u00e5 koble til, vennligst pr\u00f8v igjen", + "unknown": "Uventet feil" + }, + "step": { + "user": { + "data": { + "loadzone": "Load Zone (settlingspunkt)" + }, + "description": "Din Load Zone er p\u00e5 din Griddy-konto under \"Konto > M\u00e5ler > Lastesone.\"", + "title": "Sett opp din Griddy Load Zone" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/griddy/translations/pl.json b/homeassistant/components/griddy/translations/pl.json new file mode 100644 index 0000000000000..43a16be8d79fc --- /dev/null +++ b/homeassistant/components/griddy/translations/pl.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Ta strefa obci\u0105\u017cenia jest ju\u017c skonfigurowana." + }, + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia, spr\u00f3buj ponownie.", + "unknown": "Niespodziewany b\u0142\u0105d." + }, + "step": { + "user": { + "data": { + "loadzone": "Strefa obci\u0105\u017cenia (punkt rozliczenia)" + }, + "description": "Twoja strefa obci\u0105\u017cenia znajduje si\u0119 na twoim koncie Griddy w sekcji \"Konto > Licznik > Strefa obci\u0105\u017cenia\".", + "title": "Konfigurowanie strefy obci\u0105\u017cenia Griddy" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/griddy/translations/ru.json b/homeassistant/components/griddy/translations/ru.json new file mode 100644 index 0000000000000..9fe9810751033 --- /dev/null +++ b/homeassistant/components/griddy/translations/ru.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u044d\u0442\u043e\u0439 \u0437\u043e\u043d\u044b \u043d\u0430\u0433\u0440\u0443\u0437\u043a\u0438 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430." + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f, \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0435\u0449\u0435 \u0440\u0430\u0437.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "step": { + "user": { + "data": { + "loadzone": "\u0417\u043e\u043d\u0430 \u043d\u0430\u0433\u0440\u0443\u0437\u043a\u0438 (\u0440\u0430\u0441\u0447\u0435\u0442\u043d\u0430\u044f \u0442\u043e\u0447\u043a\u0430)" + }, + "description": "\u0417\u043e\u043d\u0430 \u043d\u0430\u0433\u0440\u0443\u0437\u043a\u0438 \u043d\u0430\u0445\u043e\u0434\u0438\u0442\u0441\u044f \u0432 \u0412\u0430\u0448\u0435\u0439 \u0443\u0447\u0435\u0442\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u0438 Griddy \u0432 \u0440\u0430\u0437\u0434\u0435\u043b\u0435 Account > Meter > Load Zone.", + "title": "Griddy" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/griddy/translations/sl.json b/homeassistant/components/griddy/translations/sl.json new file mode 100644 index 0000000000000..8df85c6dc67af --- /dev/null +++ b/homeassistant/components/griddy/translations/sl.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Ta obremenitvena cona je \u017ee konfigurirana" + }, + "error": { + "cannot_connect": "Povezava ni uspela, poskusite znova", + "unknown": "Nepri\u010dakovana napaka" + }, + "step": { + "user": { + "data": { + "loadzone": "Obremenitvena cona (poselitvena to\u010dka)" + }, + "description": "Va\u0161a obremenitvena cona je v va\u0161em ra\u010dunu Griddy pod \"Ra\u010dun > Merilnik > Nalo\u017ei cono.\"", + "title": "Nastavite svojo Griddy Load Cono" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/griddy/translations/sv.json b/homeassistant/components/griddy/translations/sv.json new file mode 100644 index 0000000000000..e9ddacf271495 --- /dev/null +++ b/homeassistant/components/griddy/translations/sv.json @@ -0,0 +1,8 @@ +{ + "config": { + "error": { + "cannot_connect": "Det gick inte att ansluta, f\u00f6rs\u00f6k igen", + "unknown": "Ov\u00e4ntat fel" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/griddy/translations/tr.json b/homeassistant/components/griddy/translations/tr.json new file mode 100644 index 0000000000000..d887b1486584f --- /dev/null +++ b/homeassistant/components/griddy/translations/tr.json @@ -0,0 +1,8 @@ +{ + "config": { + "error": { + "cannot_connect": "Ba\u011flant\u0131 kurulamad\u0131, l\u00fctfen tekrar deneyin", + "unknown": "Beklenmeyen hata" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/griddy/translations/zh-Hant.json b/homeassistant/components/griddy/translations/zh-Hant.json new file mode 100644 index 0000000000000..e112a2c682f8e --- /dev/null +++ b/homeassistant/components/griddy/translations/zh-Hant.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "\u6b64\u8ca0\u8f09\u5340\u57df\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557\uff0c\u8acb\u518d\u8a66\u4e00\u6b21", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "step": { + "user": { + "data": { + "loadzone": "\u8ca0\u8f09\u5340\u57df\uff08\u5c45\u4f4f\u9ede\uff09" + }, + "description": "\u8ca0\u8f09\u5340\u57df\u986f\u793a\u65bc Griddy \u5e33\u865f\uff0c\u4f4d\u65bc \u201cAccount > Meter > Load Zone\u201d\u3002", + "title": "\u8a2d\u5b9a Griddy \u8ca0\u8f09\u5340\u57df" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/group/__init__.py b/homeassistant/components/group/__init__.py index 80ac01a78ac78..e4966a1a4ceef 100644 --- a/homeassistant/components/group/__init__.py +++ b/homeassistant/components/group/__init__.py @@ -1,72 +1,59 @@ """Provide the functionality to group entities.""" import asyncio import logging +from typing import Any, Iterable, List, Optional, cast 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) + ATTR_ASSUMED_STATE, + ATTR_ENTITY_ID, + ATTR_ICON, + ATTR_NAME, + CONF_ICON, + CONF_NAME, + ENTITY_MATCH_ALL, + ENTITY_MATCH_NONE, + SERVICE_RELOAD, + STATE_CLOSED, + STATE_HOME, + STATE_LOCKED, + STATE_NOT_HOME, + STATE_OFF, + STATE_OK, + STATE_ON, + STATE_OPEN, + STATE_PROBLEM, + STATE_UNKNOWN, + STATE_UNLOCKED, +) from homeassistant.core import callback -from homeassistant.loader import bind_hass +import homeassistant.helpers.config_validation as cv 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, -}) +from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.loader import bind_hass + +# mypy: allow-untyped-calls, allow-untyped-defs, no-check-untyped-defs + +DOMAIN = "group" + +ENTITY_ID_FORMAT = DOMAIN + ".{}" + +CONF_ENTITIES = "entities" +CONF_ALL = "all" + +ATTR_ADD_ENTITIES = "add_entities" +ATTR_AUTO = "auto" +ATTR_ENTITIES = "entities" +ATTR_OBJECT_ID = "object_id" +ATTR_ORDER = "order" +ATTR_ALL = "all" + +SERVICE_SET = "set" +SERVICE_REMOVE = "remove" _LOGGER = logging.getLogger(__name__) @@ -79,23 +66,30 @@ def _conf_preprocess(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, -}) +GROUP_SCHEMA = vol.All( + vol.Schema( + { + vol.Optional(CONF_ENTITIES): vol.Any(cv.entity_ids, None), + CONF_NAME: cv.string, + CONF_ICON: cv.icon, + CONF_ALL: cv.boolean, + } + ) +) -CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({cv.match_all: vol.All(_conf_preprocess, GROUP_SCHEMA)}) -}, extra=vol.ALLOW_EXTRA) +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)] +_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): @@ -122,14 +116,17 @@ def is_on(hass, entity_id): @bind_hass -def expand_entity_ids(hass, entity_ids): +def expand_entity_ids(hass: HomeAssistantType, entity_ids: Iterable[Any]) -> List[str]: """Return entity_ids with group entity ids replaced by their members. Async friendly. """ - found_ids = [] + found_ids: List[str] = [] for entity_id in entity_ids: - if not isinstance(entity_id, str): + if not isinstance(entity_id, str) or entity_id in ( + ENTITY_MATCH_NONE, + ENTITY_MATCH_ALL, + ): continue entity_id = entity_id.lower() @@ -144,9 +141,10 @@ def expand_entity_ids(hass, entity_ids): 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) + 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: @@ -160,7 +158,9 @@ def expand_entity_ids(hass, entity_ids): @bind_hass -def get_entity_ids(hass, entity_id, domain_filter=None): +def get_entity_ids( + hass: HomeAssistantType, entity_id: str, domain_filter: Optional[str] = None +) -> List[str]: """Get members of this group. Async friendly. @@ -172,12 +172,29 @@ def get_entity_ids(hass, entity_id, domain_filter=None): entity_ids = group.attributes[ATTR_ENTITY_ID] if not domain_filter: - return entity_ids + return cast(List[str], entity_ids) + + domain_filter = f"{domain_filter.lower()}." + + return [ent_id for ent_id in entity_ids if ent_id.startswith(domain_filter)] + + +@bind_hass +def groups_with_entity(hass: HomeAssistantType, entity_id: str) -> List[str]: + """Get all groups that contain this entity. + + Async friendly. + """ + if DOMAIN not in hass.data: + return [] + + groups = [] - domain_filter = domain_filter.lower() + '.' + for group in hass.data[DOMAIN].entities: + if entity_id in group.tracking: + groups.append(group.entity_id) - return [ent_id for ent_id in entity_ids - if ent_id.startswith(domain_filter)] + return groups async def async_setup(hass, config): @@ -201,8 +218,8 @@ async def reload_service_handler(service): await component.async_add_entities(auto) hass.services.async_register( - DOMAIN, SERVICE_RELOAD, reload_service_handler, - schema=RELOAD_SERVICE_SCHEMA) + DOMAIN, SERVICE_RELOAD, reload_service_handler, schema=vol.Schema({}) + ) service_lock = asyncio.Lock() @@ -214,31 +231,36 @@ async def locked_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) + entity_id = f"{DOMAIN}.{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 + 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} + extra_arg = { + attr: service.data[attr] + for attr in (ATTR_ICON,) + if service.data.get(attr) is not None + } await Group.async_create_group( - hass, service.data.get(ATTR_NAME, object_id), + 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 + **extra_arg, ) return if group is None: - _LOGGER.warning("%s:Group '%s' doesn't exist!", - service.service, object_id) + _LOGGER.warning("%s:Group '%s' doesn't exist!", service.service, object_id) return # update group @@ -258,28 +280,16 @@ async def groups_service_handler(service): 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() + group.async_write_ha_state() return @@ -288,29 +298,29 @@ async def groups_service_handler(service): 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, loop=hass.loop) + DOMAIN, + SERVICE_SET, + locked_service_handler, + schema=vol.All( + vol.Schema( + { + vol.Required(ATTR_OBJECT_ID): cv.slug, + vol.Optional(ATTR_NAME): cv.string, + vol.Optional(ATTR_ICON): cv.string, + vol.Optional(ATTR_ALL): cv.boolean, + vol.Exclusive(ATTR_ENTITIES, "entities"): cv.entity_ids, + vol.Exclusive(ATTR_ADD_ENTITIES, "entities"): cv.entity_ids, + } + ) + ), + ) hass.services.async_register( - DOMAIN, SERVICE_SET_VISIBILITY, visibility_service_handler, - schema=SET_VISIBILITY_SERVICE_SCHEMA) + DOMAIN, + SERVICE_REMOVE, + groups_service_handler, + schema=vol.Schema({vol.Required(ATTR_OBJECT_ID): cv.slug}), + ) return True @@ -321,23 +331,28 @@ async def _async_process_config(hass, config, component): 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) + hass, name, entity_ids, icon=icon, 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): + def __init__( + self, + hass, + name, + order=None, + icon=None, + user_defined=True, + entity_ids=None, + mode=None, + ): """Initialize a group. This Object has factory function for creation. @@ -346,15 +361,12 @@ def __init__(self, hass, name, order=None, visible=True, icon=None, 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.tracking = () self.group_on = None self.group_off = None - self.visible = visible - self.control = control self.user_defined = user_defined self.mode = any if mode: @@ -364,41 +376,56 @@ def __init__(self, hass, name, order=None, visible=True, icon=None, 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): + def create_group( + hass, + name, + entity_ids=None, + user_defined=True, + icon=None, + object_id=None, + mode=None, + ): """Initialize a group.""" - return run_coroutine_threadsafe( + return asyncio.run_coroutine_threadsafe( Group.async_create_group( - hass, name, entity_ids, user_defined, visible, icon, view, - control, object_id, mode), - hass.loop).result() + hass, name, entity_ids, user_defined, icon, 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): + async def async_create_group( + hass, + name, + entity_ids=None, + user_defined=True, + icon=None, + object_id=None, + mode=None, + ): """Initialize a group. This method must be run in the event loop. """ group = Group( - hass, name, + 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 + icon=icon, + 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) + 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) + component = hass.data[DOMAIN] = EntityComponent(_LOGGER, DOMAIN, hass) await component.async_add_entities([group], True) @@ -434,26 +461,12 @@ 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, - } + 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 @@ -463,7 +476,7 @@ def assumed_state(self): def update_tracked_entity_ids(self, entity_ids): """Update the member entity IDs.""" - run_coroutine_threadsafe( + asyncio.run_coroutine_threadsafe( self.async_update_tracked_entity_ids(entity_ids), self.hass.loop ).result() @@ -505,18 +518,17 @@ async def async_update(self): self._async_update_group_state() async def async_added_to_hass(self): - """Handle addition to HASS.""" + """Handle addition to Home Assistant.""" if self.tracking: self.async_start() async def async_will_remove_from_hass(self): - """Handle removal from HASS.""" + """Handle removal from Home Assistant.""" 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): + 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. @@ -526,7 +538,7 @@ async def _async_state_changed_listener(self, entity_id, old_state, return self._async_update_group_state(new_state) - await self.async_update_ha_state() + self.async_write_ha_state() @property def _tracking_states(self): @@ -562,8 +574,7 @@ def _async_update_group_state(self, tr_state=None): states = self._tracking_states for state in states: - gr_on, gr_off = \ - _get_group_on_off(state.state) + gr_on, gr_off = _get_group_on_off(state.state) if gr_on is not None: break else: @@ -576,12 +587,11 @@ def _async_update_group_state(self, tr_state=None): 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 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 @@ -593,14 +603,17 @@ def _async_update_group_state(self, tr_state=None): 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 ( + 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) + 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 index 385d20949d6d6..c4e691eeff9f6 100644 --- a/homeassistant/components/group/cover.py +++ b/homeassistant/components/group/cover.py @@ -1,67 +1,103 @@ """This platform allows several cover to be grouped into one cover.""" import logging +from typing import Dict, Optional, Set import voluptuous as vol +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, + CoverEntity, +) from homeassistant.const import ( - ATTR_ASSUMED_STATE, ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, CONF_ENTITIES, - CONF_NAME, STATE_CLOSED) -from homeassistant.core import callback + ATTR_ASSUMED_STATE, + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + CONF_ENTITIES, + CONF_NAME, + STATE_CLOSED, +) +from homeassistant.core import State, 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) +# mypy: allow-incomplete-defs, allow-untyped-calls, allow-untyped-defs +# mypy: no-check-untyped-defs _LOGGER = logging.getLogger(__name__) -KEY_OPEN_CLOSE = 'open_close' -KEY_STOP = 'stop' -KEY_POSITION = 'position' +KEY_OPEN_CLOSE = "open_close" +KEY_STOP = "stop" +KEY_POSITION = "position" -DEFAULT_NAME = 'Cover Group' +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), -}) +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): +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])]) + async_add_entities([CoverGroup(config[CONF_NAME], config[CONF_ENTITIES])]) -class CoverGroup(CoverDevice): +class CoverGroup(CoverEntity): """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._cover_position: Optional[int] = 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()} + self._covers: Dict[str, Set[str]] = { + KEY_OPEN_CLOSE: set(), + KEY_STOP: set(), + KEY_POSITION: set(), + } + self._tilts: Dict[str, Set[str]] = { + 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): + def update_supported_features( + self, + entity_id: str, + old_state: Optional[State], + new_state: Optional[State], + update_state: bool = True, + ) -> None: """Update dictionaries with supported features.""" if not new_state: for values in self._covers.values(): @@ -107,10 +143,12 @@ 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) + 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 @@ -139,7 +177,7 @@ def is_closed(self): return self._is_closed @property - def current_cover_position(self): + def current_cover_position(self) -> Optional[int]: """Return current position for all covers.""" return self._cover_position @@ -152,51 +190,63 @@ 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) + 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) + 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) + 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]} + 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) + 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) + 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) + 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) + 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]} + 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) + DOMAIN, SERVICE_SET_COVER_TILT_POSITION, data, blocking=True + ) async def async_update(self): """Update state and attributes.""" @@ -244,18 +294,18 @@ async def async_update(self): 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 + 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: diff --git a/homeassistant/components/group/light.py b/homeassistant/components/group/light.py index 170e93398a1c2..56408f410b81e 100644 --- a/homeassistant/components/group/light.py +++ b/homeassistant/components/group/light.py @@ -1,83 +1,119 @@ """This platform allows several lights to be grouped into one light.""" +import asyncio from collections import Counter import itertools import logging -from typing import Any, Callable, Iterator, List, Optional, Tuple +from typing import Any, Callable, Iterator, List, Optional, Tuple, cast import voluptuous as vol from homeassistant.components import light +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, +) from homeassistant.const import ( - ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, CONF_ENTITIES, CONF_NAME, - STATE_ON, STATE_UNAVAILABLE) -from homeassistant.core import State, callback + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + CONF_ENTITIES, + CONF_NAME, + STATE_ON, + STATE_UNAVAILABLE, +) +from homeassistant.core import CALLBACK_TYPE, 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.util import color as color_util -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) +# mypy: allow-incomplete-defs, allow-untyped-calls, allow-untyped-defs +# mypy: no-check-untyped-defs _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: +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])]) + async_add_entities( + [LightGroup(cast(str, config.get(CONF_NAME)), config[CONF_ENTITIES])] + ) -class LightGroup(light.Light): +class LightGroup(light.LightEntity): """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 + self._name = name + self._entity_ids = entity_ids + self._is_on = False + self._available = False + self._brightness: Optional[int] = None + self._hs_color: Optional[Tuple[float, float]] = None + self._color_temp: Optional[int] = None + self._min_mireds: Optional[int] = 154 + self._max_mireds: Optional[int] = 500 + self._white_value: Optional[int] = None + self._effect_list: Optional[List[str]] = None + self._effect: Optional[str] = None + self._supported_features: int = 0 + self._async_unsub_state_changed: Optional[CALLBACK_TYPE] = 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): + def async_state_changed_listener( + entity_id: str, old_state: State, new_state: State + ): """Handle child updates.""" self.async_schedule_update_ha_state(True) + assert self.hass is not None self._async_unsub_state_changed = async_track_state_change( - self.hass, self._entity_ids, async_state_changed_listener) + 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.""" + """Handle removal from Home Assistant.""" if self._async_unsub_state_changed is not None: self._async_unsub_state_changed() self._async_unsub_state_changed = None @@ -150,6 +186,7 @@ def should_poll(self) -> bool: 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} + emulate_color_temp_entity_ids = [] if ATTR_BRIGHTNESS in kwargs: data[ATTR_BRIGHTNESS] = kwargs[ATTR_BRIGHTNESS] @@ -160,6 +197,23 @@ async def async_turn_on(self, **kwargs): if ATTR_COLOR_TEMP in kwargs: data[ATTR_COLOR_TEMP] = kwargs[ATTR_COLOR_TEMP] + # Create a new entity list to mutate + updated_entities = list(self._entity_ids) + + # Walk through initial entity ids, split entity lists by support + for entity_id in self._entity_ids: + state = self.hass.states.get(entity_id) + if not state: + continue + support = state.attributes.get(ATTR_SUPPORTED_FEATURES) + # Only pass color temperature to supported entity_ids + if bool(support & SUPPORT_COLOR) and not bool( + support & SUPPORT_COLOR_TEMP + ): + emulate_color_temp_entity_ids.append(entity_id) + updated_entities.remove(entity_id) + data[ATTR_ENTITY_ID] = updated_entities + if ATTR_WHITE_VALUE in kwargs: data[ATTR_WHITE_VALUE] = kwargs[ATTR_WHITE_VALUE] @@ -172,8 +226,33 @@ async def async_turn_on(self, **kwargs): 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) + if not emulate_color_temp_entity_ids: + await self.hass.services.async_call( + light.DOMAIN, light.SERVICE_TURN_ON, data, blocking=True + ) + return + + emulate_color_temp_data = data.copy() + temp_k = color_util.color_temperature_mired_to_kelvin( + emulate_color_temp_data[ATTR_COLOR_TEMP] + ) + hs_color = color_util.color_temperature_to_hs(temp_k) + emulate_color_temp_data[ATTR_HS_COLOR] = hs_color + del emulate_color_temp_data[ATTR_COLOR_TEMP] + + emulate_color_temp_data[ATTR_ENTITY_ID] = emulate_color_temp_entity_ids + + await asyncio.gather( + self.hass.services.async_call( + light.DOMAIN, light.SERVICE_TURN_ON, data, blocking=True + ), + self.hass.services.async_call( + light.DOMAIN, + light.SERVICE_TURN_ON, + emulate_color_temp_data, + blocking=True, + ), + ) async def async_turn_off(self, **kwargs): """Forward the turn_off command to all lights in the light group.""" @@ -183,34 +262,34 @@ async def async_turn_off(self, **kwargs): data[ATTR_TRANSITION] = kwargs[ATTR_TRANSITION] await self.hass.services.async_call( - light.DOMAIN, light.SERVICE_TURN_OFF, data, blocking=True) + 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)) + states: List[State] = 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._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._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) + states, ATTR_MIN_MIREDS, default=154, reduce=min + ) self._max_mireds = _reduce_attribute( - states, ATTR_MAX_MIREDS, default=500, reduce=max) + states, ATTR_MAX_MIREDS, default=500, reduce=max + ) self._effect_list = None - all_effect_lists = list( - _find_state_attributes(states, ATTR_EFFECT_LIST)) + 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)) @@ -232,8 +311,7 @@ async def async_update(self): self._supported_features &= SUPPORT_GROUP_LIGHT -def _find_state_attributes(states: List[State], - key: str) -> Iterator[Any]: +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) @@ -248,13 +326,15 @@ def _mean_int(*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)) + return tuple(sum(x) / len(x) for x in zip(*args)) -def _reduce_attribute(states: List[State], - key: str, - default: Optional[Any] = None, - reduce: Callable[..., Any] = _mean_int) -> Any: +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. diff --git a/homeassistant/components/group/manifest.json b/homeassistant/components/group/manifest.json index aa99e20a4dfe4..692267817f99c 100644 --- a/homeassistant/components/group/manifest.json +++ b/homeassistant/components/group/manifest.json @@ -1,10 +1,7 @@ { "domain": "group", "name": "Group", - "documentation": "https://www.home-assistant.io/components/group", - "requirements": [], - "dependencies": [], - "codeowners": [ - "@home-assistant/core" - ] + "documentation": "https://www.home-assistant.io/integrations/group", + "codeowners": ["@home-assistant/core"], + "quality_scale": "internal" } diff --git a/homeassistant/components/group/notify.py b/homeassistant/components/group/notify.py index b59c49563e21b..2209e0e233326 100644 --- a/homeassistant/components/group/notify.py +++ b/homeassistant/components/group/notify.py @@ -6,22 +6,30 @@ import voluptuous as vol +from homeassistant.components.notify import ( + ATTR_DATA, + ATTR_MESSAGE, + DOMAIN, + PLATFORM_SCHEMA, + BaseNotificationService, +) 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) +# mypy: allow-untyped-calls, allow-untyped-defs, no-check-untyped-defs _LOGGER = logging.getLogger(__name__) -CONF_SERVICES = 'services' +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, - }]) -}) +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): @@ -61,8 +69,11 @@ async def async_send_message(self, message="", **kwargs): 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)) + tasks.append( + self.hass.services.async_call( + DOMAIN, entity.get(ATTR_SERVICE), sending_payload + ) + ) if tasks: - await asyncio.wait(tasks, loop=self.hass.loop) + await asyncio.wait(tasks) diff --git a/homeassistant/components/group/reproduce_state.py b/homeassistant/components/group/reproduce_state.py index 1cf1793e6f6f2..95915412e4f99 100644 --- a/homeassistant/components/group/reproduce_state.py +++ b/homeassistant/components/group/reproduce_state.py @@ -1,28 +1,36 @@ """Module that groups code required to handle state restore for component.""" -from typing import Iterable, Optional +from typing import Any, Dict, Iterable, Optional from homeassistant.core import Context, State +from homeassistant.helpers.state import async_reproduce_state from homeassistant.helpers.typing import HomeAssistantType -from homeassistant.loader import bind_hass +from . import get_entity_ids -@bind_hass -async def async_reproduce_states(hass: HomeAssistantType, - states: Iterable[State], - context: Optional[Context] = None) -> None: + +async def async_reproduce_states( + hass: HomeAssistantType, + states: Iterable[State], + *, + context: Optional[Context] = None, + reproduce_options: Optional[Dict[str, Any]] = 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) + 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, context=context, reproduce_options=reproduce_options + ) diff --git a/homeassistant/components/group/services.yaml b/homeassistant/components/group/services.yaml index 68c2f04f06498..cec4f187ca682 100644 --- a/homeassistant/components/group/services.yaml +++ b/homeassistant/components/group/services.yaml @@ -1,39 +1,19 @@ # 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' + example: "test_group" name: description: Name of group - example: 'My test group' - view: - description: Boolean for if the group is a view. - example: True + example: "My test group" 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 + example: "mdi:camera" entities: description: List of all members in the group. Not compatible with 'delta'. example: domain.entity_id1, domain.entity_id2 @@ -42,12 +22,11 @@ set: 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 + example: true remove: description: Remove a user group. fields: object_id: description: Group id and part of entity id. - example: 'test_group' - + example: "test_group" diff --git a/homeassistant/components/group/strings.json b/homeassistant/components/group/strings.json new file mode 100644 index 0000000000000..e29407bf932aa --- /dev/null +++ b/homeassistant/components/group/strings.json @@ -0,0 +1,17 @@ +{ + "title": "Group", + "state": { + "_": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]", + "home": "[%key:component::device_tracker::state::_::home%]", + "not_home": "[%key:component::device_tracker::state::_::not_home%]", + "open": "[%key:common::state::open%]", + "closed": "[%key:common::state::closed%]", + "locked": "[%key:common::state::locked%]", + "unlocked": "[%key:common::state::unlocked%]", + "ok": "[%key:component::binary_sensor::state::problem::off%]", + "problem": "[%key:component::binary_sensor::state::problem::on%]" + } + } +} diff --git a/homeassistant/components/group/translations/af.json b/homeassistant/components/group/translations/af.json new file mode 100644 index 0000000000000..4ababc0a7ec9f --- /dev/null +++ b/homeassistant/components/group/translations/af.json @@ -0,0 +1,17 @@ +{ + "state": { + "_": { + "closed": "Toe", + "home": "Tuis", + "locked": "Gesluit", + "not_home": "Elders", + "off": "Af", + "ok": "OK", + "on": "Aan", + "open": "Oop", + "problem": "Probleem", + "unlocked": "Oopgesluit" + } + }, + "title": "Groep" +} \ No newline at end of file diff --git a/homeassistant/components/group/translations/ar.json b/homeassistant/components/group/translations/ar.json new file mode 100644 index 0000000000000..26310b131c1f6 --- /dev/null +++ b/homeassistant/components/group/translations/ar.json @@ -0,0 +1,17 @@ +{ + "state": { + "_": { + "closed": "\u0645\u063a\u0644\u0642 ", + "home": "\u0641\u064a \u0627\u0644\u0645\u0646\u0632\u0644", + "locked": "\u0645\u0642\u0641\u0644 ", + "not_home": "\u0641\u064a \u0627\u0644\u062e\u0627\u0631\u062c", + "off": "\u0625\u064a\u0642\u0627\u0641", + "ok": "\u0623\u0648\u0643\u064a", + "on": "\u0642\u064a\u062f \u0627\u0644\u062a\u0634\u063a\u064a\u0644", + "open": "\u0645\u0641\u062a\u0648\u062d ", + "problem": "\u0645\u0634\u0643\u0644\u0629", + "unlocked": "\u063a\u064a\u0631 \u0645\u0642\u0641\u0644 " + } + }, + "title": "\u0645\u062c\u0645\u0648\u0639\u0629" +} \ No newline at end of file diff --git a/homeassistant/components/group/translations/bg.json b/homeassistant/components/group/translations/bg.json new file mode 100644 index 0000000000000..c737a09216e04 --- /dev/null +++ b/homeassistant/components/group/translations/bg.json @@ -0,0 +1,17 @@ +{ + "state": { + "_": { + "closed": "\u0417\u0430\u0442\u0432\u043e\u0440\u0435\u043d\u0430", + "home": "\u0412\u043a\u044a\u0449\u0438", + "locked": "\u0417\u0430\u043a\u043b\u044e\u0447\u0435\u043d\u0430", + "not_home": "\u041e\u0442\u0441\u044a\u0441\u0442\u0432\u0430", + "off": "\u0418\u0437\u043a\u043b\u044e\u0447\u0435\u043d", + "ok": "\u041e\u041a", + "on": "\u0412\u043a\u043b\u044e\u0447\u0435\u043d\u0430", + "open": "\u041e\u0442\u0432\u043e\u0440\u0435\u043d\u0430", + "problem": "\u041f\u0440\u043e\u0431\u043b\u0435\u043c", + "unlocked": "\u041e\u0442\u043a\u043b\u044e\u0447\u0435\u043d\u0430" + } + }, + "title": "\u0413\u0440\u0443\u043f\u0430" +} \ No newline at end of file diff --git a/homeassistant/components/group/translations/bs.json b/homeassistant/components/group/translations/bs.json new file mode 100644 index 0000000000000..b74015e389bd4 --- /dev/null +++ b/homeassistant/components/group/translations/bs.json @@ -0,0 +1,17 @@ +{ + "state": { + "_": { + "closed": "Zatvoren", + "home": "Kod ku\u0107e", + "locked": "Zaklju\u010dan", + "not_home": "Odsutan", + "off": "Isklju\u010den", + "ok": "OK", + "on": "Uklju\u010den", + "open": "Otvoren", + "problem": "Problem", + "unlocked": "Otklju\u010dan" + } + }, + "title": "Grupa" +} \ No newline at end of file diff --git a/homeassistant/components/group/translations/ca.json b/homeassistant/components/group/translations/ca.json new file mode 100644 index 0000000000000..bbbd84b2147d6 --- /dev/null +++ b/homeassistant/components/group/translations/ca.json @@ -0,0 +1,17 @@ +{ + "state": { + "_": { + "closed": "Tancat", + "home": "A casa", + "locked": "Bloquejat", + "not_home": "Fora", + "off": "Desactivat", + "ok": "Correcte", + "on": "Activat", + "open": "Obert", + "problem": "Problema", + "unlocked": "Desbloquejat" + } + }, + "title": "Grups" +} \ No newline at end of file diff --git a/homeassistant/components/group/translations/cs.json b/homeassistant/components/group/translations/cs.json new file mode 100644 index 0000000000000..b33ff28448bac --- /dev/null +++ b/homeassistant/components/group/translations/cs.json @@ -0,0 +1,17 @@ +{ + "state": { + "_": { + "closed": "Zav\u0159eno", + "home": "Doma", + "locked": "Zam\u010deno", + "not_home": "Pry\u010d", + "off": "Neaktivn\u00ed", + "ok": "V po\u0159\u00e1dku", + "on": "Aktivn\u00ed", + "open": "Otev\u0159eno", + "problem": "Probl\u00e9m", + "unlocked": "Odem\u010deno" + } + }, + "title": "Skupina" +} \ No newline at end of file diff --git a/homeassistant/components/group/translations/cy.json b/homeassistant/components/group/translations/cy.json new file mode 100644 index 0000000000000..51104ed48c12b --- /dev/null +++ b/homeassistant/components/group/translations/cy.json @@ -0,0 +1,17 @@ +{ + "state": { + "_": { + "closed": "Wedi cau", + "home": "Gartref", + "locked": " Cloi", + "not_home": "Dim gartref", + "off": "i ffwrdd", + "ok": "Iawn", + "on": "Ar", + "open": "Agored", + "problem": "Problem", + "unlocked": "Dadgloi" + } + }, + "title": "Gr\u0175p" +} \ No newline at end of file diff --git a/homeassistant/components/group/translations/da.json b/homeassistant/components/group/translations/da.json new file mode 100644 index 0000000000000..a0b58d0adab46 --- /dev/null +++ b/homeassistant/components/group/translations/da.json @@ -0,0 +1,17 @@ +{ + "state": { + "_": { + "closed": "Lukket", + "home": "Hjemme", + "locked": "L\u00e5st", + "not_home": "Ude", + "off": "Fra", + "ok": "OK", + "on": "Til", + "open": "\u00c5ben", + "problem": "Problem", + "unlocked": "Ul\u00e5st" + } + }, + "title": "Gruppe" +} \ No newline at end of file diff --git a/homeassistant/components/group/translations/de.json b/homeassistant/components/group/translations/de.json new file mode 100644 index 0000000000000..80da069e72a95 --- /dev/null +++ b/homeassistant/components/group/translations/de.json @@ -0,0 +1,17 @@ +{ + "state": { + "_": { + "closed": "Geschlossen", + "home": "Zu Hause", + "locked": "Verriegelt", + "not_home": "Abwesend", + "off": "Aus", + "ok": "OK", + "on": "An", + "open": "Offen", + "problem": "Problem", + "unlocked": "Entriegelt" + } + }, + "title": "Gruppe" +} \ No newline at end of file diff --git a/homeassistant/components/group/translations/el.json b/homeassistant/components/group/translations/el.json new file mode 100644 index 0000000000000..e22d7a788aff3 --- /dev/null +++ b/homeassistant/components/group/translations/el.json @@ -0,0 +1,17 @@ +{ + "state": { + "_": { + "closed": "\u039a\u03bb\u03b5\u03b9\u03c3\u03c4\u03cc", + "home": "\u03a3\u03c0\u03af\u03c4\u03b9", + "locked": "\u039a\u03bb\u03b5\u03b9\u03b4\u03c9\u03bc\u03ad\u03bd\u03bf", + "not_home": "\u0395\u03ba\u03c4\u03cc\u03c2 \u03a3\u03c0\u03b9\u03c4\u03b9\u03bf\u03cd", + "off": "\u0391\u03bd\u03b5\u03bd\u03b5\u03c1\u03b3\u03cc", + "ok": "\u0395\u03bd\u03c4\u03ac\u03be\u03b5\u03b9", + "on": "\u0395\u03bd\u03b5\u03c1\u03b3\u03cc", + "open": "\u0391\u03bd\u03bf\u03b9\u03c7\u03c4\u03cc", + "problem": "\u03a0\u03c1\u03cc\u03b2\u03bb\u03b7\u03bc\u03b1", + "unlocked": "\u039e\u03b5\u03ba\u03bb\u03b5\u03af\u03b4\u03c9\u03c4\u03bf" + } + }, + "title": "\u039f\u03bc\u03ac\u03b4\u03b1" +} \ No newline at end of file diff --git a/homeassistant/components/group/translations/en.json b/homeassistant/components/group/translations/en.json new file mode 100644 index 0000000000000..271ada378ca03 --- /dev/null +++ b/homeassistant/components/group/translations/en.json @@ -0,0 +1,17 @@ +{ + "state": { + "_": { + "closed": "Closed", + "home": "Home", + "locked": "Locked", + "not_home": "Away", + "off": "Off", + "ok": "OK", + "on": "On", + "open": "Open", + "problem": "Problem", + "unlocked": "Unlocked" + } + }, + "title": "Group" +} \ No newline at end of file diff --git a/homeassistant/components/group/translations/es-419.json b/homeassistant/components/group/translations/es-419.json new file mode 100644 index 0000000000000..17e89cb3c6162 --- /dev/null +++ b/homeassistant/components/group/translations/es-419.json @@ -0,0 +1,17 @@ +{ + "state": { + "_": { + "closed": "Cerrado", + "home": "En casa", + "locked": "Cerrado", + "not_home": "Fuera de Casa", + "off": "Apagado", + "ok": "OK", + "on": "Encendido", + "open": "Abierto", + "problem": "Problema", + "unlocked": "Abierto" + } + }, + "title": "Grupo" +} \ No newline at end of file diff --git a/homeassistant/components/group/translations/es.json b/homeassistant/components/group/translations/es.json new file mode 100644 index 0000000000000..9aac8e09780e9 --- /dev/null +++ b/homeassistant/components/group/translations/es.json @@ -0,0 +1,17 @@ +{ + "state": { + "_": { + "closed": "Cerrado", + "home": "En casa", + "locked": "Bloqueado", + "not_home": "Fuera de casa", + "off": "Apagado", + "ok": "OK", + "on": "Encendido", + "open": "Abierto", + "problem": "Problema", + "unlocked": "Desbloqueado" + } + }, + "title": "Grupo" +} \ No newline at end of file diff --git a/homeassistant/components/group/translations/et.json b/homeassistant/components/group/translations/et.json new file mode 100644 index 0000000000000..dacd0973f1d57 --- /dev/null +++ b/homeassistant/components/group/translations/et.json @@ -0,0 +1,17 @@ +{ + "state": { + "_": { + "closed": "Suletud", + "home": "Kodus", + "locked": "Lukus", + "not_home": "Eemal", + "off": "V\u00e4ljas", + "ok": "OK", + "on": "Sees", + "open": "Avatud", + "problem": "Probleem", + "unlocked": "Lukustamata" + } + }, + "title": "Grupp" +} \ No newline at end of file diff --git a/homeassistant/components/group/translations/eu.json b/homeassistant/components/group/translations/eu.json new file mode 100644 index 0000000000000..af90d04e5efab --- /dev/null +++ b/homeassistant/components/group/translations/eu.json @@ -0,0 +1,15 @@ +{ + "state": { + "_": { + "closed": "Itxita", + "home": "Etxean", + "not_home": "Kanpoan", + "off": "Itzalita", + "ok": "Itzalita", + "on": "Piztuta", + "open": "Ireki", + "problem": "Arazoa" + } + }, + "title": "Taldea" +} \ No newline at end of file diff --git a/homeassistant/components/group/translations/fa.json b/homeassistant/components/group/translations/fa.json new file mode 100644 index 0000000000000..8a12e9aeed02e --- /dev/null +++ b/homeassistant/components/group/translations/fa.json @@ -0,0 +1,17 @@ +{ + "state": { + "_": { + "closed": "\u0628\u0633\u062a\u0647", + "home": "\u062e\u0627\u0646\u0647", + "locked": "\u0642\u0641\u0644 \u0634\u062f\u0647", + "not_home": "\u0628\u06cc\u0631\u0648\u0646", + "off": "\u063a\u06cc\u0631\u0641\u0639\u0627\u0644", + "ok": "\u062e\u0648\u0628", + "on": "\u0641\u0639\u0627\u0644", + "open": "\u0628\u0627\u0632", + "problem": "\u0645\u0634\u06a9\u0644", + "unlocked": "\u0628\u0627\u0632" + } + }, + "title": "\u06af\u0631\u0648\u0647" +} \ No newline at end of file diff --git a/homeassistant/components/group/translations/fi.json b/homeassistant/components/group/translations/fi.json new file mode 100644 index 0000000000000..b83d12ebd1a32 --- /dev/null +++ b/homeassistant/components/group/translations/fi.json @@ -0,0 +1,17 @@ +{ + "state": { + "_": { + "closed": "Suljettu", + "home": "Kotona", + "locked": "Lukittu", + "not_home": "Poissa", + "off": "Pois", + "ok": "Ok", + "on": "P\u00e4\u00e4ll\u00e4", + "open": "Auki", + "problem": "Ongelma", + "unlocked": "Avattu" + } + }, + "title": "Ryhm\u00e4" +} \ No newline at end of file diff --git a/homeassistant/components/group/translations/fr.json b/homeassistant/components/group/translations/fr.json new file mode 100644 index 0000000000000..f1ade09f6504c --- /dev/null +++ b/homeassistant/components/group/translations/fr.json @@ -0,0 +1,17 @@ +{ + "state": { + "_": { + "closed": "Ferm\u00e9", + "home": "Pr\u00e9sent", + "locked": "Verrouill\u00e9", + "not_home": "Absent", + "off": "Inactif", + "ok": "OK", + "on": "Actif", + "open": "Ouvert", + "problem": "Probl\u00e8me", + "unlocked": "D\u00e9verrouill\u00e9" + } + }, + "title": "Groupe" +} \ No newline at end of file diff --git a/homeassistant/components/group/translations/gsw.json b/homeassistant/components/group/translations/gsw.json new file mode 100644 index 0000000000000..57a174ab1e02d --- /dev/null +++ b/homeassistant/components/group/translations/gsw.json @@ -0,0 +1,17 @@ +{ + "state": { + "_": { + "closed": "Gschloss\u00e4", + "home": "Dahei", + "locked": "Gsperrt", + "not_home": "Nid Dahei", + "off": "Us", + "ok": "Ok", + "on": "Ah", + "open": "Off\u00e4", + "problem": "Problem", + "unlocked": "Entsperrt" + } + }, + "title": "Gruppe" +} \ No newline at end of file diff --git a/homeassistant/components/group/translations/he.json b/homeassistant/components/group/translations/he.json new file mode 100644 index 0000000000000..caa6ee98ea84c --- /dev/null +++ b/homeassistant/components/group/translations/he.json @@ -0,0 +1,17 @@ +{ + "state": { + "_": { + "closed": "\u05e1\u05d2\u05d5\u05e8", + "home": "\u05d1\u05d1\u05d9\u05ea", + "locked": "\u05e0\u05e2\u05d5\u05dc", + "not_home": "\u05dc\u05d0 \u05d1\u05d1\u05d9\u05ea", + "off": "\u05db\u05d1\u05d5\u05d9", + "ok": "\u05ea\u05e7\u05d9\u05df", + "on": "\u05d3\u05dc\u05d5\u05e7", + "open": "\u05e4\u05ea\u05d5\u05d7", + "problem": "\u05d1\u05e2\u05d9\u05d4", + "unlocked": "\u05e4\u05ea\u05d5\u05d7" + } + }, + "title": "\u05e7\u05b0\u05d1\u05d5\u05bc\u05e6\u05b8\u05d4" +} \ No newline at end of file diff --git a/homeassistant/components/group/translations/hi.json b/homeassistant/components/group/translations/hi.json new file mode 100644 index 0000000000000..e4b98d10301e6 --- /dev/null +++ b/homeassistant/components/group/translations/hi.json @@ -0,0 +1,16 @@ +{ + "state": { + "_": { + "closed": "\u092c\u0902\u0926", + "home": "\u0918\u0930", + "locked": "\u0924\u093e\u0932\u093e \u092c\u0902\u0926 \u0939\u0948", + "off": "\u092c\u0902\u0926", + "ok": "\u0920\u0940\u0915", + "on": "\u091a\u093e\u0932\u0942", + "open": "\u0916\u0941\u0932\u093e", + "problem": "\u0938\u092e\u0938\u094d\u092f\u093e", + "unlocked": "\u0924\u093e\u0932\u093e \u0916\u0941\u0932\u093e \u0939\u0948" + } + }, + "title": "\u0938\u092e\u0942\u0939" +} \ No newline at end of file diff --git a/homeassistant/components/group/translations/hr.json b/homeassistant/components/group/translations/hr.json new file mode 100644 index 0000000000000..85abe33638b8d --- /dev/null +++ b/homeassistant/components/group/translations/hr.json @@ -0,0 +1,17 @@ +{ + "state": { + "_": { + "closed": "Zatvoreno", + "home": "Doma", + "locked": "Zaklju\u010dano", + "not_home": "Odsutan", + "off": "Uklju\u010deno", + "ok": "U redu", + "on": "Uklju\u010deno", + "open": "Otvoreno", + "problem": "Problem", + "unlocked": "Otklju\u010dano" + } + }, + "title": "Grupa" +} \ No newline at end of file diff --git a/homeassistant/components/group/translations/hu.json b/homeassistant/components/group/translations/hu.json new file mode 100644 index 0000000000000..3c4a8ce75c580 --- /dev/null +++ b/homeassistant/components/group/translations/hu.json @@ -0,0 +1,17 @@ +{ + "state": { + "_": { + "closed": "Z\u00e1rva", + "home": "Otthon", + "locked": "Bez\u00e1rva", + "not_home": "T\u00e1vol", + "off": "Ki", + "ok": "OK", + "on": "Be", + "open": "Nyitva", + "problem": "Probl\u00e9ma", + "unlocked": "Kinyitva" + } + }, + "title": "Csoport" +} \ No newline at end of file diff --git a/homeassistant/components/group/translations/hy.json b/homeassistant/components/group/translations/hy.json new file mode 100644 index 0000000000000..7ccd318451c49 --- /dev/null +++ b/homeassistant/components/group/translations/hy.json @@ -0,0 +1,17 @@ +{ + "state": { + "_": { + "closed": "\u0553\u0561\u056f\u057e\u0561\u056e", + "home": "\u054f\u0578\u0582\u0576", + "locked": "\u056f\u0578\u0572\u057a\u057e\u0561\u056e \u0567", + "not_home": "\u0540\u0565\u057c\u0578\u0582", + "off": "\u0531\u0576\u057b\u0561\u057f\u057e\u0561\u056e", + "ok": "\u053c\u0561\u057e", + "on": "\u0544\u056b\u0561\u0581\u0561\u056e", + "open": "\u0532\u0561\u0581\u0565\u0584", + "problem": "\u053d\u0576\u0564\u056b\u0580", + "unlocked": "\u0532\u0561\u0581\u0565\u056c \u0567" + } + }, + "title": "\u053d\u0578\u0582\u0574\u0562" +} \ No newline at end of file diff --git a/homeassistant/components/group/translations/id.json b/homeassistant/components/group/translations/id.json new file mode 100644 index 0000000000000..9a38f0f2de3c1 --- /dev/null +++ b/homeassistant/components/group/translations/id.json @@ -0,0 +1,17 @@ +{ + "state": { + "_": { + "closed": "Tertutup", + "home": "Rumah", + "locked": "Terkunci", + "not_home": "Keluar", + "off": "Off", + "ok": "OK", + "on": "On", + "open": "Terbuka", + "problem": "Masalah", + "unlocked": "Terbuka" + } + }, + "title": "Grup" +} \ No newline at end of file diff --git a/homeassistant/components/group/translations/is.json b/homeassistant/components/group/translations/is.json new file mode 100644 index 0000000000000..4e364e36f3edb --- /dev/null +++ b/homeassistant/components/group/translations/is.json @@ -0,0 +1,17 @@ +{ + "state": { + "_": { + "closed": "Loku\u00f0", + "home": "Heima", + "locked": "L\u00e6st", + "not_home": "Fjarverandi", + "off": "\u00d3virkur", + "ok": "\u00cd lagi", + "on": "Virkur", + "open": "Opin", + "problem": "Vandam\u00e1l", + "unlocked": "Afl\u00e6st" + } + }, + "title": "H\u00f3pur" +} \ No newline at end of file diff --git a/homeassistant/components/group/translations/it.json b/homeassistant/components/group/translations/it.json new file mode 100644 index 0000000000000..bbc9753909b76 --- /dev/null +++ b/homeassistant/components/group/translations/it.json @@ -0,0 +1,17 @@ +{ + "state": { + "_": { + "closed": "Chiuso", + "home": "A casa", + "locked": "Bloccato", + "not_home": "Fuori casa", + "off": "Spento", + "ok": "OK", + "on": "Acceso", + "open": "Aperto", + "problem": "Problema", + "unlocked": "Sbloccato" + } + }, + "title": "Gruppo" +} \ No newline at end of file diff --git a/homeassistant/components/group/translations/ja.json b/homeassistant/components/group/translations/ja.json new file mode 100644 index 0000000000000..d6f283d5ef638 --- /dev/null +++ b/homeassistant/components/group/translations/ja.json @@ -0,0 +1,14 @@ +{ + "state": { + "_": { + "closed": "\u9589\u9396", + "home": "\u5728\u5b85", + "locked": "\u30ed\u30c3\u30af\u3055\u308c\u307e\u3057\u305f", + "not_home": "\u5916\u51fa", + "off": "\u30aa\u30d5", + "ok": "OK", + "on": "\u30aa\u30f3" + } + }, + "title": "\u30b0\u30eb\u30fc\u30d7" +} \ No newline at end of file diff --git a/homeassistant/components/group/translations/ko.json b/homeassistant/components/group/translations/ko.json new file mode 100644 index 0000000000000..c2adb88c7ca28 --- /dev/null +++ b/homeassistant/components/group/translations/ko.json @@ -0,0 +1,17 @@ +{ + "state": { + "_": { + "closed": "\ub2eb\ud798", + "home": "\uc7ac\uc2e4", + "locked": "\uc7a0\uae40", + "not_home": "\uc678\ucd9c", + "off": "\uaebc\uc9d0", + "ok": "\ubb38\uc81c\uc5c6\uc74c", + "on": "\ucf1c\uc9d0", + "open": "\uc5f4\ub9bc", + "problem": "\ubb38\uc81c\uc788\uc74c", + "unlocked": "\ud574\uc81c" + } + }, + "title": "\uadf8\ub8f9" +} \ No newline at end of file diff --git a/homeassistant/components/group/translations/lb.json b/homeassistant/components/group/translations/lb.json new file mode 100644 index 0000000000000..aaa9e7b9d8154 --- /dev/null +++ b/homeassistant/components/group/translations/lb.json @@ -0,0 +1,17 @@ +{ + "state": { + "_": { + "closed": "Zou", + "home": "Doheem", + "locked": "Gespaart", + "not_home": "\u00cbnnerwee", + "off": "Aus", + "ok": "OK", + "on": "Un", + "open": "Op", + "problem": "Problem", + "unlocked": "Net gespaart" + } + }, + "title": "Grupp" +} \ No newline at end of file diff --git a/homeassistant/components/group/translations/lt.json b/homeassistant/components/group/translations/lt.json new file mode 100644 index 0000000000000..533d203663a29 --- /dev/null +++ b/homeassistant/components/group/translations/lt.json @@ -0,0 +1,9 @@ +{ + "state": { + "_": { + "off": "I\u0161jungta", + "ok": "Ok", + "on": "\u012ejungta" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/group/translations/lv.json b/homeassistant/components/group/translations/lv.json new file mode 100644 index 0000000000000..9d0c951820316 --- /dev/null +++ b/homeassistant/components/group/translations/lv.json @@ -0,0 +1,17 @@ +{ + "state": { + "_": { + "closed": "Sl\u0113gta", + "home": "M\u0101j\u0101s", + "locked": "Blo\u0137\u0113ta", + "not_home": "Promb\u016btn\u0113", + "off": "Izsl\u0113gta", + "ok": "OK", + "on": "Iesl\u0113gta", + "open": "Atv\u0113rta", + "problem": "Probl\u0113ma", + "unlocked": "Atblo\u0137\u0113ta" + } + }, + "title": "Grupa" +} \ No newline at end of file diff --git a/homeassistant/components/group/translations/nb.json b/homeassistant/components/group/translations/nb.json new file mode 100644 index 0000000000000..14ac7fac24f2a --- /dev/null +++ b/homeassistant/components/group/translations/nb.json @@ -0,0 +1,17 @@ +{ + "state": { + "_": { + "closed": "Lukket", + "home": "Hjemme", + "locked": "L\u00e5st", + "not_home": "Borte", + "off": "Av", + "ok": "", + "on": "P\u00e5", + "open": "\u00c5pen", + "problem": "Problem", + "unlocked": "Ul\u00e5st" + } + }, + "title": "Gruppe" +} \ No newline at end of file diff --git a/homeassistant/components/group/translations/nl.json b/homeassistant/components/group/translations/nl.json new file mode 100644 index 0000000000000..be9b55699b09d --- /dev/null +++ b/homeassistant/components/group/translations/nl.json @@ -0,0 +1,17 @@ +{ + "state": { + "_": { + "closed": "Gesloten", + "home": "Thuis", + "locked": "Vergrendeld", + "not_home": "Afwezig", + "off": "Uit", + "ok": "OK", + "on": "Aan", + "open": "Open", + "problem": "Probleem", + "unlocked": "Ontgrendeld" + } + }, + "title": "Groep" +} \ No newline at end of file diff --git a/homeassistant/components/group/translations/nn.json b/homeassistant/components/group/translations/nn.json new file mode 100644 index 0000000000000..972c43b81d65f --- /dev/null +++ b/homeassistant/components/group/translations/nn.json @@ -0,0 +1,17 @@ +{ + "state": { + "_": { + "closed": "Lukka", + "home": "Heime", + "locked": "L\u00e5st", + "not_home": "Borte", + "off": "Av", + "ok": "Ok", + "on": "P\u00e5", + "open": "Open", + "problem": "Problem", + "unlocked": "Ul\u00e5st" + } + }, + "title": "Gruppe" +} \ No newline at end of file diff --git a/homeassistant/components/group/translations/no.json b/homeassistant/components/group/translations/no.json new file mode 100644 index 0000000000000..7067eaed9469d --- /dev/null +++ b/homeassistant/components/group/translations/no.json @@ -0,0 +1,11 @@ +{ + "state": { + "_": { + "off": "Av", + "ok": "", + "on": "P\u00e5", + "problem": "" + } + }, + "title": "Gruppe" +} \ No newline at end of file diff --git a/homeassistant/components/group/translations/pl.json b/homeassistant/components/group/translations/pl.json new file mode 100644 index 0000000000000..b72716973d810 --- /dev/null +++ b/homeassistant/components/group/translations/pl.json @@ -0,0 +1,17 @@ +{ + "state": { + "_": { + "closed": "zamkni\u0119te", + "home": "w domu", + "locked": "zamkni\u0119ty", + "not_home": "poza domem", + "off": "wy\u0142\u0105czony", + "ok": "ok", + "on": "w\u0142\u0105czony", + "open": "otwarte", + "problem": "problem", + "unlocked": "otwarty" + } + }, + "title": "Grupa" +} \ No newline at end of file diff --git a/homeassistant/components/group/translations/pt-BR.json b/homeassistant/components/group/translations/pt-BR.json new file mode 100644 index 0000000000000..e0cbc7c02fd49 --- /dev/null +++ b/homeassistant/components/group/translations/pt-BR.json @@ -0,0 +1,17 @@ +{ + "state": { + "_": { + "closed": "Fechado", + "home": "Em casa", + "locked": "Trancado", + "not_home": "Ausente", + "off": "Desligado", + "ok": "OK", + "on": "Ligado", + "open": "Aberto", + "problem": "Problema", + "unlocked": "Destrancado" + } + }, + "title": "Grupo" +} \ No newline at end of file diff --git a/homeassistant/components/group/translations/pt.json b/homeassistant/components/group/translations/pt.json new file mode 100644 index 0000000000000..1dfe38b7b71ae --- /dev/null +++ b/homeassistant/components/group/translations/pt.json @@ -0,0 +1,17 @@ +{ + "state": { + "_": { + "closed": "Fechada", + "home": "Casa", + "locked": "Bloqueado", + "not_home": "Fora", + "off": "Desligado", + "ok": "OK", + "on": "Ligado", + "open": "Aberta", + "problem": "Problema", + "unlocked": "Desbloqueado" + } + }, + "title": "Grupo" +} \ No newline at end of file diff --git a/homeassistant/components/group/translations/ro.json b/homeassistant/components/group/translations/ro.json new file mode 100644 index 0000000000000..865c08d5ab556 --- /dev/null +++ b/homeassistant/components/group/translations/ro.json @@ -0,0 +1,17 @@ +{ + "state": { + "_": { + "closed": "\u00cenchis", + "home": "Acas\u0103", + "locked": "Blocat", + "not_home": "Plecat", + "off": "Oprit", + "ok": "OK", + "on": "Pornit", + "open": "Deschis", + "problem": "Problem\u0103", + "unlocked": "Deblocat" + } + }, + "title": "Grup" +} \ No newline at end of file diff --git a/homeassistant/components/group/translations/ru.json b/homeassistant/components/group/translations/ru.json new file mode 100644 index 0000000000000..7103b9f75d0fe --- /dev/null +++ b/homeassistant/components/group/translations/ru.json @@ -0,0 +1,17 @@ +{ + "state": { + "_": { + "closed": "\u0417\u0430\u043a\u0440\u044b\u0442\u043e", + "home": "\u0414\u043e\u043c\u0430", + "locked": "\u0417\u0430\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u0430\u043d\u043e", + "not_home": "\u041d\u0435 \u0434\u043e\u043c\u0430", + "off": "\u0412\u044b\u043a\u043b", + "ok": "\u041e\u041a", + "on": "\u0412\u043a\u043b", + "open": "\u041e\u0442\u043a\u0440\u044b\u0442\u043e", + "problem": "\u041f\u0440\u043e\u0431\u043b\u0435\u043c\u0430", + "unlocked": "\u0420\u0430\u0437\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u0430\u043d\u043e" + } + }, + "title": "\u0413\u0440\u0443\u043f\u043f\u0430" +} \ No newline at end of file diff --git a/homeassistant/components/group/translations/sk.json b/homeassistant/components/group/translations/sk.json new file mode 100644 index 0000000000000..151cc1c47b068 --- /dev/null +++ b/homeassistant/components/group/translations/sk.json @@ -0,0 +1,17 @@ +{ + "state": { + "_": { + "closed": "Zatvoren\u00e1", + "home": "Doma", + "locked": "Zamknut\u00e1", + "not_home": "Pre\u010d", + "off": "Vypnut\u00e1", + "ok": "OK", + "on": "Zapnut\u00e1", + "open": "Otvoren\u00e1", + "problem": "Probl\u00e9m", + "unlocked": "Odomknut\u00e1" + } + }, + "title": "Skupina" +} \ No newline at end of file diff --git a/homeassistant/components/group/translations/sl.json b/homeassistant/components/group/translations/sl.json new file mode 100644 index 0000000000000..f810bbc6d2d06 --- /dev/null +++ b/homeassistant/components/group/translations/sl.json @@ -0,0 +1,17 @@ +{ + "state": { + "_": { + "closed": "Zaprto", + "home": "Doma", + "locked": "Zaklenjeno", + "not_home": "Odsoten", + "off": "Izklju\u010den", + "ok": "OK", + "on": "Vklopljen", + "open": "Odprto", + "problem": "Te\u017eava", + "unlocked": "Odklenjeno" + } + }, + "title": "Skupina" +} \ No newline at end of file diff --git a/homeassistant/components/group/translations/sv.json b/homeassistant/components/group/translations/sv.json new file mode 100644 index 0000000000000..50b3f605682ca --- /dev/null +++ b/homeassistant/components/group/translations/sv.json @@ -0,0 +1,17 @@ +{ + "state": { + "_": { + "closed": "St\u00e4ngd", + "home": "Hemma", + "locked": "L\u00e5st", + "not_home": "Borta", + "off": "Av", + "ok": "Ok", + "on": "P\u00e5", + "open": "\u00d6ppen", + "problem": "Problem", + "unlocked": "Ol\u00e5st" + } + }, + "title": "Grupp" +} \ No newline at end of file diff --git a/homeassistant/components/group/translations/ta.json b/homeassistant/components/group/translations/ta.json new file mode 100644 index 0000000000000..225ebd99fa7a0 --- /dev/null +++ b/homeassistant/components/group/translations/ta.json @@ -0,0 +1,16 @@ +{ + "state": { + "_": { + "closed": "\u0bae\u0bc2\u0b9f\u0baa\u0bcd\u0baa\u0b9f\u0bcd\u0b9f\u0bc1\u0bb3\u0bcd\u0bb3\u0ba4\u0bc1 ", + "home": "\u0bb5\u0bc0\u0b9f\u0bcd\u0b9f\u0bbf\u0bb2\u0bcd", + "locked": "\u0baa\u0bc2\u0b9f\u0bcd\u0b9f\u0baa\u0bcd\u0baa\u0b9f\u0bcd\u0b9f\u0bc1\u0bb3\u0bcd\u0bb3\u0ba4\u0bc1 ", + "not_home": "\u0ba4\u0bca\u0bb2\u0bc8\u0bb5\u0bbf\u0bb2\u0bcd", + "off": "\u0b86\u0b83\u0baa\u0bcd", + "ok": "\u0b9a\u0bb0\u0bbf", + "on": "\u0b86\u0ba9\u0bcd", + "open": "\u0ba4\u0bbf\u0bb1\u0b95\u0bcd\u0b95\u0baa\u0bcd\u0baa\u0b9f\u0bcd\u0b9f\u0bc1\u0bb3\u0bcd\u0bb3\u0ba4\u0bc1 ", + "problem": "\u0b9a\u0bbf\u0b95\u0bcd\u0b95\u0bb2\u0bcd", + "unlocked": "\u0ba4\u0bbf\u0bb1\u0b95\u0bcd\u0b95\u0baa\u0bcd\u0baa\u0b9f\u0bcd\u0b9f\u0bc1\u0bb3\u0bcd\u0bb3\u0ba4\u0bc1 " + } + } +} \ No newline at end of file diff --git a/homeassistant/components/group/translations/te.json b/homeassistant/components/group/translations/te.json new file mode 100644 index 0000000000000..ef4ed78af516e --- /dev/null +++ b/homeassistant/components/group/translations/te.json @@ -0,0 +1,17 @@ +{ + "state": { + "_": { + "closed": "\u0c2e\u0c42\u0c38\u0c41\u0c15\u0c41\u0c02\u0c26\u0c3f", + "home": "\u0c07\u0c02\u0c1f", + "locked": "\u0c2e\u0c42\u0c38\u0c3f \u0c35\u0c41\u0c02\u0c21\u0c41", + "not_home": "\u0c2c\u0c2f\u0c1f", + "off": "\u0c06\u0c2b\u0c4d", + "ok": "\u0c05\u0c32\u0c3e\u0c17\u0c47", + "on": "\u0c06\u0c28\u0c4d", + "open": "\u0c24\u0c46\u0c30\u0c3f\u0c1a\u0c3f\u0c35\u0c41\u0c02\u0c26\u0c3f", + "problem": "\u0c38\u0c2e\u0c38\u0c4d\u0c2f", + "unlocked": "\u0c24\u0c46\u0c30\u0c41\u0c1a\u0c3f \u0c35\u0c41\u0c02\u0c21\u0c41" + } + }, + "title": "\u0c17\u0c4d\u0c30\u0c42\u0c2a\u0c4d" +} \ No newline at end of file diff --git a/homeassistant/components/group/translations/th.json b/homeassistant/components/group/translations/th.json new file mode 100644 index 0000000000000..e90a6173e1598 --- /dev/null +++ b/homeassistant/components/group/translations/th.json @@ -0,0 +1,17 @@ +{ + "state": { + "_": { + "closed": "\u0e1b\u0e34\u0e14\u0e41\u0e25\u0e49\u0e27", + "home": "\u0e2d\u0e22\u0e39\u0e48\u0e1a\u0e49\u0e32\u0e19", + "locked": "\u0e25\u0e47\u0e2d\u0e04\u0e41\u0e25\u0e49\u0e27", + "not_home": "\u0e44\u0e21\u0e48\u0e2d\u0e22\u0e39\u0e48\u0e1a\u0e49\u0e32\u0e19", + "off": "\u0e1b\u0e34\u0e14", + "ok": "\u0e1e\u0e23\u0e49\u0e2d\u0e21\u0e43\u0e0a\u0e49\u0e07\u0e32\u0e19", + "on": "\u0e40\u0e1b\u0e34\u0e14", + "open": "\u0e40\u0e1b\u0e34\u0e14", + "problem": "\u0e21\u0e35\u0e1b\u0e31\u0e0d\u0e2b\u0e32", + "unlocked": "\u0e1b\u0e25\u0e14\u0e25\u0e47\u0e2d\u0e04\u0e41\u0e25\u0e49\u0e27" + } + }, + "title": "\u0e01\u0e25\u0e38\u0e48\u0e21" +} \ No newline at end of file diff --git a/homeassistant/components/group/translations/tr.json b/homeassistant/components/group/translations/tr.json new file mode 100644 index 0000000000000..5a596efdf0108 --- /dev/null +++ b/homeassistant/components/group/translations/tr.json @@ -0,0 +1,17 @@ +{ + "state": { + "_": { + "closed": "Kapand\u0131", + "home": "Evde", + "locked": "Kilitli", + "not_home": "D\u0131\u015far\u0131da", + "off": "Kapal\u0131", + "ok": "Tamam", + "on": "A\u00e7\u0131k", + "open": "A\u00e7\u0131k", + "problem": "Problem", + "unlocked": "Kilitli de\u011fil" + } + }, + "title": "Grup" +} \ No newline at end of file diff --git a/homeassistant/components/group/translations/uk.json b/homeassistant/components/group/translations/uk.json new file mode 100644 index 0000000000000..2d57686134a7d --- /dev/null +++ b/homeassistant/components/group/translations/uk.json @@ -0,0 +1,17 @@ +{ + "state": { + "_": { + "closed": "\u0417\u0430\u0447\u0438\u043d\u0435\u043d\u043e", + "home": "\u0412\u0434\u043e\u043c\u0430", + "locked": "\u0417\u0430\u0431\u043b\u043e\u043a\u043e\u0432\u0430\u043d\u043e", + "not_home": "\u041d\u0435 \u0432\u0434\u043e\u043c\u0430", + "off": "\u0412\u0438\u043c\u043a\u043d\u0435\u043d\u043e", + "ok": "\u041e\u041a", + "on": "\u0423\u0432\u0456\u043c\u043a\u043d\u0435\u043d\u043e", + "open": "\u0412\u0456\u0434\u0447\u0438\u043d\u0435\u043d\u043e", + "problem": "\u0425\u0430\u043b\u0435\u043f\u0430", + "unlocked": "\u0420\u043e\u0437\u0431\u043b\u043e\u043a\u043e\u0432\u0430\u043d\u043e" + } + }, + "title": "\u0413\u0440\u0443\u043f\u0430" +} \ No newline at end of file diff --git a/homeassistant/components/group/translations/vi.json b/homeassistant/components/group/translations/vi.json new file mode 100644 index 0000000000000..f95e6ed506ffa --- /dev/null +++ b/homeassistant/components/group/translations/vi.json @@ -0,0 +1,17 @@ +{ + "state": { + "_": { + "closed": "\u0110\u00e3 \u0111\u00f3ng", + "home": "\u1ede nh\u00e0", + "locked": "Kho\u00e1", + "not_home": "\u0110i v\u1eafng", + "off": "T\u1eaft", + "ok": "OK", + "on": "B\u1eadt", + "open": "M\u1edf", + "problem": "V\u1ea5n \u0111\u1ec1", + "unlocked": "M\u1edf kho\u00e1" + } + }, + "title": "Nh\u00f3m" +} \ No newline at end of file diff --git a/homeassistant/components/group/translations/zh-Hans.json b/homeassistant/components/group/translations/zh-Hans.json new file mode 100644 index 0000000000000..66577b36963f3 --- /dev/null +++ b/homeassistant/components/group/translations/zh-Hans.json @@ -0,0 +1,17 @@ +{ + "state": { + "_": { + "closed": "\u5df2\u5173\u95ed", + "home": "\u5728\u5bb6", + "locked": "\u5df2\u9501\u5b9a", + "not_home": "\u79bb\u5f00", + "off": "\u5173\u95ed", + "ok": "\u6b63\u5e38", + "on": "\u5f00\u542f", + "open": "\u5f00\u542f", + "problem": "\u5f02\u5e38", + "unlocked": "\u5df2\u89e3\u9501" + } + }, + "title": "\u7fa4\u7ec4" +} \ No newline at end of file diff --git a/homeassistant/components/group/translations/zh-Hant.json b/homeassistant/components/group/translations/zh-Hant.json new file mode 100644 index 0000000000000..790feb0c5ff24 --- /dev/null +++ b/homeassistant/components/group/translations/zh-Hant.json @@ -0,0 +1,17 @@ +{ + "state": { + "_": { + "closed": "\u95dc\u9589", + "home": "\u5728\u5bb6", + "locked": "\u5df2\u4e0a\u9396", + "not_home": "\u96e2\u5bb6", + "off": "\u95dc\u9589", + "ok": "\u6b63\u5e38", + "on": "\u958b\u555f", + "open": "\u958b\u555f", + "problem": "\u7570\u5e38", + "unlocked": "\u5df2\u89e3\u9396" + } + }, + "title": "\u7fa4\u7d44" +} \ No newline at end of file diff --git a/homeassistant/components/growatt_server/__init__.py b/homeassistant/components/growatt_server/__init__.py new file mode 100644 index 0000000000000..14205e8d9ba3c --- /dev/null +++ b/homeassistant/components/growatt_server/__init__.py @@ -0,0 +1 @@ +"""The Growatt server PV inverter sensor integration.""" diff --git a/homeassistant/components/growatt_server/manifest.json b/homeassistant/components/growatt_server/manifest.json new file mode 100644 index 0000000000000..7d8a8a3852fa9 --- /dev/null +++ b/homeassistant/components/growatt_server/manifest.json @@ -0,0 +1,7 @@ +{ + "domain": "growatt_server", + "name": "Growatt", + "documentation": "https://www.home-assistant.io/integrations/growatt_server/", + "requirements": ["growattServer==0.0.1"], + "codeowners": ["@indykoning"] +} diff --git a/homeassistant/components/growatt_server/sensor.py b/homeassistant/components/growatt_server/sensor.py new file mode 100644 index 0000000000000..6bfdf7f2552bf --- /dev/null +++ b/homeassistant/components/growatt_server/sensor.py @@ -0,0 +1,222 @@ +"""Read status of growatt inverters.""" +import datetime +import json +import logging +import re + +import growattServer +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_NAME, + CONF_PASSWORD, + CONF_USERNAME, + ENERGY_KILO_WATT_HOUR, + FREQUENCY_HERTZ, + POWER_WATT, + VOLT, +) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle + +_LOGGER = logging.getLogger(__name__) + +CONF_PLANT_ID = "plant_id" +DEFAULT_PLANT_ID = "0" +DEFAULT_NAME = "Growatt" +SCAN_INTERVAL = datetime.timedelta(minutes=5) + +TOTAL_SENSOR_TYPES = { + "total_money_today": ("Total money today", "€", "plantMoneyText", None), + "total_money_total": ("Money lifetime", "€", "totalMoneyText", None), + "total_energy_today": ( + "Energy Today", + ENERGY_KILO_WATT_HOUR, + "todayEnergy", + "power", + ), + "total_output_power": ("Output Power", POWER_WATT, "invTodayPpv", "power"), + "total_energy_output": ( + "Lifetime energy output", + ENERGY_KILO_WATT_HOUR, + "totalEnergy", + "power", + ), + "total_maximum_output": ("Maximum power", POWER_WATT, "nominalPower", "power"), +} + +INVERTER_SENSOR_TYPES = { + "inverter_energy_today": ( + "Energy today", + ENERGY_KILO_WATT_HOUR, + "e_today", + "power", + ), + "inverter_energy_total": ( + "Lifetime energy output", + ENERGY_KILO_WATT_HOUR, + "e_total", + "power", + ), + "inverter_voltage_input_1": ("Input 1 voltage", VOLT, "vpv1", None), + "inverter_amperage_input_1": ("Input 1 Amperage", "A", "ipv1", None), + "inverter_wattage_input_1": ("Input 1 Wattage", POWER_WATT, "ppv1", "power"), + "inverter_voltage_input_2": ("Input 2 voltage", VOLT, "vpv2", None), + "inverter_amperage_input_2": ("Input 2 Amperage", "A", "ipv2", None), + "inverter_wattage_input_2": ("Input 2 Wattage", POWER_WATT, "ppv2", "power"), + "inverter_voltage_input_3": ("Input 3 voltage", VOLT, "vpv3", None), + "inverter_amperage_input_3": ("Input 3 Amperage", "A", "ipv3", None), + "inverter_wattage_input_3": ("Input 3 Wattage", POWER_WATT, "ppv3", "power"), + "inverter_internal_wattage": ("Internal wattage", POWER_WATT, "ppv", "power"), + "inverter_reactive_voltage": ("Reactive voltage", VOLT, "vacr", None), + "inverter_inverter_reactive_amperage": ("Reactive amperage", "A", "iacr", None), + "inverter_frequency": ("AC frequency", FREQUENCY_HERTZ, "fac", None), + "inverter_current_wattage": ("Output power", POWER_WATT, "pac", "power"), + "inverter_current_reactive_wattage": ( + "Reactive wattage", + POWER_WATT, + "pacr", + "power", + ), +} + +SENSOR_TYPES = {**TOTAL_SENSOR_TYPES, **INVERTER_SENSOR_TYPES} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PLANT_ID, default=DEFAULT_PLANT_ID): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Growatt sensor.""" + username = config[CONF_USERNAME] + password = config[CONF_PASSWORD] + plant_id = config[CONF_PLANT_ID] + name = config[CONF_NAME] + + api = growattServer.GrowattApi() + + # Log in to api and fetch first plant if no plant id is defined. + login_response = api.login(username, password) + if not login_response["success"] and login_response["errCode"] == "102": + _LOGGER.error("Username or Password may be incorrect!") + return + user_id = login_response["userId"] + if plant_id == DEFAULT_PLANT_ID: + plant_info = api.plant_list(user_id) + plant_id = plant_info["data"][0]["plantId"] + + # Get a list of inverters for specified plant to add sensors for. + inverters = api.inverter_list(plant_id) + entities = [] + probe = GrowattData(api, username, password, plant_id, True) + for sensor in TOTAL_SENSOR_TYPES: + entities.append( + GrowattInverter(probe, f"{name} Total", sensor, f"{plant_id}-{sensor}") + ) + + # Add sensors for each inverter in the specified plant. + for inverter in inverters: + probe = GrowattData(api, username, password, inverter["deviceSn"], False) + for sensor in INVERTER_SENSOR_TYPES: + entities.append( + GrowattInverter( + probe, + f"{inverter['deviceAilas']}", + sensor, + f"{inverter['deviceSn']}-{sensor}", + ) + ) + + add_entities(entities, True) + + +class GrowattInverter(Entity): + """Representation of a Growatt Sensor.""" + + def __init__(self, probe, name, sensor, unique_id): + """Initialize a PVOutput sensor.""" + self.sensor = sensor + self.probe = probe + self._name = name + self._state = None + self._unique_id = unique_id + + @property + def name(self): + """Return the name of the sensor.""" + return f"{self._name} {SENSOR_TYPES[self.sensor][0]}" + + @property + def unique_id(self): + """Return the unique id of the sensor.""" + return self._unique_id + + @property + def icon(self): + """Return the icon of the sensor.""" + return "mdi:solar-power" + + @property + def state(self): + """Return the state of the sensor.""" + return self.probe.get_data(SENSOR_TYPES[self.sensor][2]) + + @property + def device_class(self): + """Return the device class of the sensor.""" + return SENSOR_TYPES[self.sensor][3] + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return SENSOR_TYPES[self.sensor][1] + + def update(self): + """Get the latest data from the Growat API and updates the state.""" + self.probe.update() + + +class GrowattData: + """The class for handling data retrieval.""" + + def __init__(self, api, username, password, inverter_id, is_total=False): + """Initialize the probe.""" + + self.is_total = is_total + self.api = api + self.inverter_id = inverter_id + self.data = {} + self.username = username + self.password = password + + @Throttle(SCAN_INTERVAL) + def update(self): + """Update probe data.""" + self.api.login(self.username, self.password) + _LOGGER.debug("Updating data for %s", self.inverter_id) + try: + if self.is_total: + total_info = self.api.plant_info(self.inverter_id) + del total_info["deviceList"] + # PlantMoneyText comes in as "3.1/€" remove anything that isn't part of the number + total_info["plantMoneyText"] = re.sub( + r"[^\d.,]", "", total_info["plantMoneyText"] + ) + self.data = total_info + else: + inverter_info = self.api.inverter_detail(self.inverter_id) + self.data = inverter_info["data"] + except json.decoder.JSONDecodeError: + _LOGGER.error("Unable to fetch data from Growatt server") + + def get_data(self, variable): + """Get the data.""" + return self.data.get(variable) diff --git a/homeassistant/components/gstreamer/manifest.json b/homeassistant/components/gstreamer/manifest.json index 6bfb8abbe0b5b..691d26ce009cf 100644 --- a/homeassistant/components/gstreamer/manifest.json +++ b/homeassistant/components/gstreamer/manifest.json @@ -1,10 +1,7 @@ { "domain": "gstreamer", - "name": "Gstreamer", - "documentation": "https://www.home-assistant.io/components/gstreamer", - "requirements": [ - "gstreamer-player==1.1.2" - ], - "dependencies": [], + "name": "GStreamer", + "documentation": "https://www.home-assistant.io/integrations/gstreamer", + "requirements": ["gstreamer-player==1.1.2"], "codeowners": [] } diff --git a/homeassistant/components/gstreamer/media_player.py b/homeassistant/components/gstreamer/media_player.py index f74040105130f..ea211ccd748ad 100644 --- a/homeassistant/components/gstreamer/media_player.py +++ b/homeassistant/components/gstreamer/media_player.py @@ -1,34 +1,43 @@ """Play media via gstreamer.""" import logging +from gsp import GstreamerPlayer import voluptuous as vol -from homeassistant.components.media_player import ( - MediaPlayerDevice, PLATFORM_SCHEMA) +from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerEntity from homeassistant.components.media_player.const import ( - MEDIA_TYPE_MUSIC, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, - SUPPORT_PLAY, SUPPORT_PLAY_MEDIA, SUPPORT_VOLUME_SET) + 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' +CONF_PIPELINE = "pipeline" -DOMAIN = 'gstreamer' +DOMAIN = "gstreamer" -SUPPORT_GSTREAMER = SUPPORT_VOLUME_SET | SUPPORT_PLAY | SUPPORT_PAUSE |\ - SUPPORT_PLAY_MEDIA | SUPPORT_NEXT_TRACK +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, -}) +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) @@ -41,7 +50,7 @@ def _shutdown(call): add_entities([GstreamerDevice(player, name)]) -class GstreamerDevice(MediaPlayerDevice): +class GstreamerDevice(MediaPlayerEntity): """Representation of a Gstreamer device.""" def __init__(self, player, name): @@ -73,7 +82,7 @@ def set_volume_level(self, volume): def play_media(self, media_type, media_id, **kwargs): """Play media.""" if media_type != MEDIA_TYPE_MUSIC: - _LOGGER.error('invalid media type') + _LOGGER.error("invalid media type") return self._player.queue(media_id) diff --git a/homeassistant/components/gtfs/manifest.json b/homeassistant/components/gtfs/manifest.json index 1c7ddbd65ee90..c3efd8cdaed4c 100644 --- a/homeassistant/components/gtfs/manifest.json +++ b/homeassistant/components/gtfs/manifest.json @@ -1,12 +1,7 @@ { "domain": "gtfs", - "name": "Gtfs", - "documentation": "https://www.home-assistant.io/components/gtfs", - "requirements": [ - "pygtfs==0.1.5" - ], - "dependencies": [], - "codeowners": [ - "@robbiet480" - ] + "name": "General Transit Feed Specification (GTFS)", + "documentation": "https://www.home-assistant.io/integrations/gtfs", + "requirements": ["pygtfs==0.1.5"], + "codeowners": ["@robbiet480"] } diff --git a/homeassistant/components/gtfs/sensor.py b/homeassistant/components/gtfs/sensor.py index 0a9301f8c3337..e4e00c7c2ec65 100644 --- a/homeassistant/components/gtfs/sensor.py +++ b/homeassistant/components/gtfs/sensor.py @@ -5,148 +5,149 @@ import threading from typing import Any, Callable, Optional +import pygtfs +from sqlalchemy.sql import text 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) + 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.helpers.typing import ( + ConfigType, + DiscoveryInfoType, + 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_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' +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, -} +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', + 0: "Regular", + 1: "Not Available", + 2: "Call Agency", + 3: "Contact Driver", } -ICON = 'mdi:train' +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', + 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_DEFAULT = "Stop" LOCATION_TYPE_OPTIONS = { - 0: 'Station', - 1: 'Stop', + 0: "Station", + 1: "Stop", 2: "Station Entrance/Exit", - 3: 'Other', + 3: "Other", } PICKUP_TYPE_DEFAULT = STATE_UNKNOWN PICKUP_TYPE_OPTIONS = { - 0: 'Regular', + 0: "Regular", 1: "None Available", 2: "Call Agency", 3: "Contact Driver", } ROUTE_TYPE_OPTIONS = { - 0: 'Tram', - 1: 'Subway', - 2: 'Rail', - 3: 'Bus', - 4: 'Ferry', + 0: "Tram", + 1: "Subway", + 2: "Rail", + 3: "Bus", + 4: "Ferry", 5: "Cable Tram", 6: "Aerial Lift", - 7: 'Funicular', + 7: "Funicular", } TIMEPOINT_DEFAULT = True -TIMEPOINT_OPTIONS = { - 0: False, - 1: True, -} +TIMEPOINT_OPTIONS = {0: False, 1: True} WHEELCHAIR_ACCESS_DEFAULT = STATE_UNKNOWN -WHEELCHAIR_ACCESS_OPTIONS = { - 1: True, - 2: False, -} +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, -}) +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: +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 = dt_util.now().replace(tzinfo=None) + 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 = '' + 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) + tomorrow_name = tomorrow.strftime("%A").lower() + tomorrow_select = f"calendar.{tomorrow_name} AS tomorrow," + tomorrow_where = f"OR calendar.{tomorrow_name} = 1" + tomorrow_order = f"calendar.{tomorrow_name} DESC," - sql_query = """ + sql_query = f""" 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, @@ -165,8 +166,8 @@ def get_next_departure(schedule: Any, start_station_id: Any, 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, + calendar.{yesterday.strftime("%A").lower()} AS yesterday, + calendar.{now.strftime("%A").lower()} AS today, {tomorrow_select} calendar.start_date AS start_date, calendar.end_date AS end_date @@ -181,8 +182,8 @@ def get_next_departure(schedule: Any, start_station_id: Any, 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 + WHERE (calendar.{yesterday.strftime("%A").lower()} = 1 + OR calendar.{now.strftime("%A").lower()} = 1 {tomorrow_where} ) AND start_station.stop_id = :origin_station_id @@ -190,88 +191,76 @@ def get_next_departure(schedule: Any, start_station_id: Any, 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, + ORDER BY calendar.{yesterday.strftime("%A").lower()} DESC, + calendar.{now.strftime("%A").lower()} 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) + """ + 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 = '' + 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 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']) + yesterday_start = row["origin_depart_date"] + if yesterday_start != row["origin_depart_date"]: + idx = f"{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 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']: + 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']) + idx = f"{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" 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']) + tomorrow_start = row["origin_depart_date"] + extras["first"] = True + if tomorrow_start == row["origin_depart_date"]: + idx = f"{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 + timetable[idx]["last"] = True _LOGGER.debug("Timetable: %s", sorted(timetable.keys())) - item = {} # type: dict + item = {} 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) + _LOGGER.debug( + "Departure found for station %s @ %s -> %s", start_station_id, key, item + ) break if item == {}: @@ -280,69 +269,75 @@ def get_next_departure(schedule: Any, start_station_id: Any, # 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']: + 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_arrival_time = ( + f"{origin_arrival.strftime(dt_util.DATE_STR_FORMAT)} " + f"{item['origin_arrival_time']}" + ) - origin_depart_time = '{} {}'.format(now_date, item['origin_depart_time']) + origin_depart_time = f"{now_date} {item['origin_depart_time']}" dest_arrival = now - if item['dest_arrival_time'] < item['origin_depart_time']: + 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_arrival_time = ( + f"{dest_arrival.strftime(dt_util.DATE_STR_FORMAT)} " + f"{item['dest_arrival_time']}" + ) dest_depart = dest_arrival - if item['dest_depart_time'] < item['dest_arrival_time']: + 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']) + dest_depart_time = ( + f"{dest_depart.strftime(dt_util.DATE_STR_FORMAT)} " + f"{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'], + "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'], + "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, + "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: +def setup_platform( + hass: HomeAssistantType, + config: ConfigType, + add_entities: Callable[[list], None], + discovery_info: Optional[DiscoveryInfoType] = None, +) -> None: """Set up the GTFS sensor.""" gtfs_dir = hass.config.path(DEFAULT_PATH) data = config[CONF_DATA] @@ -359,11 +354,9 @@ def setup_platform(hass: HomeAssistantType, config: ConfigType, _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) + sqlite_file = f"{gtfs_root}.sqlite?check_same_thread=False" joined_path = os.path.join(gtfs_dir, sqlite_file) gtfs = pygtfs.Schedule(joined_path) @@ -371,19 +364,25 @@ def setup_platform(hass: HomeAssistantType, config: ConfigType, if not gtfs.feeds: pygtfs.append_feed(gtfs, os.path.join(gtfs_dir, data)) - add_entities([ - GTFSDepartureSensor(gtfs, name, origin, destination, offset, - include_tomorrow)]) + 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: + def __init__( + self, + gtfs: Any, + name: Optional[Any], + origin: Any, + destination: Any, + offset: cv.time_period, + include_tomorrow: bool, + ) -> None: """Initialize the sensor.""" - self._pygtfs = pygtfs + self._pygtfs = gtfs self.origin = origin self.destination = destination self._include_tomorrow = include_tomorrow @@ -392,12 +391,12 @@ def __init__(self, pygtfs: Any, name: Optional[Any], origin: Any, self._available = False self._icon = ICON - self._name = '' - self._state = None # type: Optional[str] - self._attributes = {} # type: dict + self._name = "" + self._state: Optional[str] = None + self._attributes = {} self._agency = None - self._departure = {} # type: dict + self._departure = {} self._destination = None self._origin = None self._route = None @@ -452,8 +451,9 @@ def update(self) -> None: stops = self._pygtfs.stops_by_id(self.destination) if not stops: self._available = False - _LOGGER.warning("Destination stop ID %s not found", - self.destination) + _LOGGER.warning( + "Destination stop ID %s not found", self.destination + ) return self._destination = stops[0] @@ -461,43 +461,47 @@ def update(self) -> None: # Fetch next departure self._departure = get_next_departure( - self._pygtfs, self.origin, self.destination, self._offset, - self._include_tomorrow) + 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() + 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'] + 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'] + 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) + _LOGGER.debug("Fetching agency details for %s", self._route.agency_id) try: - self._agency = self._pygtfs.agencies_by_id( - self._route.agency_id)[0] + 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._route.agency_id, + ) self._agency = False # Assign attributes, icon and name @@ -508,33 +512,31 @@ def update(self) -> None: else: self._icon = ICON - name = '{agency} {origin} to {destination} next departure' + name = ( + f"{getattr(self._agency, 'agency_name', DEFAULT_NAME)} " + f"{self.origin} to {self.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)) + name = f"{DEFAULT_NAME}" + self._name = self._custom_name or name 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._departure["arrival_time"] + ).isoformat() - self._attributes[ATTR_DAY] = self._departure['day'] + self._attributes[ATTR_DAY] = self._departure["day"] if self._departure[ATTR_FIRST] is not None: - self._attributes[ATTR_FIRST] = self._departure['first'] + 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'] + self._attributes[ATTR_LAST] = self._departure["last"] elif ATTR_LAST in self._attributes: del self._attributes[ATTR_LAST] else: @@ -551,8 +553,11 @@ def update_attributes(self) -> None: 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" + 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] @@ -562,113 +567,115 @@ def update_attributes(self) -> None: del self._attributes[ATTR_ATTRIBUTION] # Add extra metadata - key = 'agency_id' + key = "agency_id" if self._agency and key not in self._attributes: - self.append_keys(self.dict_for_table(self._agency), 'Agency') + self.append_keys(self.dict_for_table(self._agency), "Agency") - key = 'origin_station_stop_id' + 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' + 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) + 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' + 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] + 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' + 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.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._trip.bikes_allowed, BICYCLE_ALLOWED_DEFAULT + ) self._attributes[ATTR_WHEELCHAIR] = WHEELCHAIR_ACCESS_OPTIONS.get( - self._trip.wheelchair_accessible, - WHEELCHAIR_ACCESS_DEFAULT) + self._trip.wheelchair_accessible, WHEELCHAIR_ACCESS_DEFAULT + ) # Manage Stop Times metadata - prefix = 'origin_stop' + prefix = "origin_stop" if self._departure: - self.append_keys(self._departure['origin_stop_time'], prefix) + 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._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._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) + self._departure["origin_stop_time"]["Timepoint"], TIMEPOINT_DEFAULT + ) else: self.remove_keys(prefix) - prefix = 'destination_stop' + 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) + 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()) + return { + col: getattr(resource, col) for col in resource.__table__.columns.keys() + } - def append_keys(self, resource: dict, prefix: Optional[str] = None) -> \ - None: + 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': + 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 = f"{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)} + 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 deleted file mode 100644 index cbb508154dde4..0000000000000 --- a/homeassistant/components/gtt/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""The gtt component.""" diff --git a/homeassistant/components/gtt/manifest.json b/homeassistant/components/gtt/manifest.json deleted file mode 100644 index 142261fe15571..0000000000000 --- a/homeassistant/components/gtt/manifest.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "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 deleted file mode 100644 index ecabd5f0a718a..0000000000000 --- a/homeassistant/components/gtt/sensor.py +++ /dev/null @@ -1,116 +0,0 @@ -"""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 index 611e8df006ae1..78c47bf963525 100644 --- a/homeassistant/components/habitica/__init__.py +++ b/homeassistant/components/habitica/__init__.py @@ -2,47 +2,52 @@ from collections import namedtuple import logging +from habitipy.aio import HabitipyAsync import voluptuous as vol from homeassistant.const import ( - CONF_API_KEY, CONF_NAME, CONF_PATH, CONF_SENSORS, CONF_URL) + 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' +CONF_API_USER = "api_user" -DEFAULT_URL = 'https://habitica.com' -DOMAIN = 'habitica' +DEFAULT_URL = "https://habitica.com" +DOMAIN = "habitica" -ST = SensorType = namedtuple('SensorType', [ - 'name', 'icon', 'unit', 'path' -]) +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']) + "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))]), -}) +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 @@ -59,38 +64,35 @@ 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') + 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]) + 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) +CONFIG_SCHEMA = vol.Schema({DOMAIN: INSTANCE_LIST_SCHEMA}, extra=vol.ALLOW_EXTRA) -SERVICE_API_CALL = 'api_call' +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') +ATTR_ARGS = "args" +EVENT_API_CALL_SUCCESS = f"{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, -}) +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] = {} @@ -107,17 +109,22 @@ def __call__(self, **kwargs): username = instance[CONF_API_USER] password = instance[CONF_API_KEY] name = instance.get(CONF_NAME) - config_dict = {'url': url, 'login': username, 'password': password} + 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'] + 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)) + hass, + "sensor", + DOMAIN, + {"name": name, "sensors": instance[CONF_SENSORS]}, + config, + ) + ) async def handle_api_call(call): name = call.data[ATTR_NAME] @@ -131,15 +138,16 @@ async def handle_api_call(call): api = api[element] except KeyError: _LOGGER.error( - "API_CALL: Path %s is invalid for API on '{%s}' element", - path, element) + "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}) + 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) + 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 index b8e622823d31d..50664d862ada6 100644 --- a/homeassistant/components/habitica/manifest.json +++ b/homeassistant/components/habitica/manifest.json @@ -1,10 +1,7 @@ { "domain": "habitica", "name": "Habitica", - "documentation": "https://www.home-assistant.io/components/habitica", - "requirements": [ - "habitipy==0.2.0" - ], - "dependencies": [], + "documentation": "https://www.home-assistant.io/integrations/habitica", + "requirements": ["habitipy==0.2.0"], "codeowners": [] } diff --git a/homeassistant/components/habitica/sensor.py b/homeassistant/components/habitica/sensor.py index fb3a5670c2b60..1fa4ad63b36bf 100644 --- a/homeassistant/components/habitica/sensor.py +++ b/homeassistant/components/habitica/sensor.py @@ -11,8 +11,7 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) -async def async_setup_platform( - hass, config, async_add_devices, discovery_info=None): +async def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the habitica platform.""" if discovery_info is None: return @@ -21,10 +20,9 @@ async def async_setup_platform( 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) + async_add_devices( + [HabitipySensor(name, sensor, sensor_data) for sensor in sensors], True + ) class HabitipyData: @@ -68,8 +66,7 @@ def icon(self): @property def name(self): """Return the name of the sensor.""" - return "{0}_{1}_{2}".format( - habitica.DOMAIN, self._name, self._sensor_name) + return f"{habitica.DOMAIN}_{self._name}_{self._sensor_name}" @property def state(self): diff --git a/homeassistant/components/habitica/services.yaml b/homeassistant/components/habitica/services.yaml index a063b1577f5cc..20794b4c47bfc 100644 --- a/homeassistant/components/habitica/services.yaml +++ b/homeassistant/components/habitica/services.yaml @@ -1,12 +1,10 @@ # Describes the format for Habitica service - ---- api_call: description: Call Habitica api fields: name: description: Habitica's username to call for - example: 'xxxNotAValidNickxxx' + 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"]' diff --git a/homeassistant/components/hangouts/.translations/ca.json b/homeassistant/components/hangouts/.translations/ca.json deleted file mode 100644 index ea43c804f2d1a..0000000000000 --- a/homeassistant/components/hangouts/.translations/ca.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "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 deleted file mode 100644 index badd381f2bee2..0000000000000 --- a/homeassistant/components/hangouts/.translations/cs.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "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 deleted file mode 100644 index 079b57722e213..0000000000000 --- a/homeassistant/components/hangouts/.translations/da.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "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 deleted file mode 100644 index fa96c00f666cd..0000000000000 --- a/homeassistant/components/hangouts/.translations/de.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "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 deleted file mode 100644 index 31e5f9894f96f..0000000000000 --- a/homeassistant/components/hangouts/.translations/en.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "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 deleted file mode 100644 index 951a30f18260a..0000000000000 --- a/homeassistant/components/hangouts/.translations/es-419.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "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 deleted file mode 100644 index dfa463fb148a7..0000000000000 --- a/homeassistant/components/hangouts/.translations/es.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "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 deleted file mode 100644 index 4bd26876ac6aa..0000000000000 --- a/homeassistant/components/hangouts/.translations/et.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "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 deleted file mode 100644 index 00a7d5fd80d2f..0000000000000 --- a/homeassistant/components/hangouts/.translations/fr.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "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": { - "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 deleted file mode 100644 index 28326d97142b9..0000000000000 --- a/homeassistant/components/hangouts/.translations/he.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "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 deleted file mode 100644 index f6e46e259852e..0000000000000 --- a/homeassistant/components/hangouts/.translations/hu.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "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 deleted file mode 100644 index 46a574bdf8a71..0000000000000 --- a/homeassistant/components/hangouts/.translations/id.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "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 deleted file mode 100644 index 76a9adcb40efe..0000000000000 --- a/homeassistant/components/hangouts/.translations/it.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "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 deleted file mode 100644 index e045f3359d154..0000000000000 --- a/homeassistant/components/hangouts/.translations/ko.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "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 deleted file mode 100644 index c22b02fd7ed38..0000000000000 --- a/homeassistant/components/hangouts/.translations/lb.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "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 deleted file mode 100644 index da9bc9edd7b21..0000000000000 --- a/homeassistant/components/hangouts/.translations/nl.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "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 deleted file mode 100644 index c8a5fb4481b8c..0000000000000 --- a/homeassistant/components/hangouts/.translations/nn.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "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 deleted file mode 100644 index ab061ee1a807e..0000000000000 --- a/homeassistant/components/hangouts/.translations/no.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "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 deleted file mode 100644 index 5da1e21979970..0000000000000 --- a/homeassistant/components/hangouts/.translations/pl.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "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 deleted file mode 100644 index 444edc40838d9..0000000000000 --- a/homeassistant/components/hangouts/.translations/pt-BR.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "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 deleted file mode 100644 index a16c60128c1b2..0000000000000 --- a/homeassistant/components/hangouts/.translations/pt.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "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 deleted file mode 100644 index d1c3ed767cef5..0000000000000 --- a/homeassistant/components/hangouts/.translations/ro.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "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 deleted file mode 100644 index 52b8798c0f408..0000000000000 --- a/homeassistant/components/hangouts/.translations/ru.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "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 deleted file mode 100644 index 64ca6da10acd3..0000000000000 --- a/homeassistant/components/hangouts/.translations/sl.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "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 deleted file mode 100644 index 993a191ef89a6..0000000000000 --- a/homeassistant/components/hangouts/.translations/sv.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "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/zh-Hans.json b/homeassistant/components/hangouts/.translations/zh-Hans.json deleted file mode 100644 index bee6bf753dbb5..0000000000000 --- a/homeassistant/components/hangouts/.translations/zh-Hans.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "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 deleted file mode 100644 index c8da604e6f268..0000000000000 --- a/homeassistant/components/hangouts/.translations/zh-Hant.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "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 index 50936ac62a060..d4892c6689098 100644 --- a/homeassistant/components/hangouts/__init__.py +++ b/homeassistant/components/hangouts/__init__.py @@ -1,43 +1,62 @@ """Support for Hangouts.""" import logging +from hangups.auth import GoogleAuthError import voluptuous as vol from homeassistant import config_entries +from homeassistant.components.conversation.util import create_matcher 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) + 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, +) +from .hangouts_bot import HangoutsBot +from .intents import HelpIntent _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) +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] = { @@ -50,14 +69,16 @@ async def async_setup(hass, config): hass.data[DOMAIN] = { CONF_INTENTS: config[CONF_INTENTS], CONF_DEFAULT_CONVERSATIONS: config[CONF_DEFAULT_CONVERSATIONS], - CONF_ERROR_SUPPRESSED_CONVERSATIONS: - config[CONF_ERROR_SUPPRESSED_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']} + 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 = [] @@ -66,65 +87,64 @@ async def async_setup(hass, config): data[CONF_MATCHERS] = matchers - hass.async_create_task(hass.config_entries.flow.async_init( - DOMAIN, context={'source': config_entries.SOURCE_IMPORT} - )) + 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_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) + hass, EVENT_HANGOUTS_CONNECTED, bot.async_handle_update_users_and_conversations + ) dispatcher.async_dispatcher_connect( - hass, - EVENT_HANGOUTS_CONVERSATIONS_CHANGED, - bot.async_resolve_conversations) + hass, EVENT_HANGOUTS_CONVERSATIONS_CHANGED, bot.async_resolve_conversations + ) dispatcher.async_dispatcher_connect( hass, EVENT_HANGOUTS_CONVERSATIONS_RESOLVED, - bot.async_update_conversation_commands) + bot.async_update_conversation_commands, + ) - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, - bot.async_handle_hass_stop) + 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({})) + 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)) diff --git a/homeassistant/components/hangouts/config_flow.py b/homeassistant/components/hangouts/config_flow.py index 743c49abfdf5d..f253df4934194 100644 --- a/homeassistant/components/hangouts/config_flow.py +++ b/homeassistant/components/hangouts/config_flow.py @@ -1,14 +1,25 @@ """Config flow to configure Google Hangouts.""" import functools -import voluptuous as vol +from hangups import get_auth +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 +from .const import ( + CONF_2FA, + CONF_AUTH_CODE, + CONF_REFRESH_TOKEN, + DOMAIN as HANGOUTS_DOMAIN, +) +from .hangups_utils import ( + Google2FAError, + GoogleAuthError, + HangoutsCredentials, + HangoutsRefreshToken, +) @callback @@ -40,27 +51,24 @@ async def async_step_user(self, user_input=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._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) + functools.partial( + get_auth, + self._credentials, + self._refresh_token, + manual_login=manual_login, + ) ) return await self.async_step_final() @@ -68,19 +76,21 @@ async def async_step_user(self, user_input=None): if isinstance(err, Google2FAError): return await self.async_step_2fa() msg = str(err) - if msg == 'Unknown verification code input': - errors['base'] = 'invalid_2fa_method' + if msg == "Unknown verification code input": + errors["base"] = "invalid_2fa_method" else: - errors['base'] = 'invalid_login' + 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 + 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): @@ -88,24 +98,20 @@ async def async_step_2fa(self, user_input=None): 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) + 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' + errors["base"] = "invalid_2fa" return self.async_show_form( step_id=CONF_2FA, - data_schema=vol.Schema({ - vol.Required(CONF_2FA): str, - }), - errors=errors + data_schema=vol.Schema({vol.Required(CONF_2FA): str}), + errors=errors, ) async def async_step_final(self): @@ -114,8 +120,9 @@ async def async_step_final(self): title=self._credentials.get_email(), data={ CONF_EMAIL: self._credentials.get_email(), - CONF_REFRESH_TOKEN: self._refresh_token.get() - }) + CONF_REFRESH_TOKEN: self._refresh_token.get(), + }, + ) async def async_step_import(self, _): """Handle a flow import.""" diff --git a/homeassistant/components/hangouts/const.py b/homeassistant/components/hangouts/const.py index f664e769b9ffa..0508bf4870347 100644 --- a/homeassistant/components/hangouts/const.py +++ b/homeassistant/components/hangouts/const.py @@ -3,77 +3,83 @@ import voluptuous as vol -from homeassistant.components.notify import ( - ATTR_DATA, ATTR_MESSAGE, ATTR_TARGET) +from homeassistant.components.notify import ATTR_DATA, ATTR_MESSAGE, ATTR_TARGET import homeassistant.helpers.config_validation as cv -_LOGGER = logging.getLogger('.') +_LOGGER = logging.getLogger(".") -DOMAIN = 'hangouts' +DOMAIN = "hangouts" -CONF_2FA = '2fa' -CONF_AUTH_CODE = 'authorization_code' -CONF_REFRESH_TOKEN = 'refresh_token' -CONF_BOT = 'bot' +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_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' +CONF_INTENTS = "intents" +CONF_INTENT_TYPE = "intent_type" +CONF_SENTENCES = "sentences" +CONF_MATCHERS = "matchers" -INTENT_HELP = 'HangoutsHelp' +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' +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' +CONF_CONVERSATION_ID = "id" +CONF_CONVERSATION_NAME = "name" -SERVICE_SEND_MESSAGE = 'send_message' -SERVICE_UPDATE = 'update' -SERVICE_RECONNECT = 'reconnect' +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) + 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, + } ) -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] - }), + 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 index fe72c50de778f..56045f0eb1cf0 100644 --- a/homeassistant/components/hangouts/hangouts_bot.py +++ b/homeassistant/components/hangouts/hangouts_bot.py @@ -4,16 +4,31 @@ import logging import aiohttp +import hangups +from hangups import ChatMessageEvent, ChatMessageSegment, Client, get_auth, hangouts_pb2 +from homeassistant.const import HTTP_OK +from homeassistant.core import callback 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) + 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, +) +from .hangups_utils import HangoutsCredentials, HangoutsRefreshToken _LOGGER = logging.getLogger(__name__) @@ -21,8 +36,9 @@ class HangoutsBot: """The Hangouts Bot.""" - def __init__(self, hass, refresh_token, intents, - default_convs, error_suppressed_convs): + def __init__( + self, hass, refresh_token, intents, default_convs, error_suppressed_convs + ): """Set up the client.""" self.hass = hass self._connected = False @@ -41,8 +57,10 @@ def __init__(self, hass, refresh_token, intents, self._error_suppressed_conv_ids = None dispatcher.async_dispatcher_connect( - self.hass, EVENT_HANGOUTS_MESSAGE_RECEIVED, - self._async_handle_conversation_message) + self.hass, + EVENT_HANGOUTS_MESSAGE_RECEIVED, + self._async_handle_conversation_message, + ) def _resolve_conversation_id(self, obj): if CONF_CONVERSATION_ID in obj: @@ -59,6 +77,7 @@ def _resolve_conversation_name(self, name): return conv return None + @callback def async_update_conversation_commands(self): """Refresh the commands for every conversation.""" self._conversation_intents = {} @@ -70,14 +89,15 @@ def async_update_conversation_commands(self): conv_id = self._resolve_conversation_id(conversation) if conv_id is not None: conversations.append(conv_id) - data['_' + CONF_CONVERSATIONS] = conversations + data[f"_{CONF_CONVERSATIONS}"] = conversations elif self._default_conv_ids: - data['_' + CONF_CONVERSATIONS] = self._default_conv_ids + data[f"_{CONF_CONVERSATIONS}"] = self._default_conv_ids else: - data['_' + CONF_CONVERSATIONS] = \ - [conv.id_ for conv in self._conversation_list.get_all()] + data[f"_{CONF_CONVERSATIONS}"] = [ + conv.id_ for conv in self._conversation_list.get_all() + ] - for conv_id in data['_' + CONF_CONVERSATIONS]: + for conv_id in data[f"_{CONF_CONVERSATIONS}"]: if conv_id not in self._conversation_intents: self._conversation_intents[conv_id] = {} @@ -85,12 +105,15 @@ def async_update_conversation_commands(self): try: self._conversation_list.on_event.remove_observer( - self._async_handle_conversation_event) + self._async_handle_conversation_event + ) except ValueError: pass self._conversation_list.on_event.add_observer( - self._async_handle_conversation_event) + self._async_handle_conversation_event + ) + @callback def async_resolve_conversations(self, _): """Resolve the list of default and error suppressed conversations.""" self._default_conv_ids = [] @@ -105,34 +128,34 @@ def async_resolve_conversations(self, _): 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) + 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): + 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) + _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) + intent_result = await self._async_process(intents, message, conv_id) except (intent.UnknownIntent, intent.IntentHandleError) as err: is_error = True intent_result = intent.IntentResponse() @@ -141,18 +164,20 @@ async def _async_handle_conversation_message(self, if intent_result is None: is_error = True intent_result = intent.IntentResponse() - intent_result.async_set_speech( - "Sorry, I didn't understand that") + intent_result.async_set_speech("Sorry, I didn't understand that") - message = intent_result.as_dict().get('speech', {})\ - .get('plain', {}).get('speech') + 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): + is_error and conv_id in self._error_suppressed_conv_ids + ): await self._async_send_message( - [{'text': message, 'parse_str': True}], + [{"text": message, "parse_str": True}], [{CONF_CONVERSATION_ID: conv_id}], - None) + None, + ) async def _async_process(self, intents, text, conv_id): """Detect a matching intent.""" @@ -164,23 +189,23 @@ async def _async_process(self, intents, text, conv_id): continue if intent_type == INTENT_HELP: return await self.hass.helpers.intent.async_handle( - DOMAIN, intent_type, - {'conv_id': {'value': conv_id}}, text) + 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) + 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)) + get_auth, + HangoutsCredentials(None, None, None), + HangoutsRefreshToken(self._refresh_token), + ) self._client = Client(session) self._client.on_connect.add_observer(self._on_connect) @@ -189,18 +214,17 @@ async def async_connect(self): self.hass.loop.create_task(self._client.connect()) def _on_connect(self): - _LOGGER.debug('Connected!') + _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...') + _LOGGER.debug("Connection lost! Reconnect...") await self.async_connect() else: - dispatcher.async_dispatcher_send(self.hass, - EVENT_HANGOUTS_DISCONNECTED) + dispatcher.async_dispatcher_send(self.hass, EVENT_HANGOUTS_DISCONNECTED) async def async_disconnect(self): """Disconnect the client if it is connected.""" @@ -217,43 +241,42 @@ async def _async_send_message(self, message, targets, data): for target in targets: conversation = None if CONF_CONVERSATION_ID in target: - conversation = self._conversation_list.get( - target[CONF_CONVERSATION_ID]) + conversation = self._conversation_list.get(target[CONF_CONVERSATION_ID]) elif CONF_CONVERSATION_NAME in target: conversation = self._resolve_conversation_name( - target[CONF_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'])) + 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'] + 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') + 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: + if response.status != HTTP_OK: _LOGGER.error( - 'Fetch image failed, %s, %s', - response.status, - response + "Fetch image failed, %s, %s", response.status, response ) image_file = None else: @@ -261,21 +284,16 @@ async def _async_send_message(self, message, targets, data): 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) - ) + _LOGGER.error("Failed to fetch image, %s", type(error)) image_file = None - elif data.get('image_file'): - uri = data.get('image_file') + 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: + image_file = open(uri, "rb") + except OSError as error: _LOGGER.error( - 'Image file I/O error(%s): %s', - error.errno, - error.strerror + "Image file I/O error(%s): %s", error.errno, error.strerror ) else: _LOGGER.error('Path "%s" not allowed', uri) @@ -286,30 +304,37 @@ async def _async_send_message(self, message, targets, data): 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)) + ( + 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) + conversations[str(i)] = { + CONF_CONVERSATION_ID: str(conv.id_), + CONF_CONVERSATION_NAME: conv.name, + "users": users_in_conversation, + } + + self.hass.states.async_set( + f"{DOMAIN}.conversations", + 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, {})) + 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.""" diff --git a/homeassistant/components/hangouts/intents.py b/homeassistant/components/hangouts/intents.py index 3887a644700ba..5e4c6ff206bfe 100644 --- a/homeassistant/components/hangouts/intents.py +++ b/homeassistant/components/hangouts/intents.py @@ -9,9 +9,7 @@ class HelpIntent(intent.IntentHandler): """Handle Help intents.""" intent_type = INTENT_HELP - slot_schema = { - 'conv_id': cv.string - } + slot_schema = {"conv_id": cv.string} def __init__(self, hass): """Set up the intent.""" @@ -20,14 +18,14 @@ def __init__(self, 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'] + 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) + for sentence in intent_data["sentences"]: + help_text += f"\n'{sentence}'" response.async_set_speech(help_text) return response diff --git a/homeassistant/components/hangouts/manifest.json b/homeassistant/components/hangouts/manifest.json index 5d9bf3c76121f..6eb62c3f59009 100644 --- a/homeassistant/components/hangouts/manifest.json +++ b/homeassistant/components/hangouts/manifest.json @@ -1,10 +1,8 @@ { "domain": "hangouts", - "name": "Hangouts", - "documentation": "https://www.home-assistant.io/components/hangouts", - "requirements": [ - "hangups==0.4.9" - ], - "dependencies": [], + "name": "Google Hangouts", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/hangouts", + "requirements": ["hangups==0.4.9"], "codeowners": [] } diff --git a/homeassistant/components/hangouts/notify.py b/homeassistant/components/hangouts/notify.py index e88f80afbcde2..01e4208fd4871 100644 --- a/homeassistant/components/hangouts/notify.py +++ b/homeassistant/components/hangouts/notify.py @@ -4,17 +4,25 @@ import voluptuous as vol from homeassistant.components.notify import ( - ATTR_DATA, ATTR_MESSAGE, ATTR_TARGET, PLATFORM_SCHEMA, - BaseNotificationService) + ATTR_DATA, + ATTR_MESSAGE, + ATTR_TARGET, + PLATFORM_SCHEMA, + BaseNotificationService, +) from .const import ( - CONF_DEFAULT_CONVERSATIONS, DOMAIN, SERVICE_SEND_MESSAGE, TARGETS_SCHEMA) + 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] -}) +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + {vol.Required(CONF_DEFAULT_CONVERSATIONS): [TARGETS_SCHEMA]} +) def get_service(hass, config, discovery_info=None): @@ -35,21 +43,19 @@ def send_message(self, message="", **kwargs): if ATTR_TARGET in kwargs: target_conversations = [] for target in kwargs.get(ATTR_TARGET): - target_conversations.append({'id': 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 "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) + DOMAIN, SERVICE_SEND_MESSAGE, service_data=service_data + ) diff --git a/homeassistant/components/hangouts/services.yaml b/homeassistant/components/hangouts/services.yaml index 26a7193493b40..ff11762235d6c 100644 --- a/homeassistant/components/hangouts/services.yaml +++ b/homeassistant/components/hangouts/services.yaml @@ -15,4 +15,4 @@ send_message: example: '{ "image_file": "file" } or { "image_url": "url" }' reconnect: - description: Reconnect the bot. \ No newline at end of file + description: Reconnect the bot. diff --git a/homeassistant/components/hangouts/strings.json b/homeassistant/components/hangouts/strings.json index 8c155784ebe1c..8d5229c99415c 100644 --- a/homeassistant/components/hangouts/strings.json +++ b/homeassistant/components/hangouts/strings.json @@ -1,30 +1,27 @@ { - "config": { - "abort": { - "already_configured": "Google Hangouts is already configured", - "unknown": "Unknown error occurred." + "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)" }, - "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" + "title": "Google Hangouts Login" + }, + "2fa": { + "data": { "2fa": "2FA Pin" }, + "title": "2-Factor-Authentication" + } } + } } diff --git a/homeassistant/components/hangouts/translations/bg.json b/homeassistant/components/hangouts/translations/bg.json new file mode 100644 index 0000000000000..09ffce392a6ac --- /dev/null +++ b/homeassistant/components/hangouts/translations/bg.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "Google Hangouts \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d", + "unknown": "\u0412\u044a\u0437\u043d\u0438\u043a\u043d\u0430 \u043d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430." + }, + "error": { + "invalid_2fa": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u0430 2-\u0444\u0430\u043a\u0442\u043e\u0440\u043d\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u043a\u0430\u0446\u0438\u044f, \u043c\u043e\u043b\u044f, \u043e\u043f\u0438\u0442\u0430\u0439\u0442\u0435 \u043e\u0442\u043d\u043e\u0432\u043e.", + "invalid_2fa_method": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u0435\u043d \u043c\u0435\u0442\u043e\u0434 2FA (\u041f\u0440\u043e\u0432\u0435\u0440\u043a\u0430 \u043d\u0430 \u0442\u0435\u043b\u0435\u0444\u043e\u043d\u0430).", + "invalid_login": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0432\u043b\u0438\u0437\u0430\u043d\u0435, \u043c\u043e\u043b\u044f, \u043e\u043f\u0438\u0442\u0430\u0439\u0442\u0435 \u043e\u0442\u043d\u043e\u0432\u043e." + }, + "step": { + "2fa": { + "data": { + "2fa": "2FA PIN" + }, + "title": "\u0414\u0432\u0443-\u0444\u0430\u043a\u0442\u043e\u0440\u043d\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u043a\u0430\u0446\u0438\u044f" + }, + "user": { + "data": { + "authorization_code": "\u041a\u043e\u0434 \u0437\u0430 \u043e\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u044f (\u043d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c \u0437\u0430 \u0440\u044a\u0447\u043d\u043e \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u043a\u0438\u0440\u0430\u043d\u0435)", + "email": "E-mail \u0430\u0434\u0440\u0435\u0441", + "password": "\u041f\u0430\u0440\u043e\u043b\u0430" + }, + "title": "\u0412\u0445\u043e\u0434 \u0432 Google Hangouts" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hangouts/translations/ca.json b/homeassistant/components/hangouts/translations/ca.json new file mode 100644 index 0000000000000..a186723f345c8 --- /dev/null +++ b/homeassistant/components/hangouts/translations/ca.json @@ -0,0 +1,31 @@ +{ + "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" + }, + "description": "buit", + "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" + }, + "description": "buit", + "title": "Inici de sessi\u00f3 de 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..6b6d430307ec7 --- /dev/null +++ b/homeassistant/components/hangouts/translations/cs.json @@ -0,0 +1,28 @@ +{ + "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" + } + } + } +} \ 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..e490c33805ddb --- /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 tofaktor-godkendelse, pr\u00f8v 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": "Tofaktor-godkendelse" + }, + "user": { + "data": { + "authorization_code": "Godkendelseskode (kr\u00e6vet til manuel godkendelse)", + "email": "Emailadresse", + "password": "Adgangskode" + }, + "title": "Google Hangouts login" + } + } + } +} \ 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..5602713d24552 --- /dev/null +++ b/homeassistant/components/hangouts/translations/de.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "Google Hangouts ist bereits konfiguriert", + "unknown": "Ein unbekannter Fehler ist aufgetreten." + }, + "error": { + "invalid_2fa": "Ung\u00fcltige 2-Faktor Authentifizierung, bitte versuche 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" + } + } + } +} \ 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..bd2170a0f92d2 --- /dev/null +++ b/homeassistant/components/hangouts/translations/en.json @@ -0,0 +1,29 @@ +{ + "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" + } + } + } +} \ 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..9ff97592d9166 --- /dev/null +++ b/homeassistant/components/hangouts/translations/es-419.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "Google Hangouts ya est\u00e1 configurado", + "unknown": "Se produjo un error desconocido." + }, + "error": { + "invalid_2fa": "Autenticaci\u00f3n de 2 factores no v\u00e1lida, intente nuevamente.", + "invalid_login": "Inicio de sesi\u00f3n no v\u00e1lido, por favor, int\u00e9ntalo de nuevo." + }, + "step": { + "2fa": { + "data": { + "2fa": "Pin 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" + } + } + } +} \ 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..692df44c5bcae --- /dev/null +++ b/homeassistant/components/hangouts/translations/es.json @@ -0,0 +1,31 @@ +{ + "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" + } + } + } +} \ 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..b1c29f3577b26 --- /dev/null +++ b/homeassistant/components/hangouts/translations/et.json @@ -0,0 +1,21 @@ +{ + "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" + } + } + } + } +} \ 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..2e8bec54c34a0 --- /dev/null +++ b/homeassistant/components/hangouts/translations/fr.json @@ -0,0 +1,31 @@ +{ + "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" + }, + "description": "Vide", + "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" + }, + "description": "Vide", + "title": "Connexion \u00e0 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..c3863a860f443 --- /dev/null +++ b/homeassistant/components/hangouts/translations/he.json @@ -0,0 +1,28 @@ +{ + "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" + } + } + } +} \ 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..ea7fd49a54822 --- /dev/null +++ b/homeassistant/components/hangouts/translations/hu.json @@ -0,0 +1,30 @@ +{ + "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" + } + } + } +} \ 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..1bcfeaeba50dd --- /dev/null +++ b/homeassistant/components/hangouts/translations/id.json @@ -0,0 +1,30 @@ +{ + "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" + } + } + } +} \ 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..094280da4effc --- /dev/null +++ b/homeassistant/components/hangouts/translations/it.json @@ -0,0 +1,31 @@ +{ + "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" + }, + "description": "Vuoto", + "title": "Autenticazione a due fattori" + }, + "user": { + "data": { + "authorization_code": "Codice di autorizzazione (necessario per l'autenticazione manuale)", + "email": "Indirizzo E-mail", + "password": "Password" + }, + "description": "Vuoto", + "title": "Accesso a 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..4a5af0779a2ba --- /dev/null +++ b/homeassistant/components/hangouts/translations/ko.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "Google \ud589\uc544\uc6c3\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 \ud589\uc544\uc6c3 \ub85c\uadf8\uc778" + } + } + } +} \ 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..fa146adde2349 --- /dev/null +++ b/homeassistant/components/hangouts/translations/lb.json @@ -0,0 +1,31 @@ +{ + "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" + } + } + } +} \ 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..fac77660251a8 --- /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": { + "authorization_code": "Autorisatiecode (vereist voor handmatige authenticatie)", + "email": "E-mailadres", + "password": "Wachtwoord" + }, + "description": "Leeg", + "title": "Google Hangouts inlog" + } + } + } +} \ 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..883a53441af06 --- /dev/null +++ b/homeassistant/components/hangouts/translations/nn.json @@ -0,0 +1,28 @@ +{ + "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" + } + } + } +} \ 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..9402687b8ff11 --- /dev/null +++ b/homeassistant/components/hangouts/translations/no.json @@ -0,0 +1,31 @@ +{ + "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" + } + } + } +} \ 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..20b17ec37b719 --- /dev/null +++ b/homeassistant/components/hangouts/translations/pl.json @@ -0,0 +1,31 @@ +{ + "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" + } + } + } +} \ 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..3f8fd23b07c9c --- /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": { + "authorization_code": "C\u00f3digo de Autoriza\u00e7\u00e3o (requerido para autentica\u00e7\u00e3o manual)", + "email": "Endere\u00e7o de e-mail", + "password": "Senha" + }, + "description": "Vazio", + "title": "Login do 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..d85caeb2bbb61 --- /dev/null +++ b/homeassistant/components/hangouts/translations/pt.json @@ -0,0 +1,30 @@ +{ + "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" + } + } + } +} \ 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..682d561929cb9 --- /dev/null +++ b/homeassistant/components/hangouts/translations/ro.json @@ -0,0 +1,27 @@ +{ + "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" + } + } + } +} \ 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..580c858d15f17 --- /dev/null +++ b/homeassistant/components/hangouts/translations/ru.json @@ -0,0 +1,31 @@ +{ + "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" + }, + "description": "\u043f\u0443\u0441\u0442\u043e", + "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" + }, + "description": "\u043f\u0443\u0441\u0442\u043e", + "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..853dfa1487a4e --- /dev/null +++ b/homeassistant/components/hangouts/translations/sl.json @@ -0,0 +1,31 @@ +{ + "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" + } + } + } +} \ 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..f9e5ec14c54b9 --- /dev/null +++ b/homeassistant/components/hangouts/translations/sv.json @@ -0,0 +1,31 @@ +{ + "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" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hangouts/.translations/th.json b/homeassistant/components/hangouts/translations/th.json similarity index 100% rename from homeassistant/components/hangouts/.translations/th.json rename to homeassistant/components/hangouts/translations/th.json diff --git a/homeassistant/components/hangouts/translations/vi.json b/homeassistant/components/hangouts/translations/vi.json new file mode 100644 index 0000000000000..d794a0b5afafa --- /dev/null +++ b/homeassistant/components/hangouts/translations/vi.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "invalid_2fa_method": "Ph\u01b0\u01a1ng ph\u00e1p 2FA kh\u00f4ng h\u1ee3p l\u1ec7 (X\u00e1c minh tr\u00ean \u0111i\u1ec7n tho\u1ea1i)." + } + } +} \ 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..46d1de99c7344 --- /dev/null +++ b/homeassistant/components/hangouts/translations/zh-Hans.json @@ -0,0 +1,30 @@ +{ + "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" + }, + "description": "\u65e0", + "title": "\u53cc\u91cd\u8ba4\u8bc1" + }, + "user": { + "data": { + "email": "\u7535\u5b50\u90ae\u4ef6\u5730\u5740", + "password": "\u5bc6\u7801" + }, + "description": "\u65e0", + "title": "\u767b\u5f55 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..1619eaddb6381 --- /dev/null +++ b/homeassistant/components/hangouts/translations/zh-Hant.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "Google Hangouts \u5df2\u7d93\u8a2d\u5b9a", + "unknown": "\u767c\u751f\u672a\u77e5\u932f\u8aa4\u3002" + }, + "error": { + "invalid_2fa": "\u96d9\u91cd\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": "\u96d9\u91cd\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" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/harman_kardon_avr/manifest.json b/homeassistant/components/harman_kardon_avr/manifest.json index eecbf0edd63e7..906b8ab266246 100644 --- a/homeassistant/components/harman_kardon_avr/manifest.json +++ b/homeassistant/components/harman_kardon_avr/manifest.json @@ -1,10 +1,7 @@ { "domain": "harman_kardon_avr", - "name": "Harman kardon avr", - "documentation": "https://www.home-assistant.io/components/harman_kardon_avr", - "requirements": [ - "hkavr==0.0.5" - ], - "dependencies": [], + "name": "Harman Kardon AVR", + "documentation": "https://www.home-assistant.io/integrations/harman_kardon_avr", + "requirements": ["hkavr==0.0.5"], "codeowners": [] } diff --git a/homeassistant/components/harman_kardon_avr/media_player.py b/homeassistant/components/harman_kardon_avr/media_player.py index dc200f39b9c8a..30a857f8b9955 100644 --- a/homeassistant/components/harman_kardon_avr/media_player.py +++ b/homeassistant/components/harman_kardon_avr/media_player.py @@ -1,37 +1,44 @@ """Support for interface with an Harman/Kardon or JBL AVR.""" import logging +import hkavr 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 import PLATFORM_SCHEMA, MediaPlayerEntity 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) + SUPPORT_SELECT_SOURCE, + SUPPORT_TURN_OFF, + SUPPORT_TURN_ON, + SUPPORT_VOLUME_MUTE, + SUPPORT_VOLUME_STEP, +) +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, STATE_OFF, STATE_ON +import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -DEFAULT_NAME = 'Harman Kardon AVR' +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 +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, -}) +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] @@ -42,7 +49,7 @@ def setup_platform(hass, config, add_entities, discover_info=None): add_entities([avr_device], True) -class HkAvrDevice(MediaPlayerDevice): +class HkAvrDevice(MediaPlayerEntity): """Representation of a Harman Kardon AVR / JBL AVR TV.""" def __init__(self, avr): diff --git a/homeassistant/components/harmony/__init__.py b/homeassistant/components/harmony/__init__.py index 12ccc78077e93..540e39f8f441b 100644 --- a/homeassistant/components/harmony/__init__.py +++ b/homeassistant/components/harmony/__init__.py @@ -1 +1,104 @@ -"""Support for Harmony devices.""" +"""The Logitech Harmony Hub integration.""" +import asyncio +import logging + +from homeassistant.components.remote import ( + ATTR_ACTIVITY, + ATTR_DELAY_SECS, + DEFAULT_DELAY_SECS, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from .const import DOMAIN, HARMONY_OPTIONS_UPDATE, PLATFORMS +from .remote import HarmonyRemote + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the Logitech Harmony Hub component.""" + hass.data.setdefault(DOMAIN, {}) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up Logitech Harmony Hub from a config entry.""" + # As there currently is no way to import options from yaml + # when setting up a config entry, we fallback to adding + # the options to the config entry and pull them out here if + # they are missing from the options + _async_import_options_from_data_if_missing(hass, entry) + + address = entry.data[CONF_HOST] + name = entry.data[CONF_NAME] + activity = entry.options.get(ATTR_ACTIVITY) + delay_secs = entry.options.get(ATTR_DELAY_SECS, DEFAULT_DELAY_SECS) + + harmony_conf_file = hass.config.path(f"harmony_{entry.unique_id}.conf") + try: + device = HarmonyRemote( + name, entry.unique_id, address, activity, harmony_conf_file, delay_secs + ) + connected_ok = await device.connect() + except (asyncio.TimeoutError, ValueError, AttributeError): + raise ConfigEntryNotReady + + if not connected_ok: + raise ConfigEntryNotReady + + hass.data[DOMAIN][entry.entry_id] = device + + entry.add_update_listener(_update_listener) + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + return True + + +@callback +def _async_import_options_from_data_if_missing(hass: HomeAssistant, entry: ConfigEntry): + options = dict(entry.options) + modified = 0 + for importable_option in [ATTR_ACTIVITY, ATTR_DELAY_SECS]: + if importable_option not in entry.options and importable_option in entry.data: + options[importable_option] = entry.data[importable_option] + modified = 1 + + if modified: + hass.config_entries.async_update_entry(entry, options=options) + + +async def _update_listener(hass: HomeAssistant, entry: ConfigEntry): + """Handle options update.""" + async_dispatcher_send( + hass, f"{HARMONY_OPTIONS_UPDATE}-{entry.unique_id}", entry.options + ) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ] + ) + ) + + # Shutdown a harmony remote for removal + device = hass.data[DOMAIN][entry.entry_id] + await device.shutdown() + + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/harmony/config_flow.py b/homeassistant/components/harmony/config_flow.py new file mode 100644 index 0000000000000..8d43b2d69ca9e --- /dev/null +++ b/homeassistant/components/harmony/config_flow.py @@ -0,0 +1,199 @@ +"""Config flow for Logitech Harmony Hub integration.""" +import logging +from urllib.parse import urlparse + +import voluptuous as vol + +from homeassistant import config_entries, exceptions +from homeassistant.components import ssdp +from homeassistant.components.remote import ( + ATTR_ACTIVITY, + ATTR_DELAY_SECS, + DEFAULT_DELAY_SECS, +) +from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.core import callback + +from .const import DOMAIN, UNIQUE_ID +from .util import ( + find_best_name_for_remote, + find_unique_id_for_remote, + get_harmony_client_if_available, +) + +_LOGGER = logging.getLogger(__name__) + +DATA_SCHEMA = vol.Schema( + {vol.Required(CONF_HOST): str, vol.Required(CONF_NAME): str}, extra=vol.ALLOW_EXTRA +) + + +async def validate_input(data): + """Validate the user input allows us to connect. + + Data has the keys from DATA_SCHEMA with values provided by the user. + """ + harmony = await get_harmony_client_if_available(data[CONF_HOST]) + if not harmony: + raise CannotConnect + + return { + CONF_NAME: find_best_name_for_remote(data, harmony), + CONF_HOST: data[CONF_HOST], + UNIQUE_ID: find_unique_id_for_remote(harmony), + } + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Logitech Harmony Hub.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH + + def __init__(self): + """Initialize the Harmony config flow.""" + self.harmony_config = {} + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + if user_input is not None: + + try: + validated = await validate_input(user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + if "base" not in errors: + await self.async_set_unique_id(validated[UNIQUE_ID]) + self._abort_if_unique_id_configured() + return await self._async_create_entry_from_valid_input( + validated, user_input + ) + + # Return form + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + async def async_step_ssdp(self, discovery_info): + """Handle a discovered Harmony device.""" + _LOGGER.debug("SSDP discovery_info: %s", discovery_info) + + parsed_url = urlparse(discovery_info[ssdp.ATTR_SSDP_LOCATION]) + friendly_name = discovery_info[ssdp.ATTR_UPNP_FRIENDLY_NAME] + + # pylint: disable=no-member + self.context["title_placeholders"] = {"name": friendly_name} + + self.harmony_config = { + CONF_HOST: parsed_url.hostname, + CONF_NAME: friendly_name, + } + + harmony = await get_harmony_client_if_available(parsed_url.hostname) + + if harmony: + unique_id = find_unique_id_for_remote(harmony) + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured( + updates={CONF_HOST: self.harmony_config[CONF_HOST]} + ) + self.harmony_config[UNIQUE_ID] = unique_id + + return await self.async_step_link() + + async def async_step_link(self, user_input=None): + """Attempt to link with the Harmony.""" + errors = {} + + if user_input is not None: + # Everything was validated in async_step_ssdp + # all we do now is create. + return await self._async_create_entry_from_valid_input( + self.harmony_config, {} + ) + + return self.async_show_form( + step_id="link", + errors=errors, + description_placeholders={ + CONF_HOST: self.harmony_config[CONF_NAME], + CONF_NAME: self.harmony_config[CONF_HOST], + }, + ) + + async def async_step_import(self, validated_input): + """Handle import.""" + await self.async_set_unique_id( + validated_input[UNIQUE_ID], raise_on_progress=False + ) + self._abort_if_unique_id_configured() + + # Everything was validated in remote async_setup_platform + # all we do now is create. + return await self._async_create_entry_from_valid_input( + validated_input, validated_input + ) + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get the options flow for this handler.""" + return OptionsFlowHandler(config_entry) + + async def _async_create_entry_from_valid_input(self, validated, user_input): + """Single path to create the config entry from validated input.""" + + data = {CONF_NAME: validated[CONF_NAME], CONF_HOST: validated[CONF_HOST]} + # Options from yaml are preserved, we will pull them out when + # we setup the config entry + data.update(_options_from_user_input(user_input)) + + return self.async_create_entry(title=validated[CONF_NAME], data=data) + + +def _options_from_user_input(user_input): + options = {} + if ATTR_ACTIVITY in user_input: + options[ATTR_ACTIVITY] = user_input[ATTR_ACTIVITY] + if ATTR_DELAY_SECS in user_input: + options[ATTR_DELAY_SECS] = user_input[ATTR_DELAY_SECS] + return options + + +class OptionsFlowHandler(config_entries.OptionsFlow): + """Handle a option flow for Harmony.""" + + def __init__(self, config_entry: config_entries.ConfigEntry): + """Initialize options flow.""" + self.config_entry = config_entry + + async def async_step_init(self, user_input=None): + """Handle options flow.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + remote = self.hass.data[DOMAIN][self.config_entry.entry_id] + + data_schema = vol.Schema( + { + vol.Optional( + ATTR_DELAY_SECS, + default=self.config_entry.options.get( + ATTR_DELAY_SECS, DEFAULT_DELAY_SECS + ), + ): vol.Coerce(float), + vol.Optional( + ATTR_ACTIVITY, default=self.config_entry.options.get(ATTR_ACTIVITY), + ): vol.In(remote.activity_names), + } + ) + return self.async_show_form(step_id="init", data_schema=data_schema) + + +class CannotConnect(exceptions.HomeAssistantError): + """Error to indicate we cannot connect.""" diff --git a/homeassistant/components/harmony/const.py b/homeassistant/components/harmony/const.py new file mode 100644 index 0000000000000..4cd5dce0af5b5 --- /dev/null +++ b/homeassistant/components/harmony/const.py @@ -0,0 +1,8 @@ +"""Constants for the Harmony component.""" +DOMAIN = "harmony" +SERVICE_SYNC = "sync" +SERVICE_CHANGE_CHANNEL = "change_channel" +PLATFORMS = ["remote"] +UNIQUE_ID = "unique_id" +ACTIVITY_POWER_OFF = "PowerOff" +HARMONY_OPTIONS_UPDATE = "harmony_options_update" diff --git a/homeassistant/components/harmony/manifest.json b/homeassistant/components/harmony/manifest.json index b2f9e69e01462..154fd211aa810 100644 --- a/homeassistant/components/harmony/manifest.json +++ b/homeassistant/components/harmony/manifest.json @@ -1,12 +1,14 @@ { "domain": "harmony", - "name": "Harmony", - "documentation": "https://www.home-assistant.io/components/harmony", - "requirements": [ - "aioharmony==0.1.11" + "name": "Logitech Harmony Hub", + "documentation": "https://www.home-assistant.io/integrations/harmony", + "requirements": ["aioharmony==0.1.13"], + "codeowners": ["@ehendrix23", "@bramkragten", "@bdraco"], + "ssdp": [ + { + "manufacturer": "Logitech", + "deviceType": "urn:myharmony-com:device:harmony:1" + } ], - "dependencies": [], - "codeowners": [ - "@ehendrix23" - ] + "config_flow": true } diff --git a/homeassistant/components/harmony/remote.py b/homeassistant/components/harmony/remote.py index c4aebb1bdcbb4..25b68b42e722c 100644 --- a/homeassistant/components/harmony/remote.py +++ b/homeassistant/components/harmony/remote.py @@ -3,191 +3,216 @@ import json import logging +import aioharmony.exceptions as aioexc +from aioharmony.harmonyapi import ( + ClientCallbackType, + HarmonyAPI as HarmonyClient, + SendCommandDevice, +) 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 + ATTR_ACTIVITY, + ATTR_DELAY_SECS, + ATTR_DEVICE, + ATTR_HOLD_SECS, + ATTR_NUM_REPEATS, + DEFAULT_DELAY_SECS, + PLATFORM_SCHEMA, ) -import homeassistant.helpers.config_validation as cv +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST, CONF_NAME +from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -from homeassistant.util import slugify +from homeassistant.helpers import entity_platform +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from .const import ( + ACTIVITY_POWER_OFF, + DOMAIN, + HARMONY_OPTIONS_UPDATE, + SERVICE_CHANGE_CHANNEL, + SERVICE_SYNC, + UNIQUE_ID, +) +from .util import ( + find_best_name_for_remote, + find_matching_config_entries_for_host, + find_unique_id_for_remote, + get_harmony_client_if_available, +) _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' +# We want to fire remote commands right away +PARALLEL_UPDATES = 0 + +ATTR_CHANNEL = "channel" +ATTR_CURRENT_ACTIVITY = "current_activity" + +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.Required(CONF_HOST): cv.string, + # The client ignores port so lets not confuse the user by pretenting we do anything with this + }, + extra=vol.ALLOW_EXTRA, +) -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_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, -}) +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): +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) + # Now handled by ssdp in the config flow 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 - + if find_matching_config_entries_for_host(hass, config[CONF_HOST]): + return -def register_services(hass): - """Register all services for harmony devices.""" - hass.services.async_register( - DOMAIN, SERVICE_SYNC, _sync_service, - schema=HARMONY_SYNC_SCHEMA) + # We do the validation to verify we can connect + # so we can raise PlatformNotReady to force + # a retry so we can avoid a scenario where the config + # entry cannot be created via import because hub + # is not yet ready. + harmony = await get_harmony_client_if_available(config[CONF_HOST]) + if not harmony: + raise PlatformNotReady - hass.services.async_register( - DOMAIN, SERVICE_CHANGE_CHANNEL, _change_channel_service, - schema=HARMONY_CHANGE_CHANNEL_SCHEMA) + validated_config = config.copy() + validated_config[UNIQUE_ID] = find_unique_id_for_remote(harmony) + validated_config[CONF_NAME] = find_best_name_for_remote(config, harmony) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=validated_config + ) + ) -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 +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities +): + """Set up the Harmony config entry.""" - for device in _devices: - await service_func(device, *service_func_args) + device = hass.data[DOMAIN][entry.entry_id] + _LOGGER.debug("Harmony Remote: %s", device) -async def _sync_service(service): - await _apply_service(service, HarmonyRemote.sync) + async_add_entities([device]) + platform = entity_platform.current_platform.get() -async def _change_channel_service(service): - channel = service.data.get(ATTR_CHANNEL) - await _apply_service(service, HarmonyRemote.change_channel, channel) + platform.async_register_entity_service( + SERVICE_SYNC, HARMONY_SYNC_SCHEMA, "sync", + ) + platform.async_register_entity_service( + SERVICE_CHANGE_CHANNEL, HARMONY_CHANGE_CHANNEL_SCHEMA, "change_channel" + ) -class HarmonyRemote(remote.RemoteDevice): +class HarmonyRemote(remote.RemoteEntity): """Remote representation used to control a Harmony device.""" - def __init__(self, name, host, port, activity, out_path, delay_secs): + def __init__(self, name, unique_id, host, 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.default_activity = activity self._client = HarmonyClient(ip_address=host) self._config_path = out_path - self._delay_secs = delay_secs + self.delay_secs = delay_secs self._available = False + self._unique_id = unique_id + + @property + def activity_names(self): + """Names of all the remotes activities.""" + activities = [activity["label"] for activity in self._client.config["activity"]] + + # Remove both ways of representing PowerOff + if None in activities: + activities.remove(None) + if ACTIVITY_POWER_OFF in activities: + activities.remove(ACTIVITY_POWER_OFF) + + return activities + + async def _async_update_options(self, data): + """Change options when the options flow does.""" + if ATTR_DELAY_SECS in data: + self.delay_secs = data[ATTR_DELAY_SECS] + + if ATTR_ACTIVITY in data: + self.default_activity = data[ATTR_ACTIVITY] 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 + disconnect=self.got_disconnected, + ) + + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{HARMONY_OPTIONS_UPDATE}-{self.unique_id}", + self._async_update_options, + ) ) # Store Harmony HUB config, this will also update our current # activity await self.new_config() - import aioharmony.exceptions as aioexc + async def shutdown(self): + """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) - 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) + @property + def device_info(self): + """Return device info.""" + model = "Harmony Hub" + if "ethernetStatus" in self._client.hub_config.info: + model = "Harmony Hub Pro 2400" + return { + "identifiers": {(DOMAIN, self.unique_id)}, + "manufacturer": "Logitech", + "sw_version": self._client.hub_config.info.get( + "hubSwVersion", self._client.fw_version + ), + "name": self.name, + "model": model, + } - self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, shutdown) + @property + def unique_id(self): + """Return the unique id.""" + return self._unique_id @property def name(self): @@ -207,7 +232,7 @@ def device_state_attributes(self): @property def is_on(self): """Return False if PowerOff is the current activity, otherwise True.""" - return self._current_activity not in [None, 'PowerOff'] + return self._current_activity not in [None, "PowerOff"] @property def available(self): @@ -216,8 +241,6 @@ def available(self): 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(): @@ -227,18 +250,16 @@ async def connect(self): 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) + _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() + self.async_write_ha_state() async def new_config(self, _=None): """Call for updating the current activity.""" @@ -263,58 +284,46 @@ async def got_disconnected(self, _=None): if not self._available: # Still disconnected. Let the state engine know. - self.async_schedule_update_ha_state() + self.async_write_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) + activity = kwargs.get(ATTR_ACTIVITY, self.default_activity) if activity: activity_id = None - if activity.isdigit() or activity == '-1': + 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()) + activity_id = self._client.get_activity_id(str(activity)) if activity_id is None: - _LOGGER.error("%s: Activity %s is invalid", - self.name, activity) + _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) + _LOGGER.error("%s: Starting activity %s timed-out", self.name, activity) else: - _LOGGER.error("%s: No activity specified with turn_on service", - self.name) + _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: @@ -323,14 +332,14 @@ async def async_send_command(self, command, **kwargs): device_id = None if device.isdigit(): - _LOGGER.debug("%s: Device %s is numeric", - self.name, device) + _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) + _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: @@ -338,20 +347,22 @@ async def async_send_command(self, command, **kwargs): return num_repeats = kwargs[ATTR_NUM_REPEATS] - delay_secs = kwargs.get(ATTR_DELAY_SECS, self._delay_secs) + 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) + _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 + device=device_id, command=single_command, delay=hold_secs ) snd_cmnd_list.append(send_command) if delay_secs > 0: @@ -365,54 +376,48 @@ async def async_send_command(self, command, **kwargs): 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 - ) + _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) + _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) + _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) + _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) + _LOGGER.debug( + "%s: Writing hub configuration to file: %s", self.name, self._config_path + ) if self._client.config is None: - _LOGGER.warning("%s: No configuration received from hub", - self.name) + _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) + 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 OSError 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 index e69de29bb2d1d..f20f0494a5f71 100644 --- a/homeassistant/components/harmony/services.yaml +++ b/homeassistant/components/harmony/services.yaml @@ -0,0 +1,16 @@ +sync: + description: Syncs the remote's configuration. + fields: + entity_id: + description: Name(s) of entities to sync. + example: "remote.family_room" + +change_channel: + description: Sends change channel command to the Harmony HUB + fields: + entity_id: + description: Name(s) of Harmony remote entities to send change channel command to + example: "remote.family_room" + channel: + description: Channel number to change to + example: "200" diff --git a/homeassistant/components/harmony/strings.json b/homeassistant/components/harmony/strings.json new file mode 100644 index 0000000000000..e093d02051d53 --- /dev/null +++ b/homeassistant/components/harmony/strings.json @@ -0,0 +1,31 @@ +{ + "config": { + "flow_title": "Logitech Harmony Hub {name}", + "step": { + "user": { + "title": "Setup Logitech Harmony Hub", + "data": { "host": "Hostname or IP Address", "name": "Hub Name" } + }, + "link": { + "title": "Setup Logitech Harmony Hub", + "description": "Do you want to setup {name} ({host})?" + } + }, + "error": { + "cannot_connect": "Failed to connect, please try again", + "unknown": "Unexpected error" + }, + "abort": { "already_configured": "Device is already configured" } + }, + "options": { + "step": { + "init": { + "description": "Adjust Harmony Hub Options", + "data": { + "activity": "The default activity to execute when none is specified.", + "delay_secs": "The delay between sending commands." + } + } + } + } +} diff --git a/homeassistant/components/harmony/translations/ca.json b/homeassistant/components/harmony/translations/ca.json new file mode 100644 index 0000000000000..90d8f0643015d --- /dev/null +++ b/homeassistant/components/harmony/translations/ca.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat" + }, + "error": { + "cannot_connect": "No s'ha pogut connectar, torna-ho a provar", + "unknown": "Error inesperat" + }, + "flow_title": "Logitech Harmony Hub {name}", + "step": { + "link": { + "description": "Vols configurar {name} ({host})?", + "title": "Configuraci\u00f3 de Logitech Harmony Hub" + }, + "user": { + "data": { + "host": "Nom de l'amfitri\u00f3 o adre\u00e7a IP", + "name": "Nom del Hub" + }, + "title": "Configuraci\u00f3 de Logitech Harmony Hub" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "activity": "Activitat predeterminada a executar quan no se n'especifica cap.", + "delay_secs": "Retard entre l'enviament d'ordres." + }, + "description": "Ajusta les opcions de Harmony Hub" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/harmony/translations/de.json b/homeassistant/components/harmony/translations/de.json new file mode 100644 index 0000000000000..ae640f1287038 --- /dev/null +++ b/homeassistant/components/harmony/translations/de.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert" + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen, versuchen Sie es erneut", + "unknown": "Unerwarteter Fehler" + }, + "flow_title": "Logitech Harmony Hub {name}", + "step": { + "link": { + "description": "M\u00f6chten Sie {name} ({host}) einrichten?", + "title": "Richten Sie den Logitech Harmony Hub ein" + }, + "user": { + "data": { + "host": "Hostname oder IP-Adresse", + "name": "Hub-Name" + }, + "title": "Richten Sie den Logitech Harmony Hub ein" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "activity": "Die Standardaktivit\u00e4t, die ausgef\u00fchrt werden soll, wenn keine angegeben ist.", + "delay_secs": "Die Verz\u00f6gerung zwischen dem Senden von Befehlen." + }, + "description": "Passen Sie die Harmony Hub-Optionen an" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/harmony/translations/en.json b/homeassistant/components/harmony/translations/en.json new file mode 100644 index 0000000000000..17964db6824f1 --- /dev/null +++ b/homeassistant/components/harmony/translations/en.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect, please try again", + "unknown": "Unexpected error" + }, + "flow_title": "Logitech Harmony Hub {name}", + "step": { + "link": { + "description": "Do you want to setup {name} ({host})?", + "title": "Setup Logitech Harmony Hub" + }, + "user": { + "data": { + "host": "Hostname or IP Address", + "name": "Hub Name" + }, + "title": "Setup Logitech Harmony Hub" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "activity": "The default activity to execute when none is specified.", + "delay_secs": "The delay between sending commands." + }, + "description": "Adjust Harmony Hub Options" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/harmony/translations/es.json b/homeassistant/components/harmony/translations/es.json new file mode 100644 index 0000000000000..a5d96ec7ef347 --- /dev/null +++ b/homeassistant/components/harmony/translations/es.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositivo ya est\u00e1 configurado" + }, + "error": { + "cannot_connect": "No se ha podido conectar, por favor, int\u00e9ntalo de nuevo", + "unknown": "Error inesperado" + }, + "flow_title": "Logitech Harmony Hub {name}", + "step": { + "link": { + "description": "\u00bfQuieres configurar {name} ({host})?", + "title": "Configurar Logitech Harmony Hub" + }, + "user": { + "data": { + "host": "Nombre del host o direcci\u00f3n IP", + "name": "Nombre del concentrador" + }, + "title": "Configurar Logitech Harmony Hub" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "activity": "La actividad por defecto a ejecutar cuando no se especifica ninguna.", + "delay_secs": "El retraso entre el env\u00edo de comandos." + }, + "description": "Ajustar las opciones de Harmony Hub" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/harmony/translations/fr.json b/homeassistant/components/harmony/translations/fr.json new file mode 100644 index 0000000000000..4343ec3139dd1 --- /dev/null +++ b/homeassistant/components/harmony/translations/fr.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" + }, + "error": { + "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer", + "unknown": "Erreur inattendue" + }, + "flow_title": "Logitech Harmony Hub {name}", + "step": { + "link": { + "description": "Voulez-vous configurer {name} ( {host} ) ?", + "title": "Configuration de Logitech Harmony Hub" + }, + "user": { + "data": { + "host": "Nom d'h\u00f4te ou adresse IP", + "name": "Nom du Hub" + }, + "title": "Configuration de Logitech Harmony Hub" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "activity": "Activit\u00e9 par d\u00e9faut \u00e0 ex\u00e9cuter lorsqu'aucune n'est sp\u00e9cifi\u00e9e.", + "delay_secs": "Le d\u00e9lai entre l'envoi des commandes." + }, + "description": "Ajuster les options du hub Harmony" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/harmony/translations/it.json b/homeassistant/components/harmony/translations/it.json new file mode 100644 index 0000000000000..8095fa0515614 --- /dev/null +++ b/homeassistant/components/harmony/translations/it.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato" + }, + "error": { + "cannot_connect": "Impossibile connettersi, si prega di riprovare", + "unknown": "Errore imprevisto" + }, + "flow_title": "Logitech Harmony Hub {name}", + "step": { + "link": { + "description": "Vuoi impostare {name} ({host})?", + "title": "Impostazione di Logitech Harmony Hub" + }, + "user": { + "data": { + "host": "Nome dell'host o indirizzo IP", + "name": "Nome Hub" + }, + "title": "Configurare Logitech Harmony Hub" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "activity": "L'attivit\u00e0 predefinita da eseguire quando nessuna \u00e8 specificata.", + "delay_secs": "Il ritardo tra l'invio dei comandi." + }, + "description": "Regolare le opzioni di Harmony Hub" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/harmony/translations/ko.json b/homeassistant/components/harmony/translations/ko.json new file mode 100644 index 0000000000000..b7d7b4e5659eb --- /dev/null +++ b/homeassistant/components/harmony/translations/ko.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, + "error": { + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.", + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" + }, + "flow_title": "Logitech Harmony Hub {name}", + "step": { + "link": { + "description": "{name} ({host}) \uc744(\ub97c) \uc124\uc815\ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?", + "title": "Logitech Harmony Hub \uc124\uc815\ud558\uae30" + }, + "user": { + "data": { + "host": "\ud638\uc2a4\ud2b8 \uc774\ub984 \ub610\ub294 IP \uc8fc\uc18c", + "name": "Hub \uc774\ub984" + }, + "title": "Logitech Harmony Hub \uc124\uc815\ud558\uae30" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "activity": "\uc9c0\uc815\ub418\uc9c0 \uc54a\uc740 \uacbd\uc6b0 \uc2e4\ud589\ud560 \uae30\ubcf8 \uc561\uc158.", + "delay_secs": "\uba85\ub839 \uc804\uc1a1 \uc0ac\uc774\uc758 \uc9c0\uc5f0 \uc2dc\uac04." + }, + "description": "Harmony Hub \uc635\uc158 \uc870\uc815" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/harmony/translations/lb.json b/homeassistant/components/harmony/translations/lb.json new file mode 100644 index 0000000000000..abcc0948835c5 --- /dev/null +++ b/homeassistant/components/harmony/translations/lb.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "Apparat ass scho konfigur\u00e9iert" + }, + "error": { + "cannot_connect": "Feeler beim verbannen, prob\u00e9ier w.e.g. nach emol.", + "unknown": "Onerwaarte Feeler" + }, + "flow_title": "Logitech Harmony Hub {name}", + "step": { + "link": { + "description": "Soll {name} ({host}) konfigur\u00e9iert ginn?", + "title": "Logitech Harmony Hub ariichten" + }, + "user": { + "data": { + "host": "Host Numm oder IP Adresse", + "name": "Numm vum Hub" + }, + "title": "Logitech Harmony Hub ariichten" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "activity": "Standard Aktivit\u00e9it d\u00e9i ausgef\u00e9iert g\u00ebtt wann keng uginn ass.", + "delay_secs": "Delai zw\u00ebschen dem versch\u00e9cken vun Kommandoen" + }, + "description": "Harmony Hub Optioune ajust\u00e9ieren" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/harmony/translations/nl.json b/homeassistant/components/harmony/translations/nl.json new file mode 100644 index 0000000000000..63d8026d9c2d0 --- /dev/null +++ b/homeassistant/components/harmony/translations/nl.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd" + }, + "error": { + "cannot_connect": "Verbinding mislukt, probeer het opnieuw", + "unknown": "Onverwachte fout" + }, + "flow_title": "Logitech Harmony Hub {name}", + "step": { + "link": { + "description": "Wil je {name} ({host}) instellen?", + "title": "Logitech Harmony Hub instellen" + }, + "user": { + "data": { + "host": "Hostnaam of IP-adres", + "name": "Naam van hub" + }, + "title": "Logitech Harmony Hub instellen" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "activity": "De standaardactiviteit die moet worden uitgevoerd wanneer er geen is opgegeven.", + "delay_secs": "De vertraging tussen het verzenden van opdrachten." + }, + "description": "Pas de Harmony Hub-opties aan" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/harmony/translations/no.json b/homeassistant/components/harmony/translations/no.json new file mode 100644 index 0000000000000..871b3161fcf83 --- /dev/null +++ b/homeassistant/components/harmony/translations/no.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert" + }, + "error": { + "cannot_connect": "Klarte ikke \u00e5 koble til, vennligst pr\u00f8v igjen", + "unknown": "Uventet feil" + }, + "flow_title": "", + "step": { + "link": { + "description": "Vil du konfigurere {name} ({host})?", + "title": "Oppsett Logitech Harmony Hub" + }, + "user": { + "data": { + "host": "Vertsnavn eller IP-adresse", + "name": "Navn p\u00e5 hub" + }, + "title": "Oppsett Logitech Harmony Hub" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "activity": "Standardaktiviteten som skal utf\u00f8res n\u00e5r ingen er angitt.", + "delay_secs": "Forsinkelsen mellom sending av kommandoer." + }, + "description": "Juster alternativene for harmonihub" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/harmony/translations/pl.json b/homeassistant/components/harmony/translations/pl.json new file mode 100644 index 0000000000000..533c14097a542 --- /dev/null +++ b/homeassistant/components/harmony/translations/pl.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane" + }, + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia, spr\u00f3buj ponownie.", + "unknown": "Niespodziewany b\u0142\u0105d." + }, + "flow_title": "Logitech Harmony Hub {name}", + "step": { + "link": { + "description": "Czy chcesz skonfigurowa\u0107 {name} ({host})?", + "title": "Konfiguracja Logitech Harmony Hub" + }, + "user": { + "data": { + "host": "Nazwa hosta lub adres IP", + "name": "Nazwa huba" + }, + "title": "Konfiguracja Logitech Harmony Hub" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "activity": "Domy\u015blna aktywno\u015b\u0107 do wykonania, gdy \u017cadnej nie okre\u015blono.", + "delay_secs": "Op\u00f3\u017anienie mi\u0119dzy wysy\u0142aniem polece\u0144." + }, + "description": "Dostosuj opcje huba Harmony" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/harmony/translations/ru.json b/homeassistant/components/harmony/translations/ru.json new file mode 100644 index 0000000000000..85b61f923e1d7 --- /dev/null +++ b/homeassistant/components/harmony/translations/ru.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u044d\u0442\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430." + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f, \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0435\u0449\u0435 \u0440\u0430\u0437.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "flow_title": "Logitech Harmony Hub {name}", + "step": { + "link": { + "description": "\u0425\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c {name} ({host})?", + "title": "Logitech Harmony Hub" + }, + "user": { + "data": { + "host": "\u0414\u043e\u043c\u0435\u043d\u043d\u043e\u0435 \u0438\u043c\u044f \u0438\u043b\u0438 IP-\u0430\u0434\u0440\u0435\u0441", + "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435" + }, + "title": "Logitech Harmony Hub" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "activity": "\u0410\u043a\u0442\u0438\u0432\u043d\u043e\u0441\u0442\u044c \u043f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e, \u043a\u043e\u0433\u0434\u0430 \u043d\u0438 \u043e\u0434\u043d\u0430 \u0438\u0437 \u043d\u0438\u0445 \u043d\u0435 \u0443\u043a\u0430\u0437\u0430\u043d\u0430.", + "delay_secs": "\u0417\u0430\u0434\u0435\u0440\u0436\u043a\u0430 \u043c\u0435\u0436\u0434\u0443 \u043e\u0442\u043f\u0440\u0430\u0432\u043a\u043e\u0439 \u043a\u043e\u043c\u0430\u043d\u0434." + }, + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u043e\u0432 Harmony Hub" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/harmony/translations/sl.json b/homeassistant/components/harmony/translations/sl.json new file mode 100644 index 0000000000000..9c99ba98bb25a --- /dev/null +++ b/homeassistant/components/harmony/translations/sl.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "Naprava je \u017ee konfigurirana" + }, + "error": { + "cannot_connect": "Povezava ni uspela, poskusite znova", + "unknown": "Nepri\u010dakovana napaka" + }, + "flow_title": "Logitech Harmony Hub {name}", + "step": { + "link": { + "description": "Ali \u017eelite nastaviti {name} ({host})?", + "title": "Nastavite Logitech Harmony Hub" + }, + "user": { + "data": { + "host": "Ime gostitelja ali naslov IP", + "name": "Ime vozli\u0161\u010da" + }, + "title": "Nastavite Logitech Harmony Hub" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "activity": "Privzeta dejavnost za izvr\u0161itev, ko ni dolo\u010dena nobena.", + "delay_secs": "Zakasnitev med po\u0161iljanjem ukazov." + }, + "description": "Prilagodite mo\u017enosti vozli\u0161\u010da Harmony" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/harmony/translations/sv.json b/homeassistant/components/harmony/translations/sv.json new file mode 100644 index 0000000000000..6e9c861763ba3 --- /dev/null +++ b/homeassistant/components/harmony/translations/sv.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten \u00e4r redan konfigurerad" + }, + "error": { + "cannot_connect": "Det gick inte att ansluta, f\u00f6rs\u00f6k igen", + "unknown": "Ov\u00e4ntat fel" + }, + "step": { + "link": { + "description": "Do vill du konfigurera {name} ({host})?" + }, + "user": { + "data": { + "host": "V\u00e4rdnamn eller IP-adress" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/harmony/translations/zh-Hant.json b/homeassistant/components/harmony/translations/zh-Hant.json new file mode 100644 index 0000000000000..5b4171de5cca6 --- /dev/null +++ b/homeassistant/components/harmony/translations/zh-Hant.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "\u8a2d\u5099\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557\uff0c\u8acb\u518d\u8a66\u4e00\u6b21", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "flow_title": "\u7f85\u6280 Harmony Hub {name}", + "step": { + "link": { + "description": "\u662f\u5426\u8981\u8a2d\u5b9a {name} ({host})\uff1f", + "title": "\u8a2d\u5b9a\u7f85\u6280 Harmony Hub" + }, + "user": { + "data": { + "host": "\u4e3b\u6a5f\u540d\u6216 IP \u4f4d\u5740", + "name": "Hub \u540d\u7a31" + }, + "title": "\u8a2d\u5b9a\u7f85\u6280 Harmony Hub" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "activity": "\u7576\u672a\u6307\u5b9a\u6642\u9810\u8a2d\u57f7\u884c\u6d3b\u52d5\u3002", + "delay_secs": "\u50b3\u9001\u547d\u4ee4\u9593\u9694\u79d2\u6578\u3002" + }, + "description": "\u8abf\u6574 Harmony Hub \u9078\u9805" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/harmony/util.py b/homeassistant/components/harmony/util.py new file mode 100644 index 0000000000000..412aa2c694071 --- /dev/null +++ b/homeassistant/components/harmony/util.py @@ -0,0 +1,52 @@ +"""The Logitech Harmony Hub integration utils.""" +import aioharmony.exceptions as harmony_exceptions +from aioharmony.harmonyapi import HarmonyAPI + +from homeassistant.const import CONF_HOST, CONF_NAME + +from .const import DOMAIN + + +def find_unique_id_for_remote(harmony: HarmonyAPI): + """Find the unique id for both websocket and xmpp clients.""" + websocket_unique_id = harmony.hub_config.info.get("activeRemoteId") + if websocket_unique_id is not None: + return str(websocket_unique_id) + + # fallback to the xmpp unique id if websocket is not available + return harmony.config["global"]["timeStampHash"].split(";")[-1] + + +def find_best_name_for_remote(data: dict, harmony: HarmonyAPI): + """Find the best name from config or fallback to the remote.""" + # As a last resort we get the name from the harmony client + # in the event a name was not provided. harmony.name is + # usually the ip address but it can be an empty string. + if CONF_NAME not in data or data[CONF_NAME] is None or data[CONF_NAME] == "": + return harmony.name + + return data[CONF_NAME] + + +async def get_harmony_client_if_available(ip_address: str): + """Connect to a harmony hub and fetch info.""" + harmony = HarmonyAPI(ip_address=ip_address) + + try: + if not await harmony.connect(): + await harmony.close() + return None + except harmony_exceptions.TimeOut: + return None + + await harmony.close() + + return harmony + + +def find_matching_config_entries_for_host(hass, host): + """Search existing config entries for one matching the host.""" + for entry in hass.config_entries.async_entries(DOMAIN): + if entry.data[CONF_HOST] == host: + return entry + return None diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index c8c0f6c9f19b0..f13db03ca4c9b 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -9,15 +9,19 @@ 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) + ATTR_NAME, + EVENT_CORE_CONFIG_UPDATE, + SERVICE_HOMEASSISTANT_RESTART, + SERVICE_HOMEASSISTANT_STOP, +) 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 .auth import async_setup_auth_view from .discovery import async_setup_discovery_view from .handler import HassIO, HassioAPIError from .http import HassIOView @@ -25,90 +29,97 @@ _LOGGER = logging.getLogger(__name__) -DOMAIN = 'hassio' +DOMAIN = "hassio" STORAGE_KEY = DOMAIN STORAGE_VERSION = 1 -CONF_FRONTEND_REPO = 'development_repo' +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) +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' +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' +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 = vol.Schema({vol.Required(ATTR_ADDON): cv.slug}) -SCHEMA_ADDON_STDIN = SCHEMA_ADDON.extend({ - vol.Required(ATTR_INPUT): vol.Any(dict, cv.string) -}) +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_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_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_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]), -}) +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), + 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, + ), } @@ -125,23 +136,31 @@ def get_homeassistant_version(hass): @callback @bind_hass def is_hassio(hass): - """Return true if hass.io is loaded. + """Return true if Hass.io is loaded. Async friendly. """ return DOMAIN in hass.config.components +@callback +def get_supervisor_ip(): + """Return the supervisor ip address.""" + if "SUPERVISOR" not in os.environ: + return None + return os.environ["SUPERVISOR"].partition(":")[0] + + async def async_setup(hass, config): """Set up the Hass.io component.""" # Check local setup - for env in ('HASSIO', 'HASSIO_TOKEN'): + 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'] + host = os.environ["HASSIO"] websession = hass.helpers.aiohttp_client.async_get_clientsession() hass.data[DOMAIN] = hassio = HassIO(hass.loop, websession, host) @@ -155,47 +174,49 @@ async def async_setup(hass, config): data = {} refresh_token = None - if 'hassio_user' in data: - user = await hass.auth.async_get_user(data['hassio_user']) + 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. + # 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]) + 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]) + 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 + 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) + "/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 hass.components.panel_custom.async_register_panel( + frontend_url_path="hassio", + webcomponent_name="hassio-main", + sidebar_title="Supervisor", + 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) + await hassio.update_hass_api(config.get("http", {}), refresh_token) - if 'homeassistant' in config: - await hassio.update_hass_timezone(config['homeassistant']) + 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.""" @@ -205,7 +226,7 @@ async def async_service_handler(service): snapshot = data.pop(ATTR_SNAPSHOT, None) payload = None - # Pass data to hass.io API + # Pass data to Hass.io API if service.service == SERVICE_ADDON_STDIN: payload = data[ATTR_INPUT] elif MAP_SERVICE_API[service.service][3]: @@ -215,25 +236,28 @@ async def async_service_handler(service): try: await hassio.send_command( api_command.format(addon=addon, snapshot=snapshot), - payload=payload, timeout=MAP_SERVICE_API[service.service][2] + 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]) + 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'] + 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) + update_homeassistant_version, utcnow() + HASSIO_UPDATE_INTERVAL + ) # Fetch last version await update_homeassistant_version(None) @@ -252,24 +276,28 @@ async def async_handle_core_service(call): 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)) + "Config error. See [the logs](/developer-tools/logs) for details.", + "Config validating", + f"{HASS_DOMAIN}.check_config", + ) 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) + 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) + async_setup_auth_view(hass, user) # Init ingress Hass.io feature async_setup_ingress_view(hass, host) diff --git a/homeassistant/components/hassio/addon_panel.py b/homeassistant/components/hassio/addon_panel.py index 7291a87e9544f..9e44b961a1c53 100644 --- a/homeassistant/components/hassio/addon_panel.py +++ b/homeassistant/components/hassio/addon_panel.py @@ -5,9 +5,10 @@ from aiohttp import web from homeassistant.components.http import HomeAssistantView +from homeassistant.const import HTTP_BAD_REQUEST from homeassistant.helpers.typing import HomeAssistantType -from .const import ATTR_PANELS, ATTR_TITLE, ATTR_ICON, ATTR_ADMIN, ATTR_ENABLE +from .const import ATTR_ADMIN, ATTR_ENABLE, ATTR_ICON, ATTR_PANELS, ATTR_TITLE from .handler import HassioAPIError _LOGGER = logging.getLogger(__name__) @@ -52,7 +53,7 @@ async def post(self, request, addon): # 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) + return web.Response(status=HTTP_BAD_REQUEST) data = panels[addon] # Register panel @@ -61,7 +62,7 @@ async def post(self, request, addon): async def delete(self, request, addon): """Handle remove add-on panel requests.""" - # Currently not supported by backend / frontend + self.hass.components.frontend.async_remove_panel(addon) return web.Response() async def get_panels(self): @@ -74,20 +75,15 @@ async def get_panels(self): return {} -def _register_panel(hass, addon, data): - """Init coroutine to register the panel. - - Return coroutine. - """ - return hass.components.panel_custom.async_register_panel( +async def _register_panel(hass, addon, data): + """Init coroutine to register the panel.""" + await hass.components.panel_custom.async_register_panel( frontend_url_path=addon, - webcomponent_name='hassio-main', + webcomponent_name="hassio-main", sidebar_title=data[ATTR_TITLE], sidebar_icon=data[ATTR_ICON], - js_url='/api/hassio/app/entrypoint.js', + js_url="/api/hassio/app/entrypoint.js", embed_iframe=True, require_admin=data[ATTR_ADMIN], - config={ - "ingress": addon - } + config={"ingress": addon}, ) diff --git a/homeassistant/components/hassio/auth.py b/homeassistant/components/hassio/auth.py index 85ae6473562f6..b95690641cdcc 100644 --- a/homeassistant/components/hassio/auth.py +++ b/homeassistant/components/hassio/auth.py @@ -1,18 +1,24 @@ """Implement the auth feature from Hass.io for Add-ons.""" +from ipaddress import ip_address import logging import os -from ipaddress import ip_address -import voluptuous as vol from aiohttp import web -from aiohttp.web_exceptions import HTTPForbidden, HTTPNotFound +from aiohttp.web_exceptions import ( + HTTPInternalServerError, + HTTPNotFound, + HTTPUnauthorized, +) +import voluptuous as vol -import homeassistant.helpers.config_validation as cv +from homeassistant.auth.models import User from homeassistant.components.http import HomeAssistantView -from homeassistant.components.http.const import KEY_REAL_IP +from homeassistant.components.http.const import KEY_HASS_USER, KEY_REAL_IP from homeassistant.components.http.data_validator import RequestDataValidator +from homeassistant.const import HTTP_OK from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import HomeAssistantType from .const import ATTR_ADDON, ATTR_PASSWORD, ATTR_USERNAME @@ -20,51 +26,76 @@ _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) +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, +) + +SCHEMA_API_PASSWORD_RESET = vol.Schema( + {vol.Required(ATTR_USERNAME): cv.string, vol.Required(ATTR_PASSWORD): cv.string}, + extra=vol.ALLOW_EXTRA, +) @callback -def async_setup_auth_view(hass: HomeAssistantType): +def async_setup_auth_view(hass: HomeAssistantType, user: User): """Auth setup.""" - hassio_auth = HassIOAuth(hass) + hassio_auth = HassIOAuth(hass, user) + hassio_password_reset = HassIOPasswordReset(hass, user) + hass.http.register_view(hassio_auth) + hass.http.register_view(hassio_password_reset) -class HassIOAuth(HomeAssistantView): - """Hass.io view to handle base part.""" +class HassIOBaseAuth(HomeAssistantView): + """Hass.io view to handle auth requests.""" - name = "api:hassio_auth" - url = "/api/hassio_auth" - - def __init__(self, hass): + def __init__(self, hass: HomeAssistantType, user: User): """Initialize WebView.""" self.hass = hass + self.user = user - @RequestDataValidator(SCHEMA_API_AUTH) - async def post(self, request, data): - """Handle new discovery requests.""" - hassio_ip = os.environ['HASSIO'].split(':')[0] + def _check_access(self, request: web.Request): + """Check if this call is from Supervisor.""" + # Check caller IP + 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() + _LOGGER.error("Invalid auth request from %s", request[KEY_REAL_IP]) + raise HTTPUnauthorized() - await self._check_login(data[ATTR_USERNAME], data[ATTR_PASSWORD]) - return web.Response(status=200) + # Check caller token + if request[KEY_HASS_USER].id != self.user.id: + _LOGGER.error("Invalid auth request from %s", request[KEY_HASS_USER].name) + raise HTTPUnauthorized() def _get_provider(self): """Return Homeassistant auth provider.""" - prv = self.hass.auth.get_auth_provider('homeassistant', None) + 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() + +class HassIOAuth(HassIOBaseAuth): + """Hass.io view to handle auth requests.""" + + name = "api:hassio:auth" + url = "/api/hassio_auth" + + @RequestDataValidator(SCHEMA_API_AUTH) + async def post(self, request, data): + """Handle auth requests.""" + self._check_access(request) + + await self._check_login(data[ATTR_USERNAME], data[ATTR_PASSWORD]) + return web.Response(status=HTTP_OK) + async def _check_login(self, username, password): """Check User credentials.""" provider = self._get_provider() @@ -72,4 +103,31 @@ async def _check_login(self, username, password): try: await provider.async_validate_login(username, password) except HomeAssistantError: - raise HTTPForbidden() from None + raise HTTPUnauthorized() from None + + +class HassIOPasswordReset(HassIOBaseAuth): + """Hass.io view to handle password reset requests.""" + + name = "api:hassio:auth:password:reset" + url = "/api/hassio_auth/password_reset" + + @RequestDataValidator(SCHEMA_API_PASSWORD_RESET) + async def post(self, request, data): + """Handle password reset requests.""" + self._check_access(request) + + await self._change_password(data[ATTR_USERNAME], data[ATTR_PASSWORD]) + return web.Response(status=HTTP_OK) + + async def _change_password(self, username, password): + """Check User credentials.""" + provider = self._get_provider() + + try: + await self.hass.async_add_executor_job( + provider.data.change_password, username, password + ) + await provider.data.async_save() + except HomeAssistantError: + raise HTTPInternalServerError() diff --git a/homeassistant/components/hassio/const.py b/homeassistant/components/hassio/const.py index 9656346cd2c06..ffccb32539563 100644 --- a/homeassistant/components/hassio/const.py +++ b/homeassistant/components/hassio/const.py @@ -1,21 +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' +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_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' +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 index 90953d634c315..fc6efbe0e5815 100644 --- a/homeassistant/components/hassio/discovery.py +++ b/homeassistant/components/hassio/discovery.py @@ -5,13 +5,18 @@ from aiohttp import web from aiohttp.web_exceptions import HTTPServiceUnavailable +from homeassistant.components.http import HomeAssistantView 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) + ATTR_ADDON, + ATTR_CONFIG, + ATTR_DISCOVERY, + ATTR_NAME, + ATTR_SERVICE, + ATTR_UUID, +) from .handler import HassioAPIError _LOGGER = logging.getLogger(__name__) @@ -32,13 +37,16 @@ async def _async_discovery_start_handler(event): _LOGGER.error("Can't read discover info: %s", err) return - jobs = [hassio_discovery.async_process_new(discovery) - for discovery in data[ATTR_DISCOVERY]] + 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) + EVENT_HOMEASSISTANT_START, _async_discovery_start_handler + ) class HassIODiscovery(HomeAssistantView): @@ -86,7 +94,8 @@ async def async_process_new(self, data): # Use config flow await self.hass.config_entries.flow.async_init( - service, context={'source': 'hassio'}, data=config_data) + service, context={"source": "hassio"}, data=config_data + ) async def async_process_del(self, data): """Process remove discovery entry.""" @@ -104,6 +113,6 @@ async def async_process_del(self, data): # Use config flow for entry in self.hass.config_entries.async_entries(service): - if entry.source != 'hassio': + 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 index aae1f31d48649..d929f2d3e82dd 100644 --- a/homeassistant/components/hassio/handler.py +++ b/homeassistant/components/hassio/handler.py @@ -10,8 +10,9 @@ CONF_SERVER_HOST, CONF_SERVER_PORT, CONF_SSL_CERTIFICATE, + DEFAULT_SERVER_HOST, ) -from homeassistant.const import CONF_TIME_ZONE, SERVER_PORT +from homeassistant.const import HTTP_BAD_REQUEST, HTTP_OK, SERVER_PORT from .const import X_HASSIO @@ -24,11 +25,12 @@ class HassioAPIError(RuntimeError): def _api_bool(funct): """Return a boolean.""" + async def _wrapper(*argv, **kwargs): """Wrap function.""" try: data = await funct(*argv, **kwargs) - return data['result'] == "ok" + return data["result"] == "ok" except HassioAPIError: return False @@ -37,12 +39,13 @@ async def _wrapper(*argv, **kwargs): 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']) + if data["result"] == "ok": + return data["data"] + raise HassioAPIError(data["message"]) return _wrapper @@ -78,8 +81,7 @@ def get_addon_info(self, addon): This method return a coroutine. """ - return self.send_command( - "/addons/{}/info".format(addon), method="get") + return self.send_command(f"/addons/{addon}/info", method="get") @_api_data def get_ingress_panels(self): @@ -119,53 +121,54 @@ def get_discovery_message(self, uuid): This method return a coroutine. """ - return self.send_command("/discovery/{}".format(uuid), method="get") + return self.send_command(f"/discovery/{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, + "ssl": CONF_SSL_CERTIFICATE in http_config, + "port": port, + "watchdog": True, + "refresh_token": refresh_token.token, } - if CONF_SERVER_HOST in http_config: - options['watchdog'] = False - _LOGGER.warning("Don't use 'server_host' options with Hass.io") + if ( + http_config.get(CONF_SERVER_HOST, DEFAULT_SERVER_HOST) + != DEFAULT_SERVER_HOST + ): + options["watchdog"] = False + _LOGGER.warning( + "Found incompatible HTTP option 'server_host'. Watchdog feature disabled" + ) - return await self.send_command("/homeassistant/options", - payload=options) + return await self.send_command("/homeassistant/options", payload=options) @_api_bool - def update_hass_timezone(self, core_config): + 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': core_config.get(CONF_TIME_ZONE) - }) + return self.send_command("/supervisor/options", payload={"timezone": timezone}) - async def send_command(self, command, method="post", payload=None, - timeout=10): + 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, loop=self.loop): + 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) + method, + f"http://{self._ip}{command}", + json=payload, + headers={X_HASSIO: os.environ.get("HASSIO_TOKEN", "")}, + ) + + if request.status not in (HTTP_OK, HTTP_BAD_REQUEST): + _LOGGER.error("%s return code %d.", command, request.status) raise HassioAPIError() answer = await request.json() diff --git a/homeassistant/components/hassio/http.py b/homeassistant/components/hassio/http.py index a798d312c257e..be2cec5ae9c64 100644 --- a/homeassistant/components/hassio/http.py +++ b/homeassistant/components/hassio/http.py @@ -7,7 +7,7 @@ import aiohttp from aiohttp import web -from aiohttp.hdrs import CONTENT_TYPE, CONTENT_LENGTH +from aiohttp.hdrs import CONTENT_LENGTH, CONTENT_TYPE from aiohttp.web_exceptions import HTTPBadGateway import async_timeout @@ -19,23 +19,20 @@ 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')$' + 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')$' + r"^(?:" r"|app/.*" r"|addons/[^/]+/logo" r"|addons/[^/]+/icon" r")$" ) @@ -52,7 +49,7 @@ def __init__(self, host: str, websession: aiohttp.ClientSession): self._websession = websession async def _handle( - self, request: web.Request, path: str + 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]: @@ -64,26 +61,26 @@ async def _handle( post = _handle async def _command_proxy( - self, path: str, request: web.Request + 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) - hass = request.app['hass'] - data = None headers = _init_header(request) try: - with async_timeout.timeout(10, loop=hass.loop): + 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 + f"http://{self._host}/{path}", + data=data, + headers=headers, + timeout=read_timeout, ) # Simple request @@ -91,9 +88,7 @@ async def _command_proxy( # Return Response body = await client.read() return web.Response( - content_type=client.content_type, - status=client.status, - body=body, + content_type=client.content_type, status=client.status, body=body ) # Stream response @@ -118,15 +113,15 @@ async def _command_proxy( def _init_header(request: web.Request) -> Dict[str, str]: """Create initial header.""" headers = { - X_HASSIO: os.environ.get('HASSIO_TOKEN', ""), + X_HASSIO: os.environ.get("HASSIO_TOKEN", ""), CONTENT_TYPE: request.content_type, } # Add user data - user = request.get('hass_user') + 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)) + headers[X_HASS_USER_ID] = request["hass_user"].id + headers[X_HASS_IS_ADMIN] = str(int(request["hass_user"].is_admin)) return headers diff --git a/homeassistant/components/hassio/ingress.py b/homeassistant/components/hassio/ingress.py index 824dee86fadaa..c69d2078468fd 100644 --- a/homeassistant/components/hassio/ingress.py +++ b/homeassistant/components/hassio/ingress.py @@ -1,8 +1,8 @@ """Hass.io Add-on ingress service.""" import asyncio +from ipaddress import ip_address import logging import os -from ipaddress import ip_address from typing import Dict, Union import aiohttp @@ -42,10 +42,10 @@ def __init__(self, host: str, websession: aiohttp.ClientSession): def _create_url(self, token: str, path: str) -> str: """Create URL to service.""" - return "http://{}/ingress/{}/{}".format(self._host, token, path) + return f"http://{self._host}/ingress/{token}/{path}" async def _handle( - self, request: web.Request, token: str, path: str + self, request: web.Request, token: str, path: str ) -> Union[web.Response, web.StreamResponse, web.WebSocketResponse]: """Route data to Hass.io ingress service.""" try: @@ -69,14 +69,13 @@ async def _handle( options = _handle async def _handle_websocket( - self, request: web.Request, token: str, path: str + 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(",") + for proto in request.headers[hdrs.SEC_WEBSOCKET_PROTOCOL].split(",") ] else: req_protocols = () @@ -92,12 +91,15 @@ async def _handle_websocket( # Support GET query if request.query_string: - url = "{}?{}".format(url, request.query_string) + url = f"{url}?{request.query_string}" # Start proxy async with self._websession.ws_connect( - url, headers=source_header, protocols=req_protocols, - autoclose=False, autoping=False, + url, + headers=source_header, + protocols=req_protocols, + autoclose=False, + autoping=False, ) as ws_client: # Proxy requests await asyncio.wait( @@ -105,13 +107,13 @@ async def _handle_websocket( _websocket_forward(ws_server, ws_client), _websocket_forward(ws_client, ws_server), ], - return_when=asyncio.FIRST_COMPLETED + return_when=asyncio.FIRST_COMPLETED, ) return ws_server async def _handle_request( - self, request: web.Request, token: str, path: str + self, request: web.Request, token: str, path: str ) -> Union[web.Response, web.StreamResponse]: """Ingress route for request.""" url = self._create_url(token, path) @@ -119,26 +121,31 @@ async def _handle_request( source_header = _init_header(request, token) async with self._websession.request( - request.method, url, headers=source_header, - params=request.query, data=data + 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: + 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 + body=body, ) # Stream response - response = web.StreamResponse( - status=result.status, headers=headers) + response = web.StreamResponse(status=result.status, headers=headers) response.content_type = result.content_type try: @@ -153,30 +160,37 @@ async def _handle_request( def _init_header( - request: web.Request, token: str + 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): + if name in ( + hdrs.CONTENT_LENGTH, + hdrs.CONTENT_ENCODING, + hdrs.SEC_WEBSOCKET_EXTENSIONS, + hdrs.SEC_WEBSOCKET_PROTOCOL, + hdrs.SEC_WEBSOCKET_VERSION, + hdrs.SEC_WEBSOCKET_KEY, + ): continue headers[name] = value # Inject token / cleanup later on Supervisor - headers[X_HASSIO] = os.environ.get('HASSIO_TOKEN', "") + headers[X_HASSIO] = os.environ.get("HASSIO_TOKEN", "") # Ingress information - headers[X_INGRESS_PATH] = "/api/hassio_ingress/{}".format(token) + headers[X_INGRESS_PATH] = f"/api/hassio_ingress/{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]) + connected_ip = ip_address(request.transport.get_extra_info("peername")[0]) if forward_for: - forward_for = "{}, {!s}".format(forward_for, connected_ip) + forward_for = f"{forward_for}, {connected_ip!s}" else: - forward_for = "{!s}".format(connected_ip) + forward_for = f"{connected_ip!s}" headers[hdrs.X_FORWARDED_FOR] = forward_for # Set X-Forwarded-Host @@ -199,8 +213,12 @@ def _response_header(response: aiohttp.ClientResponse) -> Dict[str, str]: headers = {} for name, value in response.headers.items(): - if name in (hdrs.TRANSFER_ENCODING, hdrs.CONTENT_LENGTH, - hdrs.CONTENT_TYPE, hdrs.CONTENT_ENCODING): + if name in ( + hdrs.TRANSFER_ENCODING, + hdrs.CONTENT_LENGTH, + hdrs.CONTENT_TYPE, + hdrs.CONTENT_ENCODING, + ): continue headers[name] = value @@ -211,8 +229,10 @@ 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": + if ( + "upgrade" in headers.get(hdrs.CONNECTION, "").lower() + and headers.get(hdrs.UPGRADE, "").lower() == "websocket" + ): return True return False diff --git a/homeassistant/components/hassio/manifest.json b/homeassistant/components/hassio/manifest.json index 23095064d558a..bc215932aa8fa 100644 --- a/homeassistant/components/hassio/manifest.json +++ b/homeassistant/components/hassio/manifest.json @@ -2,12 +2,7 @@ "domain": "hassio", "name": "Hass.io", "documentation": "https://www.home-assistant.io/hassio", - "requirements": [], - "dependencies": [ - "http", - "panel_custom" - ], - "codeowners": [ - "@home-assistant/hass-io" - ] + "dependencies": ["http"], + "after_dependencies": ["panel_custom"], + "codeowners": ["@home-assistant/hass-io"] } diff --git a/homeassistant/components/hassio/services.yaml b/homeassistant/components/hassio/services.yaml index 33574c5dd713b..30314c646b030 100644 --- a/homeassistant/components/hassio/services.yaml +++ b/homeassistant/components/hassio/services.yaml @@ -1,37 +1,84 @@ addon_install: - description: Install a HassIO docker addon. + description: Install a Hass.io docker add-on. fields: - addon: {description: Name of addon., example: smb_config} - version: {description: Optional or it will be use the latest version., example: '0.2'} + addon: + description: The add-on slug. + example: core_ssh + version: + description: Optional or it will be use the latest version. + example: "0.2" + addon_start: - description: Start a HassIO docker addon. + description: Start a Hass.io docker add-on. fields: - addon: {description: Name of addon., example: smb_config} + addon: + description: The add-on slug. + example: core_ssh + +addon_restart: + description: Restart a Hass.io docker add-on. + fields: + addon: + description: The add-on slug. + example: core_ssh + +addon_stdin: + description: Write data to a Hass.io docker add-on stdin . + fields: + addon: + description: The add-on slug. + example: core_ssh + addon_stop: - description: Stop a HassIO docker addon. + description: Stop a Hass.io docker add-on. fields: - addon: {description: Name of addon., example: smb_config} + addon: + description: The add-on slug. + example: core_ssh + addon_uninstall: - description: Uninstall a HassIO docker addon. + description: Uninstall a Hass.io docker add-on. fields: - addon: {description: Name of addon., example: smb_config} + addon: + description: The add-on slug. + example: core_ssh + addon_update: - description: Update a HassIO docker addon. + description: Update a Hass.io docker add-on. fields: - addon: {description: Name of addon., example: smb_config} - version: {description: Optional or it will be use the latest version., example: '0.2'} + addon: + description: The add-on slug. + example: core_ssh + version: + description: Optional or it will be use the latest version. + example: "0.2" + homeassistant_update: - description: Update HomeAssistant docker image. + description: Update the Home Assistant 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.} + version: + description: Optional or it will be use the latest version. + example: 0.40.1 + +host_reboot: + description: Reboot the host system. + +host_shutdown: + description: Poweroff the host system. + host_update: - description: Update host computer. + description: Update the host system. fields: - version: {description: Optional or it will be use the latest version., example: '0.3'} -supervisor_reload: {description: Reload HassIO supervisor addons/updates/configs.} + version: + description: Optional or it will be use the latest version. + example: "0.3" + +supervisor_reload: + description: Reload the Hass.io supervisor. + supervisor_update: - description: Update HassIO supervisor. + description: Update the Hass.io supervisor. fields: - version: {description: Optional or it will be use the latest version., example: '0.3'} + version: + description: Optional or it will be use the latest version. + example: "0.3" diff --git a/homeassistant/components/hassio/translations/af.json b/homeassistant/components/hassio/translations/af.json new file mode 100644 index 0000000000000..981cb51c83ab8 --- /dev/null +++ b/homeassistant/components/hassio/translations/af.json @@ -0,0 +1,3 @@ +{ + "title": "Hass.io" +} \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/bg.json b/homeassistant/components/hassio/translations/bg.json new file mode 100644 index 0000000000000..981cb51c83ab8 --- /dev/null +++ b/homeassistant/components/hassio/translations/bg.json @@ -0,0 +1,3 @@ +{ + "title": "Hass.io" +} \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/ca.json b/homeassistant/components/hassio/translations/ca.json new file mode 100644 index 0000000000000..981cb51c83ab8 --- /dev/null +++ b/homeassistant/components/hassio/translations/ca.json @@ -0,0 +1,3 @@ +{ + "title": "Hass.io" +} \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/cs.json b/homeassistant/components/hassio/translations/cs.json new file mode 100644 index 0000000000000..981cb51c83ab8 --- /dev/null +++ b/homeassistant/components/hassio/translations/cs.json @@ -0,0 +1,3 @@ +{ + "title": "Hass.io" +} \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/cy.json b/homeassistant/components/hassio/translations/cy.json new file mode 100644 index 0000000000000..981cb51c83ab8 --- /dev/null +++ b/homeassistant/components/hassio/translations/cy.json @@ -0,0 +1,3 @@ +{ + "title": "Hass.io" +} \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/da.json b/homeassistant/components/hassio/translations/da.json new file mode 100644 index 0000000000000..981cb51c83ab8 --- /dev/null +++ b/homeassistant/components/hassio/translations/da.json @@ -0,0 +1,3 @@ +{ + "title": "Hass.io" +} \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/de.json b/homeassistant/components/hassio/translations/de.json new file mode 100644 index 0000000000000..981cb51c83ab8 --- /dev/null +++ b/homeassistant/components/hassio/translations/de.json @@ -0,0 +1,3 @@ +{ + "title": "Hass.io" +} \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/el.json b/homeassistant/components/hassio/translations/el.json new file mode 100644 index 0000000000000..981cb51c83ab8 --- /dev/null +++ b/homeassistant/components/hassio/translations/el.json @@ -0,0 +1,3 @@ +{ + "title": "Hass.io" +} \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/en.json b/homeassistant/components/hassio/translations/en.json new file mode 100644 index 0000000000000..981cb51c83ab8 --- /dev/null +++ b/homeassistant/components/hassio/translations/en.json @@ -0,0 +1,3 @@ +{ + "title": "Hass.io" +} \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/es-419.json b/homeassistant/components/hassio/translations/es-419.json new file mode 100644 index 0000000000000..981cb51c83ab8 --- /dev/null +++ b/homeassistant/components/hassio/translations/es-419.json @@ -0,0 +1,3 @@ +{ + "title": "Hass.io" +} \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/es.json b/homeassistant/components/hassio/translations/es.json new file mode 100644 index 0000000000000..981cb51c83ab8 --- /dev/null +++ b/homeassistant/components/hassio/translations/es.json @@ -0,0 +1,3 @@ +{ + "title": "Hass.io" +} \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/et.json b/homeassistant/components/hassio/translations/et.json new file mode 100644 index 0000000000000..981cb51c83ab8 --- /dev/null +++ b/homeassistant/components/hassio/translations/et.json @@ -0,0 +1,3 @@ +{ + "title": "Hass.io" +} \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/eu.json b/homeassistant/components/hassio/translations/eu.json new file mode 100644 index 0000000000000..981cb51c83ab8 --- /dev/null +++ b/homeassistant/components/hassio/translations/eu.json @@ -0,0 +1,3 @@ +{ + "title": "Hass.io" +} \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/fa.json b/homeassistant/components/hassio/translations/fa.json new file mode 100644 index 0000000000000..981cb51c83ab8 --- /dev/null +++ b/homeassistant/components/hassio/translations/fa.json @@ -0,0 +1,3 @@ +{ + "title": "Hass.io" +} \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/fi.json b/homeassistant/components/hassio/translations/fi.json new file mode 100644 index 0000000000000..981cb51c83ab8 --- /dev/null +++ b/homeassistant/components/hassio/translations/fi.json @@ -0,0 +1,3 @@ +{ + "title": "Hass.io" +} \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/fr.json b/homeassistant/components/hassio/translations/fr.json new file mode 100644 index 0000000000000..981cb51c83ab8 --- /dev/null +++ b/homeassistant/components/hassio/translations/fr.json @@ -0,0 +1,3 @@ +{ + "title": "Hass.io" +} \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/he.json b/homeassistant/components/hassio/translations/he.json new file mode 100644 index 0000000000000..981cb51c83ab8 --- /dev/null +++ b/homeassistant/components/hassio/translations/he.json @@ -0,0 +1,3 @@ +{ + "title": "Hass.io" +} \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/hr.json b/homeassistant/components/hassio/translations/hr.json new file mode 100644 index 0000000000000..981cb51c83ab8 --- /dev/null +++ b/homeassistant/components/hassio/translations/hr.json @@ -0,0 +1,3 @@ +{ + "title": "Hass.io" +} \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/hu.json b/homeassistant/components/hassio/translations/hu.json new file mode 100644 index 0000000000000..981cb51c83ab8 --- /dev/null +++ b/homeassistant/components/hassio/translations/hu.json @@ -0,0 +1,3 @@ +{ + "title": "Hass.io" +} \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/hy.json b/homeassistant/components/hassio/translations/hy.json new file mode 100644 index 0000000000000..981cb51c83ab8 --- /dev/null +++ b/homeassistant/components/hassio/translations/hy.json @@ -0,0 +1,3 @@ +{ + "title": "Hass.io" +} \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/is.json b/homeassistant/components/hassio/translations/is.json new file mode 100644 index 0000000000000..981cb51c83ab8 --- /dev/null +++ b/homeassistant/components/hassio/translations/is.json @@ -0,0 +1,3 @@ +{ + "title": "Hass.io" +} \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/it.json b/homeassistant/components/hassio/translations/it.json new file mode 100644 index 0000000000000..981cb51c83ab8 --- /dev/null +++ b/homeassistant/components/hassio/translations/it.json @@ -0,0 +1,3 @@ +{ + "title": "Hass.io" +} \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/ja.json b/homeassistant/components/hassio/translations/ja.json new file mode 100644 index 0000000000000..981cb51c83ab8 --- /dev/null +++ b/homeassistant/components/hassio/translations/ja.json @@ -0,0 +1,3 @@ +{ + "title": "Hass.io" +} \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/ko.json b/homeassistant/components/hassio/translations/ko.json new file mode 100644 index 0000000000000..981cb51c83ab8 --- /dev/null +++ b/homeassistant/components/hassio/translations/ko.json @@ -0,0 +1,3 @@ +{ + "title": "Hass.io" +} \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/lb.json b/homeassistant/components/hassio/translations/lb.json new file mode 100644 index 0000000000000..981cb51c83ab8 --- /dev/null +++ b/homeassistant/components/hassio/translations/lb.json @@ -0,0 +1,3 @@ +{ + "title": "Hass.io" +} \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/lt.json b/homeassistant/components/hassio/translations/lt.json new file mode 100644 index 0000000000000..981cb51c83ab8 --- /dev/null +++ b/homeassistant/components/hassio/translations/lt.json @@ -0,0 +1,3 @@ +{ + "title": "Hass.io" +} \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/lv.json b/homeassistant/components/hassio/translations/lv.json new file mode 100644 index 0000000000000..981cb51c83ab8 --- /dev/null +++ b/homeassistant/components/hassio/translations/lv.json @@ -0,0 +1,3 @@ +{ + "title": "Hass.io" +} \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/nb.json b/homeassistant/components/hassio/translations/nb.json new file mode 100644 index 0000000000000..d8a4c45301515 --- /dev/null +++ b/homeassistant/components/hassio/translations/nb.json @@ -0,0 +1,3 @@ +{ + "title": "" +} \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/nl.json b/homeassistant/components/hassio/translations/nl.json new file mode 100644 index 0000000000000..981cb51c83ab8 --- /dev/null +++ b/homeassistant/components/hassio/translations/nl.json @@ -0,0 +1,3 @@ +{ + "title": "Hass.io" +} \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/nn.json b/homeassistant/components/hassio/translations/nn.json new file mode 100644 index 0000000000000..981cb51c83ab8 --- /dev/null +++ b/homeassistant/components/hassio/translations/nn.json @@ -0,0 +1,3 @@ +{ + "title": "Hass.io" +} \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/no.json b/homeassistant/components/hassio/translations/no.json new file mode 100644 index 0000000000000..d8a4c45301515 --- /dev/null +++ b/homeassistant/components/hassio/translations/no.json @@ -0,0 +1,3 @@ +{ + "title": "" +} \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/pl.json b/homeassistant/components/hassio/translations/pl.json new file mode 100644 index 0000000000000..981cb51c83ab8 --- /dev/null +++ b/homeassistant/components/hassio/translations/pl.json @@ -0,0 +1,3 @@ +{ + "title": "Hass.io" +} \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/pt-BR.json b/homeassistant/components/hassio/translations/pt-BR.json new file mode 100644 index 0000000000000..981cb51c83ab8 --- /dev/null +++ b/homeassistant/components/hassio/translations/pt-BR.json @@ -0,0 +1,3 @@ +{ + "title": "Hass.io" +} \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/pt.json b/homeassistant/components/hassio/translations/pt.json new file mode 100644 index 0000000000000..981cb51c83ab8 --- /dev/null +++ b/homeassistant/components/hassio/translations/pt.json @@ -0,0 +1,3 @@ +{ + "title": "Hass.io" +} \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/ro.json b/homeassistant/components/hassio/translations/ro.json new file mode 100644 index 0000000000000..981cb51c83ab8 --- /dev/null +++ b/homeassistant/components/hassio/translations/ro.json @@ -0,0 +1,3 @@ +{ + "title": "Hass.io" +} \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/ru.json b/homeassistant/components/hassio/translations/ru.json new file mode 100644 index 0000000000000..981cb51c83ab8 --- /dev/null +++ b/homeassistant/components/hassio/translations/ru.json @@ -0,0 +1,3 @@ +{ + "title": "Hass.io" +} \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/sk.json b/homeassistant/components/hassio/translations/sk.json new file mode 100644 index 0000000000000..981cb51c83ab8 --- /dev/null +++ b/homeassistant/components/hassio/translations/sk.json @@ -0,0 +1,3 @@ +{ + "title": "Hass.io" +} \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/sl.json b/homeassistant/components/hassio/translations/sl.json new file mode 100644 index 0000000000000..981cb51c83ab8 --- /dev/null +++ b/homeassistant/components/hassio/translations/sl.json @@ -0,0 +1,3 @@ +{ + "title": "Hass.io" +} \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/sv.json b/homeassistant/components/hassio/translations/sv.json new file mode 100644 index 0000000000000..981cb51c83ab8 --- /dev/null +++ b/homeassistant/components/hassio/translations/sv.json @@ -0,0 +1,3 @@ +{ + "title": "Hass.io" +} \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/th.json b/homeassistant/components/hassio/translations/th.json new file mode 100644 index 0000000000000..981cb51c83ab8 --- /dev/null +++ b/homeassistant/components/hassio/translations/th.json @@ -0,0 +1,3 @@ +{ + "title": "Hass.io" +} \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/tr.json b/homeassistant/components/hassio/translations/tr.json new file mode 100644 index 0000000000000..981cb51c83ab8 --- /dev/null +++ b/homeassistant/components/hassio/translations/tr.json @@ -0,0 +1,3 @@ +{ + "title": "Hass.io" +} \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/uk.json b/homeassistant/components/hassio/translations/uk.json new file mode 100644 index 0000000000000..981cb51c83ab8 --- /dev/null +++ b/homeassistant/components/hassio/translations/uk.json @@ -0,0 +1,3 @@ +{ + "title": "Hass.io" +} \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/vi.json b/homeassistant/components/hassio/translations/vi.json new file mode 100644 index 0000000000000..981cb51c83ab8 --- /dev/null +++ b/homeassistant/components/hassio/translations/vi.json @@ -0,0 +1,3 @@ +{ + "title": "Hass.io" +} \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/zh-Hans.json b/homeassistant/components/hassio/translations/zh-Hans.json new file mode 100644 index 0000000000000..981cb51c83ab8 --- /dev/null +++ b/homeassistant/components/hassio/translations/zh-Hans.json @@ -0,0 +1,3 @@ +{ + "title": "Hass.io" +} \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/zh-Hant.json b/homeassistant/components/hassio/translations/zh-Hant.json new file mode 100644 index 0000000000000..981cb51c83ab8 --- /dev/null +++ b/homeassistant/components/hassio/translations/zh-Hant.json @@ -0,0 +1,3 @@ +{ + "title": "Hass.io" +} \ No newline at end of file diff --git a/homeassistant/components/haveibeenpwned/manifest.json b/homeassistant/components/haveibeenpwned/manifest.json index f0b0561e170ea..255124eb133a3 100644 --- a/homeassistant/components/haveibeenpwned/manifest.json +++ b/homeassistant/components/haveibeenpwned/manifest.json @@ -1,8 +1,6 @@ { "domain": "haveibeenpwned", - "name": "Haveibeenpwned", - "documentation": "https://www.home-assistant.io/components/haveibeenpwned", - "requirements": [], - "dependencies": [], + "name": "HaveIBeenPwned", + "documentation": "https://www.home-assistant.io/integrations/haveibeenpwned", "codeowners": [] } diff --git a/homeassistant/components/haveibeenpwned/sensor.py b/homeassistant/components/haveibeenpwned/sensor.py index 72cc5ced3effb..0f5a9b5ebfd86 100644 --- a/homeassistant/components/haveibeenpwned/sensor.py +++ b/homeassistant/components/haveibeenpwned/sensor.py @@ -7,7 +7,13 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import CONF_EMAIL, ATTR_ATTRIBUTION +from homeassistant.const import ( + ATTR_ATTRIBUTION, + CONF_API_KEY, + CONF_EMAIL, + HTTP_NOT_FOUND, + HTTP_OK, +) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import track_point_in_time @@ -25,17 +31,21 @@ MIN_TIME_BETWEEN_FORCED_UPDATES = timedelta(seconds=5) MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) -URL = 'https://haveibeenpwned.com/api/v2/breachedaccount/' +URL = "https://haveibeenpwned.com/api/v3/breachedaccount/" -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_EMAIL): vol.All(cv.ensure_list, [cv.string]), -}) +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_EMAIL): vol.All(cv.ensure_list, [cv.string]), + vol.Required(CONF_API_KEY): 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) + api_key = config[CONF_API_KEY] + data = HaveIBeenPwnedData(emails, api_key) devices = [] for email in emails: @@ -57,7 +67,7 @@ def __init__(self, data, email): @property def name(self): """Return the name of the sensor.""" - return "Breaches {}".format(self._email) + return f"Breaches {self._email}" @property def unit_of_measurement(self): @@ -77,11 +87,11 @@ def device_state_attributes(self): 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)) + tmpname = f"breach {idx + 1}" + datetime_local = dt_util.as_local( + dt_util.parse_datetime(value["AddedDate"]) + ) + tmpvalue = f"{value['Title']} {datetime_local.strftime(DATE_STR_FORMAT)}" val[tmpname] = tmpvalue return val @@ -103,8 +113,10 @@ def update_nothrottle(self, dummy=None): # 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) + self.hass, + self.update_nothrottle, + dt_util.now() + MIN_TIME_BETWEEN_FORCED_UPDATES, + ) return self._state = len(self._data.data[self._email]) @@ -121,13 +133,14 @@ def update(self): class HaveIBeenPwnedData: """Class for handling the data retrieval.""" - def __init__(self, emails): + def __init__(self, emails, api_key): """Initialize the data object.""" self._email_count = len(emails) self._current_index = 0 self.data = {} self._email = emails[0] self._emails = emails + self._api_key = api_key def set_next_email(self): """Set the next email to be looked up.""" @@ -142,28 +155,25 @@ def update_no_throttle(self): def update(self, **kwargs): """Get the latest data for current email from REST service.""" try: - url = "{}{}".format(URL, self._email) - + url = f"{URL}{self._email}?truncateResponse=false" + header = {USER_AGENT: HA_USER_AGENT, "hibp-api-key": self._api_key} _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) + req = requests.get(url, headers=header, 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) + if req.status_code == HTTP_OK: + 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: + elif req.status_code == HTTP_NOT_FOUND: self.data[self._email] = [] # only goto next email if we had data so that @@ -171,6 +181,8 @@ def update(self, **kwargs): self.set_next_email() else: - _LOGGER.error("Failed fetching data for %s" - "(HTTP Status_code = %d)", self._email, - req.status_code) + _LOGGER.error( + "Failed fetching data for %s (HTTP Status_code = %d)", + self._email, + req.status_code, + ) diff --git a/homeassistant/components/hddtemp/manifest.json b/homeassistant/components/hddtemp/manifest.json index 2d34d3b4e7b64..d72103f202644 100644 --- a/homeassistant/components/hddtemp/manifest.json +++ b/homeassistant/components/hddtemp/manifest.json @@ -1,8 +1,6 @@ { "domain": "hddtemp", - "name": "Hddtemp", - "documentation": "https://www.home-assistant.io/components/hddtemp", - "requirements": [], - "dependencies": [], + "name": "hddtemp", + "documentation": "https://www.home-assistant.io/integrations/hddtemp", "codeowners": [] } diff --git a/homeassistant/components/hddtemp/sensor.py b/homeassistant/components/hddtemp/sensor.py index 6d96f244f5864..a1052b0440a11 100644 --- a/homeassistant/components/hddtemp/sensor.py +++ b/homeassistant/components/hddtemp/sensor.py @@ -1,35 +1,43 @@ """Support for getting the disk temperature of a host.""" -import logging from datetime import timedelta -from telnetlib import Telnet +import logging import socket +from telnetlib import Telnet 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) + CONF_DISKS, + CONF_HOST, + CONF_NAME, + CONF_PORT, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, +) +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) -ATTR_DEVICE = 'device' -ATTR_MODEL = 'model' +ATTR_DEVICE = "device" +ATTR_MODEL = "model" -DEFAULT_HOST = 'localhost' +DEFAULT_HOST = "localhost" DEFAULT_PORT = 7634 -DEFAULT_NAME = 'HD Temperature' +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, -}) +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): @@ -43,7 +51,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): hddtemp.update() if not disks: - disks = [next(iter(hddtemp.data)).split('|')[0]] + disks = [next(iter(hddtemp.data)).split("|")[0]] dev = [] for disk in disks: @@ -59,7 +67,7 @@ def __init__(self, name, disk, hddtemp): """Initialize a HDDTemp sensor.""" self.hddtemp = hddtemp self.disk = disk - self._name = '{} {}'.format(name, disk) + self._name = f"{name} {disk}" self._state = None self._details = None self._unit = None @@ -83,19 +91,16 @@ def unit_of_measurement(self): 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], - } + 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._details = self.hddtemp.data[self.disk].split("|") self._state = self._details[2] - if self._details is not None and self._details[3] == 'F': + if self._details is not None and self._details[3] == "F": self._unit = TEMP_FAHRENHEIT else: self._unit = TEMP_CELSIUS @@ -115,15 +120,17 @@ def __init__(self, host, port): 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)} + 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) + _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) diff --git a/homeassistant/components/hdmi_cec/__init__.py b/homeassistant/components/hdmi_cec/__init__.py index 189cc748d5d99..471a2dd0f46b8 100644 --- a/homeassistant/components/hdmi_cec/__init__.py +++ b/homeassistant/components/hdmi_cec/__init__.py @@ -4,32 +4,59 @@ import logging import multiprocessing +from pycec.cec import CecAdapter +from pycec.commands import CecCommand, KeyPressCommand, KeyReleaseCommand +from pycec.const import ( + ADDR_AUDIOSYSTEM, + ADDR_BROADCAST, + ADDR_UNREGISTERED, + KEY_MUTE_OFF, + KEY_MUTE_ON, + KEY_MUTE_TOGGLE, + KEY_VOLUME_DOWN, + KEY_VOLUME_UP, + POWER_OFF, + POWER_ON, + STATUS_PLAY, + STATUS_STILL, + STATUS_STOP, +) +from pycec.network import HDMINetwork, PhysicalAddress +from pycec.tcp import TcpAdapter 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) + 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' +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' +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, @@ -40,85 +67,100 @@ 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' +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' +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) +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): @@ -132,7 +174,6 @@ def parse_mapping(mapping, parents=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] @@ -144,13 +185,6 @@ def parse_mapping(mapping, parents=None): 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. @@ -164,10 +198,12 @@ def setup(hass: HomeAssistant, base_config): 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) + hass.loop + if multiprocessing.cpu_count() < 2 + else None + ) + host = base_config[DOMAIN].get(CONF_HOST) + display_name = base_config[DOMAIN].get(CONF_DISPLAY_NAME, DEFAULT_DISPLAY_NAME) if host: adapter = TcpAdapter(host, name=display_name, activate_source=False) else: @@ -176,8 +212,11 @@ def setup(hass: HomeAssistant, base_config): 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} + 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) @@ -185,10 +224,9 @@ def _volume(call): _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)) + 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) @@ -197,17 +235,14 @@ 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)) + 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)) + hdmi_network.send_command(KeyPressCommand(cmd, dst=ADDR_AUDIOSYSTEM)) + hdmi_network.send_command(KeyReleaseCommand(dst=ADDR_AUDIOSYSTEM)) def _tx(call): """Send CEC command.""" @@ -232,7 +267,7 @@ def _tx(call): if isinstance(data[ATTR_ATT], (list,)): att = data[ATTR_ATT] else: - att = reduce(lambda x, y: "%s:%x" % (x, y), data[ATTR_ATT]) + att = reduce(lambda x, y: f"{x}:{y:x}", data[ATTR_ATT]) else: att = "" command = CecCommand(cmd, dst, src, att) @@ -246,8 +281,6 @@ def _power_on(call): 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]) @@ -258,11 +291,12 @@ def _select_device(call): entity = hass.states.get(addr) _LOGGER.debug("Selecting entity %s", entity) if entity is not None: - addr = entity.attributes['physical_address'] + 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]) + _LOGGER.error( + "Device %s has not physical address", call.data[ATTR_DEVICE] + ) return if not isinstance(addr, (PhysicalAddress,)): addr = PhysicalAddress(addr) @@ -279,24 +313,34 @@ def _update(call): def _new_device(device): """Handle new devices which are detected by HDMI network.""" - key = '{}.{}'.format(DOMAIN, device.name) + key = f"{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) + 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_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) @@ -323,8 +367,6 @@ def __init__(self, device, logical) -> None: 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: @@ -350,13 +392,17 @@ def _update(self, device=None): 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') + f"{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)) + else "%s %d (%s)" + % (self._device.type_name, self._logical_address, self._device.osd_name) + ) @property def vendor_id(self): @@ -386,9 +432,13 @@ def type_id(self): @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) + 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): diff --git a/homeassistant/components/hdmi_cec/manifest.json b/homeassistant/components/hdmi_cec/manifest.json index b59d5622821db..3d2ea355e02cb 100644 --- a/homeassistant/components/hdmi_cec/manifest.json +++ b/homeassistant/components/hdmi_cec/manifest.json @@ -1,10 +1,7 @@ { "domain": "hdmi_cec", - "name": "Hdmi cec", - "documentation": "https://www.home-assistant.io/components/hdmi_cec", - "requirements": [ - "pyCEC==0.4.13" - ], - "dependencies": [], + "name": "HDMI-CEC", + "documentation": "https://www.home-assistant.io/integrations/hdmi_cec", + "requirements": ["pyCEC==0.4.13"], "codeowners": [] } diff --git a/homeassistant/components/hdmi_cec/media_player.py b/homeassistant/components/hdmi_cec/media_player.py index 4468fd9d648dc..180580ef37179 100644 --- a/homeassistant/components/hdmi_cec/media_player.py +++ b/homeassistant/components/hdmi_cec/media_player.py @@ -1,19 +1,53 @@ """Support for HDMI CEC devices as media players.""" import logging -from homeassistant.components.media_player import MediaPlayerDevice +from pycec.commands import CecCommand, KeyPressCommand, KeyReleaseCommand +from pycec.const import ( + KEY_BACKWARD, + KEY_FORWARD, + KEY_MUTE_TOGGLE, + KEY_PAUSE, + KEY_PLAY, + KEY_STOP, + KEY_VOLUME_DOWN, + KEY_VOLUME_UP, + POWER_OFF, + POWER_ON, + STATUS_PLAY, + STATUS_STILL, + STATUS_STOP, + TYPE_AUDIO, + TYPE_PLAYBACK, + TYPE_RECORDER, + TYPE_TUNER, +) + +from homeassistant.components.media_player import MediaPlayerEntity 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) + 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) + STATE_IDLE, + STATE_OFF, + STATE_ON, + STATE_PAUSED, + STATE_PLAYING, +) from . import ATTR_NEW, CecDevice _LOGGER = logging.getLogger(__name__) -ENTITY_ID_FORMAT = DOMAIN + '.{}' +ENTITY_ID_FORMAT = DOMAIN + ".{}" def setup_platform(hass, config, add_entities, discovery_info=None): @@ -23,45 +57,36 @@ def setup_platform(hass, config, add_entities, discovery_info=None): entities = [] for device in discovery_info[ATTR_NEW]: hdmi_device = hass.data.get(device) - entities.append(CecPlayerDevice( - hdmi_device, hdmi_device.logical_address, - )) + entities.append(CecPlayerDevice(hdmi_device, hdmi_device.logical_address)) add_entities(entities, True) -class CecPlayerDevice(CecDevice, MediaPlayerDevice): +class CecPlayerDevice(CecDevice, MediaPlayerEntity): """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:]) + self.entity_id = f"{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)) + _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)) + 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): @@ -80,7 +105,6 @@ def turn_off(self): def media_stop(self): """Stop playback.""" - from pycec.const import KEY_STOP self.send_keypress(KEY_STOP) self._state = STATE_IDLE @@ -90,7 +114,6 @@ def play_media(self, media_type, media_id, **kwargs): 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): @@ -103,7 +126,6 @@ def set_volume_level(self, volume): def media_pause(self): """Pause playback.""" - from pycec.const import KEY_PAUSE self.send_keypress(KEY_PAUSE) self._state = STATE_PAUSED @@ -113,19 +135,16 @@ def select_source(self, source): 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) @@ -137,8 +156,6 @@ def state(self) -> str: 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: @@ -156,16 +173,29 @@ def update(self): @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) + 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) + 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 + | 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 index f2e5f0b837a40..aa85ffb0214e0 100644 --- a/homeassistant/components/hdmi_cec/services.yaml +++ b/homeassistant/components/hdmi_cec/services.yaml @@ -1,32 +1,45 @@ -power_on: {description: Power on all devices which supports it.} +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"'} + device: + description: Address of device to select. Can be entity_id, physical address or alias from configuration. + 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 + 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.} + 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} + 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 index 9fb003f6d6a01..aaaa2b83054e7 100644 --- a/homeassistant/components/hdmi_cec/switch.py +++ b/homeassistant/components/hdmi_cec/switch.py @@ -1,14 +1,14 @@ """Support for HDMI CEC devices as switches.""" import logging -from homeassistant.components.switch import DOMAIN, SwitchDevice +from homeassistant.components.switch import DOMAIN, SwitchEntity from homeassistant.const import STATE_OFF, STATE_ON, STATE_STANDBY from . import ATTR_NEW, CecDevice _LOGGER = logging.getLogger(__name__) -ENTITY_ID_FORMAT = DOMAIN + '.{}' +ENTITY_ID_FORMAT = DOMAIN + ".{}" def setup_platform(hass, config, add_entities, discovery_info=None): @@ -18,20 +18,17 @@ def setup_platform(hass, config, add_entities, discovery_info=None): entities = [] for device in discovery_info[ATTR_NEW]: hdmi_device = hass.data.get(device) - entities.append(CecSwitchDevice( - hdmi_device, hdmi_device.logical_address, - )) + entities.append(CecSwitchDevice(hdmi_device, hdmi_device.logical_address)) add_entities(entities, True) -class CecSwitchDevice(CecDevice, SwitchDevice): +class CecSwitchDevice(CecDevice, SwitchEntity): """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:]) + self.entity_id = f"{DOMAIN}.hdmi_{hex(self._logical_address)[2:]}" def turn_on(self, **kwargs) -> None: """Turn device on.""" diff --git a/homeassistant/components/heatmiser/climate.py b/homeassistant/components/heatmiser/climate.py index 045ffdd34c586..b3f3363818c3b 100644 --- a/homeassistant/components/heatmiser/climate.py +++ b/homeassistant/components/heatmiser/climate.py @@ -1,64 +1,83 @@ """Support for the PRT Heatmiser themostats using the V3 protocol.""" import logging +from typing import List +from heatmiserV3 import connection, heatmiser import voluptuous as vol -from homeassistant.components.climate import ClimateDevice, PLATFORM_SCHEMA -from homeassistant.components.climate.const import ( - SUPPORT_TARGET_TEMPERATURE) +from homeassistant.components.climate import ( + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + PLATFORM_SCHEMA, + ClimateEntity, +) +from homeassistant.components.climate.const import SUPPORT_TARGET_TEMPERATURE from homeassistant.const import ( - TEMP_CELSIUS, ATTR_TEMPERATURE, CONF_PORT, CONF_NAME, CONF_ID) + ATTR_TEMPERATURE, + CONF_HOST, + CONF_ID, + CONF_NAME, + CONF_PORT, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, +) import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -CONF_IPADDRESS = 'ipaddress' -CONF_TSTATS = 'tstats' +CONF_THERMOSTATS = "tstats" -TSTATS_SCHEMA = vol.Schema({ - vol.Required(CONF_ID): cv.string, - vol.Required(CONF_NAME): cv.string, -}) +TSTATS_SCHEMA = vol.Schema( + vol.All( + cv.ensure_list, + [{vol.Required(CONF_ID): cv.positive_int, 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}), -}) +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PORT): cv.string, + vol.Optional(CONF_THERMOSTATS, default=[]): 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) + heatmiser_v3_thermostat = heatmiser.HeatmiserThermostat - serport = connection.connection(ipaddress, port) - serport.open() + host = config[CONF_HOST] + port = config[CONF_PORT] - for tstat in tstats.values(): - add_entities([ - HeatmiserV3Thermostat( - heatmiser, tstat.get(CONF_ID), tstat.get(CONF_NAME), serport) - ]) + thermostats = config[CONF_THERMOSTATS] + uh1_hub = connection.HeatmiserUH1(host, port) -class HeatmiserV3Thermostat(ClimateDevice): + add_entities( + [ + HeatmiserV3Thermostat(heatmiser_v3_thermostat, thermostat, uh1_hub) + for thermostat in thermostats + ], + True, + ) + + +class HeatmiserV3Thermostat(ClimateEntity): """Representation of a HeatmiserV3 thermostat.""" - def __init__(self, heatmiser, device, name, serport): + def __init__(self, therm, device, uh1): """Initialize the thermostat.""" - self.heatmiser = heatmiser - self.serport = serport + self.therm = therm(device[CONF_ID], "prt", uh1) + self.uh1 = uh1 + self._name = device[CONF_NAME] self._current_temperature = None - self._name = name + self._target_temperature = None self._id = device self.dcb = None - self.update() - self._target_temperature = int(self.dcb.get('roomset')) + self._hvac_mode = HVAC_MODE_HEAT + self._temperature_unit = None @property def supported_features(self): @@ -73,18 +92,27 @@ def name(self): @property def temperature_unit(self): """Return the unit of measurement which this thermostat uses.""" - return TEMP_CELSIUS + return self._temperature_unit + + @property + def hvac_mode(self) -> str: + """Return hvac operation ie. heat, cool mode. + + Need to be one of HVAC_MODE_*. + """ + return self._hvac_mode + + @property + def hvac_modes(self) -> List[str]: + """Return the list of available hvac operation modes. + + Need to be a subset of HVAC_MODES. + """ + return [HVAC_MODE_HEAT, HVAC_MODE_OFF] @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 @@ -95,16 +123,25 @@ def target_temperature(self): 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 + self._target_temperature = int(temperature) + self.therm.set_target_temp(self._target_temperature) def update(self): """Get the latest data.""" - self.dcb = self.heatmiser.hmReadAddress(self._id, 'prt', self.serport) + self.uh1.reopen() + if not self.uh1.status: + _LOGGER.error("Failed to update device %s", self._name) + return + self.dcb = self.therm.read_dcb() + self._temperature_unit = ( + TEMP_CELSIUS + if (self.therm.get_temperature_format() == "C") + else TEMP_FAHRENHEIT + ) + self._current_temperature = int(self.therm.get_floor_temp()) + self._target_temperature = int(self.therm.get_target_temp()) + self._hvac_mode = ( + HVAC_MODE_OFF + if (int(self.therm.get_current_state()) == 0) + else HVAC_MODE_HEAT + ) diff --git a/homeassistant/components/heatmiser/manifest.json b/homeassistant/components/heatmiser/manifest.json index 0a11aecd079d9..065cfc9f6a2e6 100644 --- a/homeassistant/components/heatmiser/manifest.json +++ b/homeassistant/components/heatmiser/manifest.json @@ -1,10 +1,7 @@ { "domain": "heatmiser", "name": "Heatmiser", - "documentation": "https://www.home-assistant.io/components/heatmiser", - "requirements": [ - "heatmiserV3==0.9.1" - ], - "dependencies": [], - "codeowners": [] + "documentation": "https://www.home-assistant.io/integrations/heatmiser", + "requirements": ["heatmiserV3==1.1.18"], + "codeowners": ["@andylockran"] } diff --git a/homeassistant/components/heos/.translations/ca.json b/homeassistant/components/heos/.translations/ca.json deleted file mode 100644 index 05d95116b10f6..0000000000000 --- a/homeassistant/components/heos/.translations/ca.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "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 deleted file mode 100644 index fac6458c5b8a8..0000000000000 --- a/homeassistant/components/heos/.translations/cs.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "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 deleted file mode 100644 index e8f4df930dbe9..0000000000000 --- a/homeassistant/components/heos/.translations/de.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "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 deleted file mode 100644 index 6d4d83192c754..0000000000000 --- a/homeassistant/components/heos/.translations/en.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "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 deleted file mode 100644 index 12ed8cc457a5d..0000000000000 --- a/homeassistant/components/heos/.translations/es-419.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "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 deleted file mode 100644 index da5d5e0ab89ee..0000000000000 --- a/homeassistant/components/heos/.translations/es.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "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 deleted file mode 100644 index 274075af7499b..0000000000000 --- a/homeassistant/components/heos/.translations/fr.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "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" - }, - "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/it.json b/homeassistant/components/heos/.translations/it.json deleted file mode 100644 index 32667d0dbe8e4..0000000000000 --- a/homeassistant/components/heos/.translations/it.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "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 deleted file mode 100644 index 9237800bf482f..0000000000000 --- a/homeassistant/components/heos/.translations/ko.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "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 deleted file mode 100644 index 416f0878de46a..0000000000000 --- a/homeassistant/components/heos/.translations/lb.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "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/nn.json b/homeassistant/components/heos/.translations/nn.json deleted file mode 100644 index ec2dc29450011..0000000000000 --- a/homeassistant/components/heos/.translations/nn.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "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 deleted file mode 100644 index 144b08c066363..0000000000000 --- a/homeassistant/components/heos/.translations/no.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "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 deleted file mode 100644 index 9b5f9844ddc9e..0000000000000 --- a/homeassistant/components/heos/.translations/pl.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "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.json b/homeassistant/components/heos/.translations/pt.json deleted file mode 100644 index 33c83fdc738af..0000000000000 --- a/homeassistant/components/heos/.translations/pt.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "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 deleted file mode 100644 index f19b5e5206433..0000000000000 --- a/homeassistant/components/heos/.translations/ru.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "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 deleted file mode 100644 index 2978d2bbbe6f7..0000000000000 --- a/homeassistant/components/heos/.translations/sl.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "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 deleted file mode 100644 index d36ad203438dd..0000000000000 --- a/homeassistant/components/heos/.translations/sv.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "config": { - "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 deleted file mode 100644 index 8e49922709c43..0000000000000 --- a/homeassistant/components/heos/.translations/zh-Hant.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "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 index 6585393d12e9a..53c65a6ab07bb 100644 --- a/homeassistant/components/heos/__init__.py +++ b/homeassistant/components/heos/__init__.py @@ -4,10 +4,10 @@ import logging from typing import Dict +from pyheos import Heos, HeosError, const as heos_const import voluptuous as vol -from homeassistant.components.media_player.const import ( - DOMAIN as MEDIA_PLAYER_DOMAIN) +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 @@ -18,14 +18,17 @@ 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) + 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) @@ -42,22 +45,22 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType): # Create new entry based on config hass.async_create_task( hass.config_entries.flow.async_init( - DOMAIN, context={'source': 'import'}, - data={CONF_HOST: host})) + 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) + hass.config_entries.async_update_entry( + entry, title=format_title(host), data={**entry.data, CONF_HOST: host} + ) return True async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry): """Initialize config entry which represents the HEOS controller.""" - from pyheos import Heos, CommandError 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 @@ -65,7 +68,7 @@ async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry): try: await controller.connect(auto_reconnect=True) # Auto reconnect only operates if initial connection was successful. - except (asyncio.TimeoutError, ConnectionError, CommandError) as error: + except HeosError as error: await controller.disconnect() _LOGGER.debug("Unable to connect to controller %s: %s", host, error) raise ConfigEntryNotReady @@ -73,6 +76,7 @@ async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry): # 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 @@ -83,14 +87,14 @@ async def disconnect_controller(event): 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) + "%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: + except HeosError as error: await controller.disconnect() - _LOGGER.debug("Unable to retrieve players and sources: %s", error, - exc_info=isinstance(error, CommandError)) + _LOGGER.debug("Unable to retrieve players and sources: %s", error) raise ConfigEntryNotReady controller_manager = ControllerManager(hass, controller) @@ -102,13 +106,14 @@ async def disconnect_controller(event): hass.data[DOMAIN] = { DATA_CONTROLLER_MANAGER: controller_manager, DATA_SOURCE_MANAGER: source_manager, - MEDIA_PLAYER_DOMAIN: players + MEDIA_PLAYER_DOMAIN: players, } services.register(hass, controller) - hass.async_create_task(hass.config_entries.async_forward_entry_setup( - entry, MEDIA_PLAYER_DOMAIN)) + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, MEDIA_PLAYER_DOMAIN) + ) return True @@ -121,7 +126,8 @@ async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry): services.remove(hass) return await hass.config_entries.async_forward_entry_unload( - entry, MEDIA_PLAYER_DOMAIN) + entry, MEDIA_PLAYER_DOMAIN + ) class ControllerManager: @@ -137,16 +143,22 @@ def __init__(self, hass, controller): async def connect_listeners(self): """Subscribe to events of interest.""" - from pyheos import const 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()) + self._hass.helpers.entity_registry.async_get_registry(), + ) # Handle controller events - self._signals.append(self.controller.dispatcher.connect( - const.SIGNAL_CONTROLLER_EVENT, self._controller_event)) + 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( - const.SIGNAL_HEOS_EVENT, self._heos_event)) + self._signals.append( + self.controller.dispatcher.connect( + heos_const.SIGNAL_HEOS_EVENT, self._heos_event + ) + ) async def disconnect(self): """Disconnect subscriptions.""" @@ -158,56 +170,59 @@ async def disconnect(self): async def _controller_event(self, event, data): """Handle controller event.""" - from pyheos import const - if event == const.EVENT_PLAYERS_CHANGED: - self.update_ids(data[const.DATA_MAPPED_IDS]) + 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) + self._hass.helpers.dispatcher.async_dispatcher_send(SIGNAL_HEOS_UPDATED) async def _heos_event(self, event): """Handle connection event.""" - from pyheos import CommandError, const - if event == const.EVENT_CONNECTED: + if event == heos_const.EVENT_CONNECTED: try: # Retrieve latest players and refresh status data = await self.controller.load_players() - self.update_ids(data[const.DATA_MAPPED_IDS]) - except (CommandError, asyncio.TimeoutError, ConnectionError) as ex: + self.update_ids(data[heos_const.DATA_MAPPED_IDS]) + except HeosError as ex: _LOGGER.error("Unable to refresh players: %s", ex) # Update players - self._hass.helpers.dispatcher.async_dispatcher_send( - SIGNAL_HEOS_UPDATED) + 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()) + 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) + 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)) + 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) + 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): + 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 @@ -218,21 +233,32 @@ def __init__(self, favorites, inputs, *, 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([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) + 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) + 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 @@ -241,16 +267,26 @@ async def play_source(self, source: str, player): def get_current_source(self, now_playing_media): """Determine current source from now playing media.""" - from pyheos import const # Match input by input_name:media_id - if now_playing_media.source_id == 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) + 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) + 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): """ @@ -260,7 +296,6 @@ def connect_update(self, hass, controller): physical event therefore throttle it. Retrieving sources immediately after the event may fail so retry. """ - from pyheos import CommandError, const @Throttle(MIN_UPDATE_SOURCES) async def get_sources(): @@ -272,23 +307,23 @@ async def get_sources(): favorites = await controller.get_favorites() inputs = await controller.get_input_sources() return favorites, inputs - except (asyncio.TimeoutError, ConnectionError, CommandError) \ - as error: + except HeosError 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)) + _LOGGER.debug( + "Error retrieving sources and will retry: %s", error + ) await asyncio.sleep(self.retry_delay) else: - _LOGGER.error("Unable to update sources: %s", error, - exc_info=isinstance(error, CommandError)) + _LOGGER.error("Unable to update sources: %s", error) return async def update_sources(event, data=None): - if event in (const.EVENT_SOURCES_CHANGED, - const.EVENT_USER_CHANGED, - const.EVENT_CONNECTED): + 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: @@ -296,10 +331,9 @@ async def update_sources(event, data=None): 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) + hass.helpers.dispatcher.async_dispatcher_send(SIGNAL_HEOS_UPDATED) controller.dispatcher.connect( - const.SIGNAL_CONTROLLER_EVENT, update_sources) - controller.dispatcher.connect( - const.SIGNAL_HEOS_EVENT, update_sources) + 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 index 656058877db9c..91dbc19ac952c 100644 --- a/homeassistant/components/heos/config_flow.py +++ b/homeassistant/components/heos/config_flow.py @@ -1,17 +1,19 @@ """Config flow to configure Heos.""" -import asyncio +from urllib.parse import urlparse +from pyheos import Heos, HeosError import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.components import ssdp +from homeassistant.const import CONF_HOST from .const import DATA_DISCOVERED_HOSTS, DOMAIN def format_title(host: str) -> str: """Format the title for config entries.""" - return "Controller ({})".format(host) + return f"Controller ({host})" @config_entries.HANDLERS.register(DOMAIN) @@ -21,34 +23,30 @@ class HeosFlowHandler(config_entries.ConfigFlow): VERSION = 1 CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH - async def async_step_discovery(self, discovery_info): + async def async_step_ssdp(self, discovery_info): """Handle a discovered Heos device.""" # Store discovered host - friendly_name = "{} ({})".format( - discovery_info[CONF_NAME], discovery_info[CONF_HOST]) + hostname = urlparse(discovery_info[ssdp.ATTR_SSDP_LOCATION]).hostname + friendly_name = f"{discovery_info[ssdp.ATTR_UPNP_FRIENDLY_NAME]} ({hostname})" self.hass.data.setdefault(DATA_DISCOVERED_HOSTS, {}) - self.hass.data[DATA_DISCOVERED_HOSTS][friendly_name] \ - = discovery_info[CONF_HOST] + self.hass.data[DATA_DISCOVERED_HOSTS][friendly_name] = hostname # 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') + return self.async_abort(reason="already_setup") # Show selection form - return self.async_show_form(step_id='user') + 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}) + 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.""" - from pyheos import Heos 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') + return self.async_abort(reason="already_setup") # Try connecting to host if provided errors = {} host = None @@ -61,17 +59,19 @@ async def async_step_user(self, user_input=None): 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' + except HeosError: + errors[CONF_HOST] = "connection_failure" finally: await heos.disconnect() # Return form - host_type = str if not self.hass.data[DATA_DISCOVERED_HOSTS] \ + 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) + 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 index d3e3ccb07c388..503df40ccd498 100644 --- a/homeassistant/components/heos/const.py +++ b/homeassistant/components/heos/const.py @@ -7,7 +7,7 @@ DATA_CONTROLLER_MANAGER = "controller" DATA_SOURCE_MANAGER = "source_manager" DATA_DISCOVERED_HOSTS = "heos_discovered_hosts" -DOMAIN = 'heos' +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 index f3a2ff4eccffb..a6da3623da730 100644 --- a/homeassistant/components/heos/manifest.json +++ b/homeassistant/components/heos/manifest.json @@ -1,12 +1,13 @@ { "domain": "heos", - "name": "HEOS", - "documentation": "https://www.home-assistant.io/components/heos", - "requirements": [ - "pyheos==0.5.2" + "name": "Denon HEOS", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/heos", + "requirements": ["pyheos==0.6.0"], + "ssdp": [ + { + "st": "urn:schemas-denon-com:device:ACT-Denon:1" + } ], - "dependencies": [], - "codeowners": [ - "@andrewsayre" - ] + "codeowners": ["@andrewsayre"] } diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py index 00a3b721efbc5..7e827c96f5568 100644 --- a/homeassistant/components/heos/media_player.py +++ b/homeassistant/components/heos/media_player.py @@ -1,41 +1,68 @@ """Denon HEOS Media Player.""" -import asyncio from functools import reduce, wraps import logging from operator import ior from typing import Sequence -from homeassistant.components.media_player import MediaPlayerDevice +from pyheos import HeosError, const as heos_const + +from homeassistant.components.media_player import MediaPlayerEntity 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) + 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 +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): +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()] @@ -44,49 +71,36 @@ async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry, def log_command_error(command: str): """Return decorator that logs command failure.""" + def decorator(func): @wraps(func) async def wrapper(*args, **kwargs): - from pyheos import CommandError try: await func(*args, **kwargs) - except (CommandError, asyncio.TimeoutError, ConnectionError, - ValueError) as ex: + except (HeosError, ValueError) as ex: _LOGGER.error("Unable to %s: %s", command, ex) + return wrapper + return decorator -class HeosMediaPlayer(MediaPlayerDevice): +class HeosMediaPlayer(MediaPlayerEntity): """The HEOS player.""" def __init__(self, player): """Initialize.""" - from pyheos import const self._media_position_updated_at = None self._player = player self._signals = [] self._supported_features = BASE_SUPPORTED_FEATURES self._source_manager = None - self._play_state_to_state = { - const.PLAY_STATE_PLAY: STATE_PLAYING, - const.PLAY_STATE_STOP: STATE_IDLE, - const.PLAY_STATE_PAUSE: STATE_PAUSED - } - self._control_to_support = { - const.CONTROL_PLAY: SUPPORT_PLAY, - const.CONTROL_PAUSE: SUPPORT_PAUSE, - const.CONTROL_STOP: SUPPORT_STOP, - const.CONTROL_PLAY_PREVIOUS: SUPPORT_PREVIOUS_TRACK, - const.CONTROL_PLAY_NEXT: SUPPORT_NEXT_TRACK - } async def _player_update(self, player_id, event): """Handle player attribute updated.""" - from pyheos import const if self._player.player_id != player_id: return - if event == const.EVENT_PLAYER_NOW_PLAYING_PROGRESS: + if event == heos_const.EVENT_PLAYER_NOW_PLAYING_PROGRESS: self._media_position_updated_at = utcnow() await self.async_update_ha_state(True) @@ -96,15 +110,18 @@ async def _heos_updated(self): async def async_added_to_hass(self): """Device added to hass.""" - from pyheos import const - 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( - const.SIGNAL_PLAYER_EVENT, self._player_update)) + 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)) + SIGNAL_HEOS_UPDATED, self._heos_updated + ) + ) @log_command_error("clear playlist") async def async_clear_playlist(self): @@ -155,22 +172,25 @@ async def async_play_media(self, media_type, media_id, **kwargs): 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) + 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)) + raise ValueError(f"Invalid quick select '{media_id}'") await self._player.play_quick_select(index) return if media_type == MEDIA_TYPE_PLAYLIST: - from pyheos import const 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 = const.ADD_QUEUE_ADD_TO_END \ - if kwargs.get(ATTR_MEDIA_ENQUEUE) \ - else const.ADD_QUEUE_REPLACE_AND_PLAY + raise ValueError(f"Invalid playlist '{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 @@ -180,15 +200,20 @@ async def async_play_media(self, media_type, media_id, **kwargs): 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) + 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)) + raise ValueError(f"Invalid favorite '{media_id}'") await self._player.play_favorite(index) return - raise ValueError("Unsupported media type '{}'".format(media_type)) + raise ValueError(f"Unsupported media type '{media_type}'") @log_command_error("select source") async def async_select_source(self, source): @@ -208,10 +233,11 @@ async def async_set_volume_level(self, volume): async def async_update(self): """Update supported features of the player.""" controls = self._player.now_playing_media.supported_controls - current_support = [self._control_to_support[control] - for control in controls] - self._supported_features = reduce(ior, current_support, - BASE_SUPPORTED_FEATURES) + current_support = [CONTROL_TO_SUPPORT[control] for control in controls] + self._supported_features = reduce(ior, current_support, BASE_SUPPORTED_FEATURES) + + if self._source_manager is None: + self._source_manager = self.hass.data[HEOS_DOMAIN][DATA_SOURCE_MANAGER] async def async_will_remove_from_hass(self): """Disconnect the device when removed.""" @@ -228,24 +254,22 @@ def available(self) -> bool: 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 + "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 + "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 @@ -332,8 +356,7 @@ def shuffle(self) -> bool: @property def source(self) -> str: """Name of the current input source.""" - return self._source_manager.get_current_source( - self._player.now_playing_media) + return self._source_manager.get_current_source(self._player.now_playing_media) @property def source_list(self) -> Sequence[str]: @@ -343,7 +366,7 @@ def source_list(self) -> Sequence[str]: @property def state(self) -> str: """State of the player.""" - return self._play_state_to_state[self._player.state] + return PLAY_STATE_TO_STATE[self._player.state] @property def supported_features(self) -> int: diff --git a/homeassistant/components/heos/services.py b/homeassistant/components/heos/services.py index 5b998f384dc99..ee5df1b483b55 100644 --- a/homeassistant/components/heos/services.py +++ b/homeassistant/components/heos/services.py @@ -1,23 +1,26 @@ """Services for the HEOS integration.""" -import asyncio import functools import logging -from pyheos import CommandError, Heos, const +from pyheos import CommandFailedError, Heos, HeosError, 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) + 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_IN_SCHEMA = vol.Schema( + {vol.Required(ATTR_USERNAME): cv.string, vol.Required(ATTR_PASSWORD): cv.string} +) HEOS_SIGN_OUT_SCHEMA = vol.Schema({}) @@ -25,13 +28,17 @@ def register(hass: HomeAssistantType, controller: Heos): """Register HEOS services.""" hass.services.async_register( - DOMAIN, SERVICE_SIGN_IN, + DOMAIN, + SERVICE_SIGN_IN, functools.partial(_sign_in_handler, controller), - schema=HEOS_SIGN_IN_SCHEMA) + schema=HEOS_SIGN_IN_SCHEMA, + ) hass.services.async_register( - DOMAIN, SERVICE_SIGN_OUT, + DOMAIN, + SERVICE_SIGN_OUT, functools.partial(_sign_out_handler, controller), - schema=HEOS_SIGN_OUT_SCHEMA) + schema=HEOS_SIGN_OUT_SCHEMA, + ) def remove(hass: HomeAssistantType): @@ -49,9 +56,9 @@ async def _sign_in_handler(controller, service): password = service.data[ATTR_PASSWORD] try: await controller.sign_in(username, password) - except CommandError as err: + except CommandFailedError as err: _LOGGER.error("Sign in failed: %s", err) - except (asyncio.TimeoutError, ConnectionError) as err: + except HeosError as err: _LOGGER.error("Unable to sign in: %s", err) @@ -62,5 +69,5 @@ async def _sign_out_handler(controller, service): return try: await controller.sign_out() - except (asyncio.TimeoutError, ConnectionError, CommandError) as err: + except HeosError as err: _LOGGER.error("Unable to sign out: %s", err) diff --git a/homeassistant/components/heos/services.yaml b/homeassistant/components/heos/services.yaml index 8274240368f80..0fe0518323f55 100644 --- a/homeassistant/components/heos/services.yaml +++ b/homeassistant/components/heos/services.yaml @@ -3,10 +3,10 @@ sign_in: fields: username: description: The username or email of the HEOS account. [Required] - example: 'example@example.com' + example: "example@example.com" password: description: The password of the HEOS account. [Required] - example: 'password' + example: "password" sign_out: - description: Sign the controller out of the HEOS account. \ No newline at end of file + description: Sign the controller out of the HEOS account. diff --git a/homeassistant/components/heos/strings.json b/homeassistant/components/heos/strings.json index b210e0ba87f2c..383afad1b963d 100644 --- a/homeassistant/components/heos/strings.json +++ b/homeassistant/components/heos/strings.json @@ -1,13 +1,10 @@ { "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" - } + "data": { "access_token": "Host", "host": "Host" } } }, "error": { @@ -17,4 +14,4 @@ "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/heos/translations/bg.json b/homeassistant/components/heos/translations/bg.json new file mode 100644 index 0000000000000..4f52830af097a --- /dev/null +++ b/homeassistant/components/heos/translations/bg.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_setup": "\u041c\u043e\u0436\u0435\u0442\u0435 \u0434\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u0442\u0435 \u0441\u0430\u043c\u043e \u0435\u0434\u043d\u0430 Heos \u0432\u0440\u044a\u0437\u043a\u0430, \u0442\u044a\u0439 \u043a\u0430\u0442\u043e \u0442\u044f \u0449\u0435 \u043f\u043e\u0434\u0434\u044a\u0440\u0436\u0430 \u0432\u0441\u0438\u0447\u043a\u0438 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0432 \u043c\u0440\u0435\u0436\u0430\u0442\u0430." + }, + "error": { + "connection_failure": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435 \u043d\u0430 \u043f\u043e\u0441\u043e\u0447\u0435\u043d\u0438\u044f \u0430\u0434\u0440\u0435\u0441." + }, + "step": { + "user": { + "data": { + "access_token": "\u0410\u0434\u0440\u0435\u0441", + "host": "\u0410\u0434\u0440\u0435\u0441" + }, + "description": "\u041c\u043e\u043b\u044f, \u0432\u044a\u0432\u0435\u0434\u0435\u0442\u0435 \u0438\u043c\u0435\u0442\u043e \u043d\u0430 \u0445\u043e\u0441\u0442\u0430 \u0438\u043b\u0438 IP \u0430\u0434\u0440\u0435\u0441\u0430 \u043d\u0430 Heos \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e (\u0437\u0430 \u043f\u0440\u0435\u0434\u043f\u043e\u0447\u0438\u0442\u0430\u043d\u0435 \u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0434\u0430 \u0435 \u0441\u0432\u044a\u0440\u0437\u0430\u043d\u043e \u0441 \u043a\u0430\u0431\u0435\u043b \u043a\u044a\u043c \u043c\u0440\u0435\u0436\u0430\u0442\u0430).", + "title": "\u0421\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435 \u0441 Heos" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/heos/translations/ca.json b/homeassistant/components/heos/translations/ca.json new file mode 100644 index 0000000000000..02e8e22d9209c --- /dev/null +++ b/homeassistant/components/heos/translations/ca.json @@ -0,0 +1,20 @@ +{ + "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 s'ha pogut connectar amb l'amfitri\u00f3 especificat." + }, + "step": { + "user": { + "data": { + "access_token": "Amfitri\u00f3", + "host": "Amfitri\u00f3" + }, + "description": "Introdueix el nom de l'amfitri\u00f3 o l'adre\u00e7a IP d'un dispositiu Heos (preferiblement un connectat a la xarxa per cable).", + "title": "Connexi\u00f3 amb 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..f77038cb8b6cd --- /dev/null +++ b/homeassistant/components/heos/translations/cs.json @@ -0,0 +1,3 @@ +{ + "title": "HEOS" +} \ No newline at end of file diff --git a/homeassistant/components/heos/translations/da.json b/homeassistant/components/heos/translations/da.json new file mode 100644 index 0000000000000..b395497d67a59 --- /dev/null +++ b/homeassistant/components/heos/translations/da.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_setup": "Du kan kun konfigurere en enkelt Heos-forbindelse, da den underst\u00f8tter alle enheder p\u00e5 netv\u00e6rket." + }, + "error": { + "connection_failure": "Kunne ikke oprette forbindelse til den angivne v\u00e6rt." + }, + "step": { + "user": { + "data": { + "access_token": "V\u00e6rt", + "host": "V\u00e6rt" + }, + "description": "Indtast v\u00e6rtsnavnet eller IP-adressen p\u00e5 en Heos-enhed (helst en tilsluttet via ledning til netv\u00e6rket).", + "title": "Opret forbindelse til 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..bbd0d8beec711 --- /dev/null +++ b/homeassistant/components/heos/translations/de.json @@ -0,0 +1,20 @@ +{ + "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" + } + } + } +} \ 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..3227e5115f961 --- /dev/null +++ b/homeassistant/components/heos/translations/en.json @@ -0,0 +1,20 @@ +{ + "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" + } + } + } +} \ 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..01338dc5af3f8 --- /dev/null +++ b/homeassistant/components/heos/translations/es-419.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "already_setup": "Solo puede configurar una sola conexi\u00f3n Heos, ya que ser\u00e1 compatible con todos los dispositivos de la red." + }, + "error": { + "connection_failure": "No se puede conectar con el host especificado." + }, + "step": { + "user": { + "description": "Ingrese el nombre de host o la direcci\u00f3n IP de un dispositivo Heos (preferiblemente uno conectado por cable a la red).", + "title": "Con\u00e9ctate a 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..b79871d487c7c --- /dev/null +++ b/homeassistant/components/heos/translations/es.json @@ -0,0 +1,20 @@ +{ + "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" + } + } + } +} \ 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..7f76c932c7102 --- /dev/null +++ b/homeassistant/components/heos/translations/fr.json @@ -0,0 +1,20 @@ +{ + "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" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/heos/.translations/hu.json b/homeassistant/components/heos/translations/hu.json similarity index 100% rename from homeassistant/components/heos/.translations/hu.json rename to homeassistant/components/heos/translations/hu.json diff --git a/homeassistant/components/heos/translations/it.json b/homeassistant/components/heos/translations/it.json new file mode 100644 index 0000000000000..2e6cfab035b4a --- /dev/null +++ b/homeassistant/components/heos/translations/it.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_setup": "\u00c8 possibile configurare una singola connessione Heos poich\u00e9 supporta tutti i dispositivi sulla rete." + }, + "error": { + "connection_failure": "Impossibile connettersi all'host specificato." + }, + "step": { + "user": { + "data": { + "access_token": "Host", + "host": "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" + } + } + } +} \ 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..1e7902adfb33b --- /dev/null +++ b/homeassistant/components/heos/translations/ko.json @@ -0,0 +1,20 @@ +{ + "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 \uc5d0 \uc5f0\uacb0\ud558\uae30" + } + } + } +} \ 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..de124207d64e9 --- /dev/null +++ b/homeassistant/components/heos/translations/lb.json @@ -0,0 +1,20 @@ +{ + "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" + } + } + } +} \ 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..f3e9dfed7e32f --- /dev/null +++ b/homeassistant/components/heos/translations/nl.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_setup": "U kunt alleen een enkele Heos-verbinding configureren, omdat deze alle apparaten in het netwerk ondersteunt." + }, + "error": { + "connection_failure": "Kan geen verbinding maken met de opgegeven host." + }, + "step": { + "user": { + "data": { + "access_token": "Host", + "host": "Host" + }, + "description": "Voer de hostnaam of het IP-adres van een Heos-apparaat in (bij voorkeur een die via een kabel is verbonden met het netwerk).", + "title": "Verbinding maken met 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..8703148b3f852 --- /dev/null +++ b/homeassistant/components/heos/translations/nn.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "access_token": "Vert" + } + } + } + } +} \ 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..25588d79e01d4 --- /dev/null +++ b/homeassistant/components/heos/translations/no.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_setup": "Du kan kun konfigurere en 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" + } + } + } +} \ 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..0c0b9ade13f2b --- /dev/null +++ b/homeassistant/components/heos/translations/pl.json @@ -0,0 +1,20 @@ +{ + "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": "Nazwa hosta lub adres IP", + "host": "Nazwa hosta lub adres IP" + }, + "description": "Wprowad\u017a nazw\u0119 hosta lub adres IP urz\u0105dzenia Heos (najlepiej pod\u0142\u0105czonego przewodowo do sieci).", + "title": "Po\u0142\u0105czenie z 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..abacf5c8ca1da --- /dev/null +++ b/homeassistant/components/heos/translations/pt-BR.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_setup": "Voc\u00ea s\u00f3 pode configurar uma \u00fanica conex\u00e3o Heos, pois ela suportar\u00e1 todos os dispositivos na rede." + }, + "error": { + "connection_failure": "N\u00e3o \u00e9 poss\u00edvel conectar-se ao host especificado." + }, + "step": { + "user": { + "data": { + "access_token": "Host", + "host": "Host" + }, + "description": "Por favor, digite o nome do host ou o endere\u00e7o IP de um dispositivo Heos (de prefer\u00eancia para conex\u00f5es conectadas por cabo \u00e0 sua rede).", + "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..d0c219cefa9fd --- /dev/null +++ b/homeassistant/components/heos/translations/pt.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "access_token": "Servidor", + "host": "Servidor" + } + } + } + } +} \ 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..9983242b34961 --- /dev/null +++ b/homeassistant/components/heos/translations/ru.json @@ -0,0 +1,20 @@ +{ + "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" + } + } + } +} \ 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..76fe3aadc5d5e --- /dev/null +++ b/homeassistant/components/heos/translations/sl.json @@ -0,0 +1,20 @@ +{ + "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" + } + } + } +} \ 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..8215388a1610c --- /dev/null +++ b/homeassistant/components/heos/translations/sv.json @@ -0,0 +1,20 @@ +{ + "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" + } + } + } +} \ 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..01ca002dd54e8 --- /dev/null +++ b/homeassistant/components/heos/translations/zh-Hant.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_setup": "\u50c5\u80fd\u8a2d\u5b9a\u4e00\u7d44 Heos \u9023\u7dda\uff0c\u5c07\u652f\u63f4\u7db2\u8def\u4e2d\u6240\u6709\u5c0d\u61c9\u8a2d\u5099\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 \u8a2d\u5099 IP \u4f4d\u5740\uff08\u5df2\u900f\u904e\u6709\u7dda\u7db2\u8def\u9023\u7dda\uff09\u3002", + "title": "\u9023\u7dda\u81f3 Heos" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/here_travel_time/__init__.py b/homeassistant/components/here_travel_time/__init__.py new file mode 100644 index 0000000000000..9a5c8ec32aca4 --- /dev/null +++ b/homeassistant/components/here_travel_time/__init__.py @@ -0,0 +1 @@ +"""The here_travel_time component.""" diff --git a/homeassistant/components/here_travel_time/manifest.json b/homeassistant/components/here_travel_time/manifest.json new file mode 100644 index 0000000000000..151211eef795f --- /dev/null +++ b/homeassistant/components/here_travel_time/manifest.json @@ -0,0 +1,7 @@ +{ + "domain": "here_travel_time", + "name": "HERE Travel Time", + "documentation": "https://www.home-assistant.io/integrations/here_travel_time", + "requirements": ["herepy==2.0.0"], + "codeowners": ["@eifinger"] +} diff --git a/homeassistant/components/here_travel_time/sensor.py b/homeassistant/components/here_travel_time/sensor.py new file mode 100644 index 0000000000000..f73d3bccaa66c --- /dev/null +++ b/homeassistant/components/here_travel_time/sensor.py @@ -0,0 +1,506 @@ +"""Support for HERE travel time sensors.""" +from datetime import datetime, timedelta +import logging +from typing import Callable, Dict, Optional, Union + +import herepy +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + ATTR_ATTRIBUTION, + ATTR_LATITUDE, + ATTR_LONGITUDE, + ATTR_MODE, + CONF_MODE, + CONF_NAME, + CONF_UNIT_SYSTEM, + CONF_UNIT_SYSTEM_IMPERIAL, + CONF_UNIT_SYSTEM_METRIC, + EVENT_HOMEASSISTANT_START, + TIME_MINUTES, +) +from homeassistant.core import HomeAssistant, State, callback +from homeassistant.helpers import location +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.typing import DiscoveryInfoType +import homeassistant.util.dt as dt + +_LOGGER = logging.getLogger(__name__) + +CONF_DESTINATION_LATITUDE = "destination_latitude" +CONF_DESTINATION_LONGITUDE = "destination_longitude" +CONF_DESTINATION_ENTITY_ID = "destination_entity_id" +CONF_ORIGIN_LATITUDE = "origin_latitude" +CONF_ORIGIN_LONGITUDE = "origin_longitude" +CONF_ORIGIN_ENTITY_ID = "origin_entity_id" +CONF_API_KEY = "api_key" +CONF_TRAFFIC_MODE = "traffic_mode" +CONF_ROUTE_MODE = "route_mode" +CONF_ARRIVAL = "arrival" +CONF_DEPARTURE = "departure" + +DEFAULT_NAME = "HERE Travel Time" + +TRAVEL_MODE_BICYCLE = "bicycle" +TRAVEL_MODE_CAR = "car" +TRAVEL_MODE_PEDESTRIAN = "pedestrian" +TRAVEL_MODE_PUBLIC = "publicTransport" +TRAVEL_MODE_PUBLIC_TIME_TABLE = "publicTransportTimeTable" +TRAVEL_MODE_TRUCK = "truck" +TRAVEL_MODE = [ + TRAVEL_MODE_BICYCLE, + TRAVEL_MODE_CAR, + TRAVEL_MODE_PEDESTRIAN, + TRAVEL_MODE_PUBLIC, + TRAVEL_MODE_PUBLIC_TIME_TABLE, + TRAVEL_MODE_TRUCK, +] + +TRAVEL_MODES_PUBLIC = [TRAVEL_MODE_PUBLIC, TRAVEL_MODE_PUBLIC_TIME_TABLE] +TRAVEL_MODES_VEHICLE = [TRAVEL_MODE_CAR, TRAVEL_MODE_TRUCK] +TRAVEL_MODES_NON_VEHICLE = [TRAVEL_MODE_BICYCLE, TRAVEL_MODE_PEDESTRIAN] + +TRAFFIC_MODE_ENABLED = "traffic_enabled" +TRAFFIC_MODE_DISABLED = "traffic_disabled" + +ROUTE_MODE_FASTEST = "fastest" +ROUTE_MODE_SHORTEST = "shortest" +ROUTE_MODE = [ROUTE_MODE_FASTEST, ROUTE_MODE_SHORTEST] + +ICON_BICYCLE = "mdi:bike" +ICON_CAR = "mdi:car" +ICON_PEDESTRIAN = "mdi:walk" +ICON_PUBLIC = "mdi:bus" +ICON_TRUCK = "mdi:truck" + +UNITS = [CONF_UNIT_SYSTEM_METRIC, CONF_UNIT_SYSTEM_IMPERIAL] + +ATTR_DURATION = "duration" +ATTR_DISTANCE = "distance" +ATTR_ROUTE = "route" +ATTR_ORIGIN = "origin" +ATTR_DESTINATION = "destination" + +ATTR_UNIT_SYSTEM = CONF_UNIT_SYSTEM +ATTR_TRAFFIC_MODE = CONF_TRAFFIC_MODE + +ATTR_DURATION_IN_TRAFFIC = "duration_in_traffic" +ATTR_ORIGIN_NAME = "origin_name" +ATTR_DESTINATION_NAME = "destination_name" + +SCAN_INTERVAL = timedelta(minutes=5) + +NO_ROUTE_ERROR_MESSAGE = "HERE could not find a route based on the input" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_API_KEY): cv.string, + vol.Inclusive( + CONF_DESTINATION_LATITUDE, "destination_coordinates" + ): cv.latitude, + vol.Inclusive( + CONF_DESTINATION_LONGITUDE, "destination_coordinates" + ): cv.longitude, + vol.Exclusive(CONF_DESTINATION_LATITUDE, "destination"): cv.latitude, + vol.Exclusive(CONF_DESTINATION_ENTITY_ID, "destination"): cv.entity_id, + vol.Inclusive(CONF_ORIGIN_LATITUDE, "origin_coordinates"): cv.latitude, + vol.Inclusive(CONF_ORIGIN_LONGITUDE, "origin_coordinates"): cv.longitude, + vol.Exclusive(CONF_ORIGIN_LATITUDE, "origin"): cv.latitude, + vol.Exclusive(CONF_ORIGIN_ENTITY_ID, "origin"): cv.entity_id, + vol.Optional(CONF_DEPARTURE): cv.time, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_MODE, default=TRAVEL_MODE_CAR): vol.In(TRAVEL_MODE), + vol.Optional(CONF_ROUTE_MODE, default=ROUTE_MODE_FASTEST): vol.In(ROUTE_MODE), + vol.Optional(CONF_TRAFFIC_MODE, default=False): cv.boolean, + vol.Optional(CONF_UNIT_SYSTEM): vol.In(UNITS), + } +) + +PLATFORM_SCHEMA = vol.All( + cv.has_at_least_one_key(CONF_DESTINATION_LATITUDE, CONF_DESTINATION_ENTITY_ID), + cv.has_at_least_one_key(CONF_ORIGIN_LATITUDE, CONF_ORIGIN_ENTITY_ID), + cv.key_value_schemas( + CONF_MODE, + { + None: PLATFORM_SCHEMA, + TRAVEL_MODE_BICYCLE: PLATFORM_SCHEMA, + TRAVEL_MODE_CAR: PLATFORM_SCHEMA, + TRAVEL_MODE_PEDESTRIAN: PLATFORM_SCHEMA, + TRAVEL_MODE_PUBLIC: PLATFORM_SCHEMA, + TRAVEL_MODE_TRUCK: PLATFORM_SCHEMA, + TRAVEL_MODE_PUBLIC_TIME_TABLE: PLATFORM_SCHEMA.extend( + { + vol.Exclusive(CONF_ARRIVAL, "arrival_departure"): cv.time, + vol.Exclusive(CONF_DEPARTURE, "arrival_departure"): cv.time, + } + ), + }, + ), +) + + +async def async_setup_platform( + hass: HomeAssistant, + config: Dict[str, Union[str, bool]], + async_add_entities: Callable, + discovery_info: Optional[DiscoveryInfoType] = None, +) -> None: + """Set up the HERE travel time platform.""" + + api_key = config[CONF_API_KEY] + here_client = herepy.RoutingApi(api_key) + + if not await hass.async_add_executor_job( + _are_valid_client_credentials, here_client + ): + _LOGGER.error( + "Invalid credentials. This error is returned if the specified token was invalid or no contract could be found for this token." + ) + return + + if config.get(CONF_ORIGIN_LATITUDE) is not None: + origin = f"{config[CONF_ORIGIN_LATITUDE]},{config[CONF_ORIGIN_LONGITUDE]}" + origin_entity_id = None + else: + origin = None + origin_entity_id = config[CONF_ORIGIN_ENTITY_ID] + + if config.get(CONF_DESTINATION_LATITUDE) is not None: + destination = ( + f"{config[CONF_DESTINATION_LATITUDE]},{config[CONF_DESTINATION_LONGITUDE]}" + ) + destination_entity_id = None + else: + destination = None + destination_entity_id = config[CONF_DESTINATION_ENTITY_ID] + + travel_mode = config[CONF_MODE] + traffic_mode = config[CONF_TRAFFIC_MODE] + route_mode = config[CONF_ROUTE_MODE] + name = config[CONF_NAME] + units = config.get(CONF_UNIT_SYSTEM, hass.config.units.name) + arrival = config.get(CONF_ARRIVAL) + departure = config.get(CONF_DEPARTURE) + + here_data = HERETravelTimeData( + here_client, travel_mode, traffic_mode, route_mode, units, arrival, departure + ) + + sensor = HERETravelTimeSensor( + name, origin, destination, origin_entity_id, destination_entity_id, here_data + ) + + async_add_entities([sensor]) + + +def _are_valid_client_credentials(here_client: herepy.RoutingApi) -> bool: + """Check if the provided credentials are correct using defaults.""" + known_working_origin = [38.9, -77.04833] + known_working_destination = [39.0, -77.1] + try: + here_client.car_route( + known_working_origin, + known_working_destination, + [ + herepy.RouteMode[ROUTE_MODE_FASTEST], + herepy.RouteMode[TRAVEL_MODE_CAR], + herepy.RouteMode[TRAFFIC_MODE_DISABLED], + ], + ) + except herepy.InvalidCredentialsError: + return False + return True + + +class HERETravelTimeSensor(Entity): + """Representation of a HERE travel time sensor.""" + + def __init__( + self, + name: str, + origin: str, + destination: str, + origin_entity_id: str, + destination_entity_id: str, + here_data: "HERETravelTimeData", + ) -> None: + """Initialize the sensor.""" + self._name = name + self._origin_entity_id = origin_entity_id + self._destination_entity_id = destination_entity_id + self._here_data = here_data + self._unit_of_measurement = TIME_MINUTES + self._attrs = { + ATTR_UNIT_SYSTEM: self._here_data.units, + ATTR_MODE: self._here_data.travel_mode, + ATTR_TRAFFIC_MODE: self._here_data.traffic_mode, + } + if self._origin_entity_id is None: + self._here_data.origin = origin + + if self._destination_entity_id is None: + self._here_data.destination = destination + + async def async_added_to_hass(self) -> None: + """Delay the sensor update to avoid entity not found warnings.""" + + @callback + def delayed_sensor_update(event): + """Update sensor after Home Assistant started.""" + self.async_schedule_update_ha_state(True) + + self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_START, delayed_sensor_update + ) + + @property + def state(self) -> Optional[str]: + """Return the state of the sensor.""" + if self._here_data.traffic_mode: + if self._here_data.traffic_time is not None: + return str(round(self._here_data.traffic_time / 60)) + if self._here_data.base_time is not None: + return str(round(self._here_data.base_time / 60)) + + return None + + @property + def name(self) -> str: + """Get the name of the sensor.""" + return self._name + + @property + def device_state_attributes( + self, + ) -> Optional[Dict[str, Union[None, float, str, bool]]]: + """Return the state attributes.""" + if self._here_data.base_time is None: + return None + + res = self._attrs + if self._here_data.attribution is not None: + res[ATTR_ATTRIBUTION] = self._here_data.attribution + res[ATTR_DURATION] = self._here_data.base_time / 60 + res[ATTR_DISTANCE] = self._here_data.distance + res[ATTR_ROUTE] = self._here_data.route + res[ATTR_DURATION_IN_TRAFFIC] = self._here_data.traffic_time / 60 + res[ATTR_ORIGIN] = self._here_data.origin + res[ATTR_DESTINATION] = self._here_data.destination + res[ATTR_ORIGIN_NAME] = self._here_data.origin_name + res[ATTR_DESTINATION_NAME] = self._here_data.destination_name + return res + + @property + def unit_of_measurement(self) -> str: + """Return the unit this state is expressed in.""" + return self._unit_of_measurement + + @property + def icon(self) -> str: + """Icon to use in the frontend depending on travel_mode.""" + if self._here_data.travel_mode == TRAVEL_MODE_BICYCLE: + return ICON_BICYCLE + if self._here_data.travel_mode == TRAVEL_MODE_PEDESTRIAN: + return ICON_PEDESTRIAN + if self._here_data.travel_mode in TRAVEL_MODES_PUBLIC: + return ICON_PUBLIC + if self._here_data.travel_mode == TRAVEL_MODE_TRUCK: + return ICON_TRUCK + return ICON_CAR + + async def async_update(self) -> None: + """Update Sensor Information.""" + # Convert device_trackers to HERE friendly location + if self._origin_entity_id is not None: + self._here_data.origin = await self._get_location_from_entity( + self._origin_entity_id + ) + + if self._destination_entity_id is not None: + self._here_data.destination = await self._get_location_from_entity( + self._destination_entity_id + ) + + await self.hass.async_add_executor_job(self._here_data.update) + + async def _get_location_from_entity(self, entity_id: str) -> Optional[str]: + """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) + 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(f"zone.{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) + + # Check if state is valid coordinate set + if self._entity_state_is_valid_coordinate_set(entity.state): + return entity.state + + _LOGGER.error( + "The state of %s is not a valid set of coordinates: %s", + entity_id, + entity.state, + ) + return None + + @staticmethod + def _entity_state_is_valid_coordinate_set(state: str) -> bool: + """Check that the given string is a valid set of coordinates.""" + schema = vol.Schema(cv.gps) + try: + coordinates = state.split(",") + schema(coordinates) + return True + except (vol.MultipleInvalid): + return False + + @staticmethod + def _get_location_from_attributes(entity: State) -> str: + """Get the lat/long string from an entities attributes.""" + attr = entity.attributes + return f"{attr.get(ATTR_LATITUDE)},{attr.get(ATTR_LONGITUDE)}" + + +class HERETravelTimeData: + """HERETravelTime data object.""" + + def __init__( + self, + here_client: herepy.RoutingApi, + travel_mode: str, + traffic_mode: bool, + route_mode: str, + units: str, + arrival: datetime, + departure: datetime, + ) -> None: + """Initialize herepy.""" + self.origin = None + self.destination = None + self.travel_mode = travel_mode + self.traffic_mode = traffic_mode + self.route_mode = route_mode + self.arrival = arrival + self.departure = departure + self.attribution = None + self.traffic_time = None + self.distance = None + self.route = None + self.base_time = None + self.origin_name = None + self.destination_name = None + self.units = units + self._client = here_client + self.combine_change = True + + def update(self) -> None: + """Get the latest data from HERE.""" + if self.traffic_mode: + traffic_mode = TRAFFIC_MODE_ENABLED + else: + traffic_mode = TRAFFIC_MODE_DISABLED + + if self.destination is not None and self.origin is not None: + # Convert location to HERE friendly location + destination = self.destination.split(",") + origin = self.origin.split(",") + arrival = self.arrival + if arrival is not None: + arrival = convert_time_to_isodate(arrival) + departure = self.departure + if departure is not None: + departure = convert_time_to_isodate(departure) + + if departure is None and arrival is None: + departure = "now" + + _LOGGER.debug( + "Requesting route for origin: %s, destination: %s, route_mode: %s, mode: %s, traffic_mode: %s, arrival: %s, departure: %s", + origin, + destination, + herepy.RouteMode[self.route_mode], + herepy.RouteMode[self.travel_mode], + herepy.RouteMode[traffic_mode], + arrival, + departure, + ) + + try: + response = self._client.public_transport_timetable( + origin, + destination, + self.combine_change, + [ + herepy.RouteMode[self.route_mode], + herepy.RouteMode[self.travel_mode], + herepy.RouteMode[traffic_mode], + ], + arrival=arrival, + departure=departure, + ) + except herepy.NoRouteFoundError: + # Better error message for cryptic no route error codes + _LOGGER.error(NO_ROUTE_ERROR_MESSAGE) + return + + _LOGGER.debug("Raw response is: %s", response.response) + + # pylint: disable=no-member + source_attribution = response.response.get("sourceAttribution") + if source_attribution is not None: + self.attribution = self._build_hass_attribution(source_attribution) + # pylint: disable=no-member + route = response.response["route"] + summary = route[0]["summary"] + waypoint = route[0]["waypoint"] + self.base_time = summary["baseTime"] + if self.travel_mode in TRAVEL_MODES_VEHICLE: + self.traffic_time = summary["trafficTime"] + else: + self.traffic_time = self.base_time + distance = summary["distance"] + if self.units == CONF_UNIT_SYSTEM_IMPERIAL: + # Convert to miles. + self.distance = distance / 1609.344 + else: + # Convert to kilometers + self.distance = distance / 1000 + # pylint: disable=no-member + self.route = response.route_short + self.origin_name = waypoint[0]["mappedRoadName"] + self.destination_name = waypoint[1]["mappedRoadName"] + + @staticmethod + def _build_hass_attribution(source_attribution: Dict) -> Optional[str]: + """Build a hass frontend ready string out of the sourceAttribution.""" + suppliers = source_attribution.get("supplier") + if suppliers is not None: + supplier_titles = [] + for supplier in suppliers: + title = supplier.get("title") + if title is not None: + supplier_titles.append(title) + joined_supplier_titles = ",".join(supplier_titles) + attribution = f"With the support of {joined_supplier_titles}. All information is provided without warranty of any kind." + return attribution + + +def convert_time_to_isodate(timestr: str) -> str: + """Take a string like 08:00:00 and combine it with the current date.""" + combined = datetime.combine(dt.start_of_local_day(), dt.parse_time(timestr)) + if combined < datetime.now(): + combined = combined + timedelta(days=1) + return combined.isoformat() diff --git a/homeassistant/components/hikvision/binary_sensor.py b/homeassistant/components/hikvision/binary_sensor.py index f15d67396151b..779afa10cca64 100644 --- a/homeassistant/components/hikvision/binary_sensor.py +++ b/homeassistant/components/hikvision/binary_sensor.py @@ -1,69 +1,83 @@ """Support for Hikvision event stream events represented as binary sensors.""" -import logging from datetime import timedelta +import logging + +from pyhik.hikvision import HikCamera import voluptuous as vol +from homeassistant.components.binary_sensor import PLATFORM_SCHEMA, BinarySensorEntity +from homeassistant.const import ( + ATTR_LAST_TRIP_TIME, + CONF_CUSTOMIZE, + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_SSL, + CONF_USERNAME, + EVENT_HOMEASSISTANT_START, + EVENT_HOMEASSISTANT_STOP, +) +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.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' +CONF_IGNORED = "ignored" +CONF_DELAY = "delay" DEFAULT_PORT = 80 DEFAULT_IGNORED = False DEFAULT_DELAY = 0 -ATTR_DELAY = 'delay' +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', + "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}), -}) +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): @@ -76,12 +90,9 @@ def setup_platform(hass, config, add_entities, discovery_info=None): customize = config.get(CONF_CUSTOMIZE) - if config.get(CONF_SSL): - protocol = 'https' - else: - protocol = 'http' + protocol = "https" if config[CONF_SSL] else "http" - url = '{}://{}'.format(protocol, host) + url = f"{protocol}://{host}" data = HikvisionData(hass, url, port, name, username, password) @@ -94,21 +105,26 @@ def setup_platform(hass, config, add_entities, discovery_info=None): 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]) + if data.type == "NVR": + sensor_name = f"{sensor.replace(' ', '_')}_{channel[1]}" else: - sensor_name = sensor.replace(' ', '_') + 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) + _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)) + entities.append( + HikvisionBinarySensor(hass, sensor, channel[1], data, delay) + ) add_entities(entities) @@ -118,7 +134,7 @@ class HikvisionData: 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 @@ -126,8 +142,7 @@ def __init__(self, hass, url, port, name, username, password): self._password = password # Establish camera - self.camdata = HikCamera( - self._url, self._port, self._username, self._password) + self.camdata = HikCamera(self._url, self._port, self._username, self._password) if self._name is None: self._name = self.camdata.get_name @@ -168,7 +183,7 @@ def get_attributes(self, sensor, channel): return self.camdata.fetch_attributes(sensor, channel) -class HikvisionBinarySensor(BinarySensorDevice): +class HikvisionBinarySensor(BinarySensorEntity): """Representation of a Hikvision binary sensor.""" def __init__(self, hass, sensor, channel, cam, delay): @@ -178,12 +193,12 @@ def __init__(self, hass, sensor, channel, cam, delay): self._sensor = sensor self._channel = channel - if self._cam.type == 'NVR': - self._name = '{} {} {}'.format(self._cam.name, sensor, channel) + if self._cam.type == "NVR": + self._name = f"{self._cam.name} {sensor} {channel}" else: - self._name = '{} {}'.format(self._cam.name, sensor) + self._name = f"{self._cam.name} {sensor}" - self._id = '{}.{}.{}'.format(self._cam.cam_id, sensor, channel) + self._id = f"{self._cam.cam_id}.{sensor}.{channel}" if delay is None: self._delay = 0 @@ -245,14 +260,15 @@ def device_state_attributes(self): def _update_callback(self, msg): """Update the sensor's state, if needed.""" - _LOGGER.debug('Callback signal from: %s', msg) + _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) + _LOGGER.debug( + "%s Called delayed (%ssec) update", self._name, self._delay + ) self.schedule_update_ha_state() self._timer = None @@ -261,8 +277,8 @@ def _delay_update(now): self._timer = None self._timer = track_point_in_utc_time( - self._hass, _delay_update, - utcnow() + timedelta(seconds=self._delay)) + 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 diff --git a/homeassistant/components/hikvision/manifest.json b/homeassistant/components/hikvision/manifest.json index db6af975081c5..e6dec7b8e89ef 100644 --- a/homeassistant/components/hikvision/manifest.json +++ b/homeassistant/components/hikvision/manifest.json @@ -1,12 +1,7 @@ { "domain": "hikvision", "name": "Hikvision", - "documentation": "https://www.home-assistant.io/components/hikvision", - "requirements": [ - "pyhik==0.2.2" - ], - "dependencies": [], - "codeowners": [ - "@mezz64" - ] + "documentation": "https://www.home-assistant.io/integrations/hikvision", + "requirements": ["pyhik==0.2.7"], + "codeowners": ["@mezz64"] } diff --git a/homeassistant/components/hikvisioncam/manifest.json b/homeassistant/components/hikvisioncam/manifest.json index f2bb0822d17c1..1a08487fa3a1d 100644 --- a/homeassistant/components/hikvisioncam/manifest.json +++ b/homeassistant/components/hikvisioncam/manifest.json @@ -1,10 +1,7 @@ { "domain": "hikvisioncam", - "name": "Hikvisioncam", - "documentation": "https://www.home-assistant.io/components/hikvisioncam", - "requirements": [ - "hikvision==0.4" - ], - "dependencies": [], + "name": "Hikvision", + "documentation": "https://www.home-assistant.io/integrations/hikvisioncam", + "requirements": ["hikvision==0.4"], "codeowners": ["@fbradyirl"] } diff --git a/homeassistant/components/hikvisioncam/switch.py b/homeassistant/components/hikvisioncam/switch.py index 373f84cee0e3a..2e924135bd402 100644 --- a/homeassistant/components/hikvisioncam/switch.py +++ b/homeassistant/components/hikvisioncam/switch.py @@ -1,38 +1,44 @@ """Support turning on/off motion detection on Hikvision cameras.""" import logging +import hikvision.api +from hikvision.error import HikvisionError, MissingParamError import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchEntity from homeassistant.const import ( - CONF_NAME, CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_PORT, STATE_OFF, - STATE_ON) -from homeassistant.helpers.entity import ToggleEntity + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + STATE_OFF, + STATE_ON, +) 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_NAME = "Hikvision Camera Motion Detection" +DEFAULT_PASSWORD = "12345" DEFAULT_PORT = 80 -DEFAULT_USERNAME = 'admin' +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, -}) +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) @@ -41,8 +47,8 @@ def setup_platform(hass, config, add_entities, discovery_info=None): try: hikvision_cam = hikvision.api.CreateDevice( - host, port=port, username=username, password=password, - is_https=False) + host, port=port, username=username, password=password, is_https=False + ) except MissingParamError as param_err: _LOGGING.error("Missing required param: %s", param_err) return False @@ -53,7 +59,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): add_entities([HikvisionMotionSwitch(name, hikvision_cam)]) -class HikvisionMotionSwitch(ToggleEntity): +class HikvisionMotionSwitch(SwitchEntity): """Representation of a switch to toggle on/off motion detection.""" def __init__(self, name, hikvision_cam): diff --git a/homeassistant/components/hipchat/__init__.py b/homeassistant/components/hipchat/__init__.py deleted file mode 100644 index 8b79982fa43d6..0000000000000 --- a/homeassistant/components/hipchat/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""The hipchat component.""" diff --git a/homeassistant/components/hipchat/manifest.json b/homeassistant/components/hipchat/manifest.json deleted file mode 100644 index d49e05a5416f9..0000000000000 --- a/homeassistant/components/hipchat/manifest.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "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 deleted file mode 100644 index f12fd1ffa76e1..0000000000000 --- a/homeassistant/components/hipchat/notify.py +++ /dev/null @@ -1,91 +0,0 @@ -"""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/hisense_aehw4a1/__init__.py b/homeassistant/components/hisense_aehw4a1/__init__.py new file mode 100644 index 0000000000000..721039d0e1c88 --- /dev/null +++ b/homeassistant/components/hisense_aehw4a1/__init__.py @@ -0,0 +1,81 @@ +"""The Hisense AEH-W4A1 integration.""" +import ipaddress +import logging + +from pyaehw4a1.aehw4a1 import AehW4a1 +import pyaehw4a1.exceptions +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN +from homeassistant.const import CONF_IP_ADDRESS +import homeassistant.helpers.config_validation as cv + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +def coerce_ip(value): + """Validate that provided value is a valid IP address.""" + if not value: + raise vol.Invalid("Must define an IP address") + try: + ipaddress.IPv4Network(value) + except ValueError: + raise vol.Invalid("Not a valid IP address") + return value + + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: { + CLIMATE_DOMAIN: vol.Schema( + { + vol.Optional(CONF_IP_ADDRESS, default=[]): vol.All( + cv.ensure_list, [vol.All(cv.string, coerce_ip)] + ) + } + ) + } + }, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass, config): + """Set up the Hisense AEH-W4A1 integration.""" + conf = config.get(DOMAIN) + hass.data[DOMAIN] = {} + + if conf is not None: + devices = conf[CONF_IP_ADDRESS][:] + for device in devices: + try: + await AehW4a1(device).check() + except pyaehw4a1.exceptions.ConnectionError: + conf[CONF_IP_ADDRESS].remove(device) + _LOGGER.warning("Hisense AEH-W4A1 at %s not found", device) + if conf[CONF_IP_ADDRESS]: + hass.data[DOMAIN] = conf + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, + ) + ) + + return True + + +async def async_setup_entry(hass, entry): + """Set up a config entry for Hisense AEH-W4A1.""" + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, CLIMATE_DOMAIN) + ) + + return True + + +async def async_unload_entry(hass, entry): + """Unload a config entry.""" + return await hass.config_entries.async_forward_entry_unload(entry, CLIMATE_DOMAIN) diff --git a/homeassistant/components/hisense_aehw4a1/climate.py b/homeassistant/components/hisense_aehw4a1/climate.py new file mode 100644 index 0000000000000..23a3a0c1416d4 --- /dev/null +++ b/homeassistant/components/hisense_aehw4a1/climate.py @@ -0,0 +1,438 @@ +"""Pyaehw4a1 platform to control of Hisense AEH-W4A1 Climate Devices.""" + +import logging + +from pyaehw4a1.aehw4a1 import AehW4a1 +import pyaehw4a1.exceptions + +from homeassistant.components.climate import ClimateEntity +from homeassistant.components.climate.const import ( + FAN_AUTO, + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, + HVAC_MODE_COOL, + HVAC_MODE_DRY, + HVAC_MODE_FAN_ONLY, + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + PRESET_BOOST, + PRESET_ECO, + PRESET_NONE, + PRESET_SLEEP, + SUPPORT_FAN_MODE, + SUPPORT_PRESET_MODE, + SUPPORT_SWING_MODE, + SUPPORT_TARGET_TEMPERATURE, + SWING_BOTH, + SWING_HORIZONTAL, + SWING_OFF, + SWING_VERTICAL, +) +from homeassistant.const import ( + ATTR_TEMPERATURE, + PRECISION_WHOLE, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, +) + +from . import CONF_IP_ADDRESS, DOMAIN + +SUPPORT_FLAGS = ( + SUPPORT_TARGET_TEMPERATURE + | SUPPORT_FAN_MODE + | SUPPORT_SWING_MODE + | SUPPORT_PRESET_MODE +) + +MIN_TEMP_C = 16 +MAX_TEMP_C = 32 + +MIN_TEMP_F = 61 +MAX_TEMP_F = 90 + +HVAC_MODES = [ + HVAC_MODE_OFF, + HVAC_MODE_HEAT, + HVAC_MODE_COOL, + HVAC_MODE_DRY, + HVAC_MODE_FAN_ONLY, +] + +FAN_MODES = [ + "mute", + FAN_LOW, + FAN_MEDIUM, + FAN_HIGH, + FAN_AUTO, +] + +SWING_MODES = [ + SWING_OFF, + SWING_VERTICAL, + SWING_HORIZONTAL, + SWING_BOTH, +] + +PRESET_MODES = [ + PRESET_NONE, + PRESET_ECO, + PRESET_BOOST, + PRESET_SLEEP, + "sleep_2", + "sleep_3", + "sleep_4", +] + +AC_TO_HA_STATE = { + "0001": HVAC_MODE_HEAT, + "0010": HVAC_MODE_COOL, + "0011": HVAC_MODE_DRY, + "0000": HVAC_MODE_FAN_ONLY, +} + +HA_STATE_TO_AC = { + HVAC_MODE_OFF: "off", + HVAC_MODE_HEAT: "mode_heat", + HVAC_MODE_COOL: "mode_cool", + HVAC_MODE_DRY: "mode_dry", + HVAC_MODE_FAN_ONLY: "mode_fan", +} + +AC_TO_HA_FAN_MODES = { + "00000000": FAN_AUTO, # fan value for heat mode + "00000001": FAN_AUTO, + "00000010": "mute", + "00000100": FAN_LOW, + "00000110": FAN_MEDIUM, + "00001000": FAN_HIGH, +} + +HA_FAN_MODES_TO_AC = { + "mute": "speed_mute", + FAN_LOW: "speed_low", + FAN_MEDIUM: "speed_med", + FAN_HIGH: "speed_max", + FAN_AUTO: "speed_auto", +} + +AC_TO_HA_SWING = { + "00": SWING_OFF, + "10": SWING_VERTICAL, + "01": SWING_HORIZONTAL, + "11": SWING_BOTH, +} + +_LOGGER = logging.getLogger(__name__) + + +def _build_entity(device): + _LOGGER.debug("Found device at %s", device) + return ClimateAehW4a1(device) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the AEH-W4A1 climate platform.""" + # Priority 1: manual config + if hass.data[DOMAIN].get(CONF_IP_ADDRESS): + devices = hass.data[DOMAIN][CONF_IP_ADDRESS] + else: + # Priority 2: scanned interfaces + devices = await AehW4a1().discovery() + + entities = [_build_entity(device) for device in devices] + async_add_entities(entities, True) + + +class ClimateAehW4a1(ClimateEntity): + """Representation of a Hisense AEH-W4A1 module for climate device.""" + + def __init__(self, device): + """Initialize the climate device.""" + self._unique_id = device + self._device = AehW4a1(device) + self._hvac_modes = HVAC_MODES + self._fan_modes = FAN_MODES + self._swing_modes = SWING_MODES + self._preset_modes = PRESET_MODES + self._available = None + self._on = None + self._temperature_unit = None + self._current_temperature = None + self._target_temperature = None + self._hvac_mode = None + self._fan_mode = None + self._swing_mode = None + self._preset_mode = None + self._previous_state = None + + async def async_update(self): + """Pull state from AEH-W4A1.""" + try: + status = await self._device.command("status_102_0") + except pyaehw4a1.exceptions.ConnectionError as library_error: + _LOGGER.warning( + "Unexpected error of %s: %s", self._unique_id, library_error + ) + self._available = False + return + + self._available = True + + self._on = status["run_status"] + + if status["temperature_Fahrenheit"] == "0": + self._temperature_unit = TEMP_CELSIUS + else: + self._temperature_unit = TEMP_FAHRENHEIT + + self._current_temperature = int(status["indoor_temperature_status"], 2) + + if self._on == "1": + device_mode = status["mode_status"] + self._hvac_mode = AC_TO_HA_STATE[device_mode] + + fan_mode = status["wind_status"] + self._fan_mode = AC_TO_HA_FAN_MODES[fan_mode] + + swing_mode = f'{status["up_down"]}{status["left_right"]}' + self._swing_mode = AC_TO_HA_SWING[swing_mode] + + if self._hvac_mode in (HVAC_MODE_COOL, HVAC_MODE_HEAT): + self._target_temperature = int(status["indoor_temperature_setting"], 2) + else: + self._target_temperature = None + + if status["efficient"] == "1": + self._preset_mode = PRESET_BOOST + elif status["low_electricity"] == "1": + self._preset_mode = PRESET_ECO + elif status["sleep_status"] == "0000001": + self._preset_mode = PRESET_SLEEP + elif status["sleep_status"] == "0000010": + self._preset_mode = "sleep_2" + elif status["sleep_status"] == "0000011": + self._preset_mode = "sleep_3" + elif status["sleep_status"] == "0000100": + self._preset_mode = "sleep_4" + else: + self._preset_mode = PRESET_NONE + else: + self._hvac_mode = HVAC_MODE_OFF + self._fan_mode = None + self._swing_mode = None + self._target_temperature = None + self._preset_mode = None + + @property + def available(self): + """Return True if entity is available.""" + return self._available + + @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._temperature_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 hvac_mode(self): + """Return hvac target hvac state.""" + return self._hvac_mode + + @property + def hvac_modes(self): + """Return the list of available operation modes.""" + return self._hvac_modes + + @property + def fan_mode(self): + """Return the fan setting.""" + return self._fan_mode + + @property + def fan_modes(self): + """Return the list of available fan modes.""" + return self._fan_modes + + @property + def preset_mode(self): + """Return the preset mode if on.""" + return self._preset_mode + + @property + def preset_modes(self): + """Return the list of available preset modes.""" + return self._preset_modes + + @property + def swing_mode(self): + """Return swing operation.""" + return self._swing_mode + + @property + def swing_modes(self): + """Return the list of available fan modes.""" + return self._swing_modes + + @property + def min_temp(self): + """Return the minimum temperature.""" + if self._temperature_unit == TEMP_CELSIUS: + return MIN_TEMP_C + return MIN_TEMP_F + + @property + def max_temp(self): + """Return the maximum temperature.""" + if self._temperature_unit == TEMP_CELSIUS: + return MAX_TEMP_C + return MAX_TEMP_F + + @property + def precision(self): + """Return the precision of the system.""" + return PRECISION_WHOLE + + @property + def target_temperature_step(self): + """Return the supported step of target temperature.""" + return 1 + + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_FLAGS + + async def async_set_temperature(self, **kwargs): + """Set new target temperatures.""" + if self._on != "1": + _LOGGER.warning( + "AC at %s is off, could not set temperature", self._unique_id + ) + return + temp = kwargs.get(ATTR_TEMPERATURE) + if temp is not None: + _LOGGER.debug("Setting temp of %s to %s", self._unique_id, temp) + if self._preset_mode != PRESET_NONE: + await self.async_set_preset_mode(PRESET_NONE) + if self._temperature_unit == TEMP_CELSIUS: + await self._device.command(f"temp_{int(temp)}_C") + else: + await self._device.command(f"temp_{int(temp)}_F") + + async def async_set_fan_mode(self, fan_mode): + """Set new fan mode.""" + if self._on != "1": + _LOGGER.warning("AC at %s is off, could not set fan mode", self._unique_id) + return + if self._hvac_mode in (HVAC_MODE_COOL, HVAC_MODE_FAN_ONLY) and ( + self._hvac_mode != HVAC_MODE_FAN_ONLY or fan_mode != FAN_AUTO + ): + _LOGGER.debug("Setting fan mode of %s to %s", self._unique_id, fan_mode) + await self._device.command(HA_FAN_MODES_TO_AC[fan_mode]) + + async def async_set_swing_mode(self, swing_mode): + """Set new target swing operation.""" + if self._on != "1": + _LOGGER.warning( + "AC at %s is off, could not set swing mode", self._unique_id + ) + return + + _LOGGER.debug("Setting swing mode of %s to %s", self._unique_id, swing_mode) + swing_act = self._swing_mode + + if swing_mode == SWING_OFF and swing_act != SWING_OFF: + if swing_act in (SWING_HORIZONTAL, SWING_BOTH): + await self._device.command("hor_dir") + if swing_act in (SWING_VERTICAL, SWING_BOTH): + await self._device.command("vert_dir") + + if swing_mode == SWING_BOTH and swing_act != SWING_BOTH: + if swing_act in (SWING_OFF, SWING_HORIZONTAL): + await self._device.command("vert_swing") + if swing_act in (SWING_OFF, SWING_VERTICAL): + await self._device.command("hor_swing") + + if swing_mode == SWING_VERTICAL and swing_act != SWING_VERTICAL: + if swing_act in (SWING_OFF, SWING_HORIZONTAL): + await self._device.command("vert_swing") + if swing_act in (SWING_BOTH, SWING_HORIZONTAL): + await self._device.command("hor_dir") + + if swing_mode == SWING_HORIZONTAL and swing_act != SWING_HORIZONTAL: + if swing_act in (SWING_BOTH, SWING_VERTICAL): + await self._device.command("vert_dir") + if swing_act in (SWING_OFF, SWING_VERTICAL): + await self._device.command("hor_swing") + + async def async_set_preset_mode(self, preset_mode): + """Set new preset mode.""" + if self._on != "1": + if preset_mode == PRESET_NONE: + return + await self.async_turn_on() + + _LOGGER.debug("Setting preset mode of %s to %s", self._unique_id, preset_mode) + + if preset_mode == PRESET_ECO: + await self._device.command("energysave_on") + self._previous_state = preset_mode + elif preset_mode == PRESET_BOOST: + await self._device.command("turbo_on") + self._previous_state = preset_mode + elif preset_mode == PRESET_SLEEP: + await self._device.command("sleep_1") + self._previous_state = self._hvac_mode + elif preset_mode == "sleep_2": + await self._device.command("sleep_2") + self._previous_state = self._hvac_mode + elif preset_mode == "sleep_3": + await self._device.command("sleep_3") + self._previous_state = self._hvac_mode + elif preset_mode == "sleep_4": + await self._device.command("sleep_4") + self._previous_state = self._hvac_mode + elif self._previous_state is not None: + if self._previous_state == PRESET_ECO: + await self._device.command("energysave_off") + elif self._previous_state == PRESET_BOOST: + await self._device.command("turbo_off") + elif self._previous_state in HA_STATE_TO_AC: + await self._device.command(HA_STATE_TO_AC[self._previous_state]) + self._previous_state = None + + async def async_set_hvac_mode(self, hvac_mode): + """Set new operation mode.""" + _LOGGER.debug("Setting operation mode of %s to %s", self._unique_id, hvac_mode) + if hvac_mode == HVAC_MODE_OFF: + await self.async_turn_off() + else: + await self._device.command(HA_STATE_TO_AC[hvac_mode]) + if self._on != "1": + await self.async_turn_on() + + async def async_turn_on(self): + """Turn on.""" + _LOGGER.debug("Turning %s on", self._unique_id) + await self._device.command("on") + + async def async_turn_off(self): + """Turn off.""" + _LOGGER.debug("Turning %s off", self._unique_id) + await self._device.command("off") diff --git a/homeassistant/components/hisense_aehw4a1/config_flow.py b/homeassistant/components/hisense_aehw4a1/config_flow.py new file mode 100644 index 0000000000000..52926ba796879 --- /dev/null +++ b/homeassistant/components/hisense_aehw4a1/config_flow.py @@ -0,0 +1,22 @@ +"""Config flow for Hisense AEH-W4A1 integration.""" +import logging + +from pyaehw4a1.aehw4a1 import AehW4a1 + +from homeassistant import config_entries +from homeassistant.helpers import config_entry_flow + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def _async_has_devices(hass): + """Return if there are devices that can be discovered.""" + aehw4a1_ip_addresses = await AehW4a1().discovery() + return len(aehw4a1_ip_addresses) > 0 + + +config_entry_flow.register_discovery_flow( + DOMAIN, "Hisense AEH-W4A1", _async_has_devices, config_entries.CONN_CLASS_LOCAL_POLL +) diff --git a/homeassistant/components/hisense_aehw4a1/const.py b/homeassistant/components/hisense_aehw4a1/const.py new file mode 100644 index 0000000000000..8f381492b62bf --- /dev/null +++ b/homeassistant/components/hisense_aehw4a1/const.py @@ -0,0 +1,3 @@ +"""Constants for the Hisense AEH-W4A1 integration.""" + +DOMAIN = "hisense_aehw4a1" diff --git a/homeassistant/components/hisense_aehw4a1/manifest.json b/homeassistant/components/hisense_aehw4a1/manifest.json new file mode 100644 index 0000000000000..02535142d1b4c --- /dev/null +++ b/homeassistant/components/hisense_aehw4a1/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "hisense_aehw4a1", + "name": "Hisense AEH-W4A1", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/hisense_aehw4a1", + "requirements": ["pyaehw4a1==0.3.4"], + "codeowners": ["@bannhead"] +} diff --git a/homeassistant/components/hisense_aehw4a1/strings.json b/homeassistant/components/hisense_aehw4a1/strings.json new file mode 100644 index 0000000000000..5d9b6f1ef9613 --- /dev/null +++ b/homeassistant/components/hisense_aehw4a1/strings.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "confirm": { + "description": "Do you want to set up Hisense AEH-W4A1?" + } + }, + "abort": { + "single_instance_allowed": "Only a single configuration of Hisense AEH-W4A1 is possible.", + "no_devices_found": "No Hisense AEH-W4A1 devices found on the network." + } + } +} diff --git a/homeassistant/components/hisense_aehw4a1/translations/bg.json b/homeassistant/components/hisense_aehw4a1/translations/bg.json new file mode 100644 index 0000000000000..607347ff9e91e --- /dev/null +++ b/homeassistant/components/hisense_aehw4a1/translations/bg.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "no_devices_found": "\u0412 \u043c\u0440\u0435\u0436\u0430\u0442\u0430 \u043d\u0435 \u0441\u0430 \u043d\u0430\u043c\u0435\u0440\u0435\u043d\u0438 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 Hisense AEH-W4A1.", + "single_instance_allowed": "\u0412\u044a\u0437\u043c\u043e\u0436\u043d\u0430 \u0435 \u0441\u0430\u043c\u043e \u0435\u0434\u043d\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f \u043d\u0430 Hisense AEH-W4A1." + }, + "step": { + "confirm": { + "description": "\u0418\u0441\u043a\u0430\u0442\u0435 \u043b\u0438 \u0434\u0430 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u0435 Hisense AEH-W4A1?", + "title": "Hisense AEH-W4A1" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hisense_aehw4a1/translations/ca.json b/homeassistant/components/hisense_aehw4a1/translations/ca.json new file mode 100644 index 0000000000000..a0aef80e03156 --- /dev/null +++ b/homeassistant/components/hisense_aehw4a1/translations/ca.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "no_devices_found": "No s'ha trobat cap dispositiu AEH-W4A1 a la xarxa.", + "single_instance_allowed": "Nom\u00e9s \u00e9s possible una \u00fanica configuraci\u00f3 del AEH-W4A1 de Hisense." + }, + "step": { + "confirm": { + "description": "Vols configurar AEH-W4A1 de Hisense?", + "title": "Hisense AEH-W4A1" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hisense_aehw4a1/translations/da.json b/homeassistant/components/hisense_aehw4a1/translations/da.json new file mode 100644 index 0000000000000..d75ffed4c568e --- /dev/null +++ b/homeassistant/components/hisense_aehw4a1/translations/da.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "no_devices_found": "Ingen Hisense AEH-W4A1-enheder fundet p\u00e5 netv\u00e6rket.", + "single_instance_allowed": "Kun en enkelt konfiguration af Hisense AEH-W4A1 er mulig." + }, + "step": { + "confirm": { + "description": "Vil du konfigurere Hisense AEH-W4A1?", + "title": "Hisense AEH-W4A1" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hisense_aehw4a1/translations/de.json b/homeassistant/components/hisense_aehw4a1/translations/de.json new file mode 100644 index 0000000000000..e42d91082e812 --- /dev/null +++ b/homeassistant/components/hisense_aehw4a1/translations/de.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "no_devices_found": "Es wurden keine Hisense AEH-W4A1-Ger\u00e4te im Netzwerk gefunden.", + "single_instance_allowed": "Es ist nur eine einzige Konfiguration von Hisense AEH-W4A1 m\u00f6glich." + }, + "step": { + "confirm": { + "description": "M\u00f6chtest du Hisense AEH-W4A1 einrichten?", + "title": "Hisense AEH-W4A1" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hisense_aehw4a1/translations/en.json b/homeassistant/components/hisense_aehw4a1/translations/en.json new file mode 100644 index 0000000000000..ca0738ec9a853 --- /dev/null +++ b/homeassistant/components/hisense_aehw4a1/translations/en.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "no_devices_found": "No Hisense AEH-W4A1 devices found on the network.", + "single_instance_allowed": "Only a single configuration of Hisense AEH-W4A1 is possible." + }, + "step": { + "confirm": { + "description": "Do you want to set up Hisense AEH-W4A1?", + "title": "Hisense AEH-W4A1" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hisense_aehw4a1/translations/es.json b/homeassistant/components/hisense_aehw4a1/translations/es.json new file mode 100644 index 0000000000000..c9c4270360ae5 --- /dev/null +++ b/homeassistant/components/hisense_aehw4a1/translations/es.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "no_devices_found": "No se encontraron dispositivos Hisense AEH-W4A1 en la red.", + "single_instance_allowed": "Solo es posible una \u00fanica configuraci\u00f3n de Hisense AEH-W4A1." + }, + "step": { + "confirm": { + "description": "\u00bfDesea configurar Hisense AEH-W4A1?", + "title": "Hisense AEH-W4A1" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hisense_aehw4a1/translations/fr.json b/homeassistant/components/hisense_aehw4a1/translations/fr.json new file mode 100644 index 0000000000000..dafe3836a5046 --- /dev/null +++ b/homeassistant/components/hisense_aehw4a1/translations/fr.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "no_devices_found": "Aucun p\u00e9riph\u00e9rique AEH-W4A1 trouv\u00e9 sur le r\u00e9seau.", + "single_instance_allowed": "Une seule configuration de AEH-W4A1 est possible." + }, + "step": { + "confirm": { + "description": "Voulez-vous configurer AEH-W4A1?", + "title": "Hisense AEH-W4A1" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hisense_aehw4a1/translations/hu.json b/homeassistant/components/hisense_aehw4a1/translations/hu.json new file mode 100644 index 0000000000000..389653422fd4b --- /dev/null +++ b/homeassistant/components/hisense_aehw4a1/translations/hu.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "no_devices_found": "A h\u00e1l\u00f3zaton nem tal\u00e1lhat\u00f3 Hisense AEH-W4A1 eszk\u00f6z.", + "single_instance_allowed": "Csak egy konfigur\u00e1ci\u00f3 lehet Hisense AEH-W4A1 eset\u00e9n." + }, + "step": { + "confirm": { + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani Hisense AEH-W4A1-et?", + "title": "Hisense AEH-W4A1" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hisense_aehw4a1/translations/it.json b/homeassistant/components/hisense_aehw4a1/translations/it.json new file mode 100644 index 0000000000000..3d878ed40beec --- /dev/null +++ b/homeassistant/components/hisense_aehw4a1/translations/it.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "no_devices_found": "Nessun dispositivo Hisense AEH-W4A1 trovato sulla rete.", + "single_instance_allowed": "\u00c8 consentita solo una configurazione di Hisense AEH-W4A1" + }, + "step": { + "confirm": { + "description": "Voui configurare Hisense AEH-W4A1", + "title": "Hisense AEH-W4A1" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hisense_aehw4a1/translations/ko.json b/homeassistant/components/hisense_aehw4a1/translations/ko.json new file mode 100644 index 0000000000000..2c472277b0016 --- /dev/null +++ b/homeassistant/components/hisense_aehw4a1/translations/ko.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "no_devices_found": "Hisense AEH-W4A1 \uae30\uae30\uac00 \ub124\ud2b8\uc6cc\ud06c\uc5d0\uc11c \ubc1c\uacac\ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4.", + "single_instance_allowed": "\ud558\ub098\uc758 Hisense AEH-W4A1 \ub9cc \uad6c\uc131\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4." + }, + "step": { + "confirm": { + "description": "Hisense AEH-W4A1 \uc744 \uc124\uc815\ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?", + "title": "Hisense AEH-W4A1" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hisense_aehw4a1/translations/lb.json b/homeassistant/components/hisense_aehw4a1/translations/lb.json new file mode 100644 index 0000000000000..cdfbb069c0fb9 --- /dev/null +++ b/homeassistant/components/hisense_aehw4a1/translations/lb.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "no_devices_found": "Keng Hisense AEH-W4A1 Apparater am Netzwierk fonnt.", + "single_instance_allowed": "N\u00ebmmen eng eenzeg Konfiguratioun vun Hisense AEH-W4A1 ass m\u00e9iglech." + }, + "step": { + "confirm": { + "description": "Soll Hisense AEH-W4A1 konfigur\u00e9iert ginn?", + "title": "Hisense AEH-W4A1" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hisense_aehw4a1/translations/nl.json b/homeassistant/components/hisense_aehw4a1/translations/nl.json new file mode 100644 index 0000000000000..9fef289f54514 --- /dev/null +++ b/homeassistant/components/hisense_aehw4a1/translations/nl.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "no_devices_found": "Geen Hisense AEH-W4A1-apparaten gevonden op het netwerk.", + "single_instance_allowed": "Slechts een enkele configuratie van Hisense AEH-W4A1 is mogelijk." + }, + "step": { + "confirm": { + "description": "Wilt u Hisense AEH-W4A1 instellen?", + "title": "Hisense AEH-W4A1" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hisense_aehw4a1/translations/no.json b/homeassistant/components/hisense_aehw4a1/translations/no.json new file mode 100644 index 0000000000000..0b0bf55d7afce --- /dev/null +++ b/homeassistant/components/hisense_aehw4a1/translations/no.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "no_devices_found": "Ingen Hisense AEH-W4A1-enheter funnet p\u00e5 nettverket.", + "single_instance_allowed": "Bare en enkelt konfigurasjon av Hisense AEH-W4A1 er mulig." + }, + "step": { + "confirm": { + "description": "Vil du konfigurere Hisense AEH-W4A1?", + "title": "" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hisense_aehw4a1/translations/pl.json b/homeassistant/components/hisense_aehw4a1/translations/pl.json new file mode 100644 index 0000000000000..77e5c6298eba0 --- /dev/null +++ b/homeassistant/components/hisense_aehw4a1/translations/pl.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "no_devices_found": "Nie znaleziono w sieci urz\u0105dze\u0144 Hisense AEH-W4A1.", + "single_instance_allowed": "Dozwolona jest tylko jedna konfiguracja Hisense AEH-W4A1." + }, + "step": { + "confirm": { + "description": "Chcesz skonfigurowa\u0107 AEH-W4A1?", + "title": "Hisense AEH-W4A1" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hisense_aehw4a1/translations/ru.json b/homeassistant/components/hisense_aehw4a1/translations/ru.json new file mode 100644 index 0000000000000..bb406e90f9270 --- /dev/null +++ b/homeassistant/components/hisense_aehw4a1/translations/ru.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "no_devices_found": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 Hisense AEH-W4A1e \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u044b \u0432 \u0441\u0435\u0442\u0438.", + "single_instance_allowed": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430." + }, + "step": { + "confirm": { + "description": "\u0412\u044b \u0443\u0432\u0435\u0440\u0435\u043d\u044b, \u0447\u0442\u043e \u0445\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c Hisense AEH-W4A1?", + "title": "Hisense AEH-W4A1" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hisense_aehw4a1/translations/sl.json b/homeassistant/components/hisense_aehw4a1/translations/sl.json new file mode 100644 index 0000000000000..d24c839865274 --- /dev/null +++ b/homeassistant/components/hisense_aehw4a1/translations/sl.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "no_devices_found": "V omre\u017eju ni bilo najdenih naprav Hisense AEH-W4A1.", + "single_instance_allowed": "Mo\u017ena je samo ena konfiguracija Hisense AEH-W4A1." + }, + "step": { + "confirm": { + "description": "Ali \u017eelite nastaviti Hisense AEH-W4A1?", + "title": "Hisense AEH-W4A1" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hisense_aehw4a1/translations/sv.json b/homeassistant/components/hisense_aehw4a1/translations/sv.json new file mode 100644 index 0000000000000..01d484075e0f7 --- /dev/null +++ b/homeassistant/components/hisense_aehw4a1/translations/sv.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "no_devices_found": "Inga Hisense AEH-W4A1-enheter hittades i n\u00e4tverket.", + "single_instance_allowed": "Endast en enda konfiguration av Hisense AEH-W4A1 \u00e4r m\u00f6jlig." + }, + "step": { + "confirm": { + "description": "Vill du konfigurera Hisense AEH-W4A1?", + "title": "Hisense AEH-W4A1" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hisense_aehw4a1/translations/zh-Hant.json b/homeassistant/components/hisense_aehw4a1/translations/zh-Hant.json new file mode 100644 index 0000000000000..44feda4fffc34 --- /dev/null +++ b/homeassistant/components/hisense_aehw4a1/translations/zh-Hant.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "no_devices_found": "\u5728\u7db2\u8def\u4e0a\u627e\u4e0d\u5230\u6d77\u4fe1 AEH-W4A1 \u8a2d\u5099\u3002", + "single_instance_allowed": "\u50c5\u5141\u8a31\u8a2d\u5b9a\u4e00\u7d44\u6d77\u4fe1 AEH-W4A1\u3002" + }, + "step": { + "confirm": { + "description": "\u662f\u5426\u8981\u8a2d\u5b9a\u6d77\u4fe1 AEH-W4A1\uff1f", + "title": "\u6d77\u4fe1 AEH-W4A1" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/history/__init__.py b/homeassistant/components/history/__init__.py index 7efe4f2beb20a..6fc68b2833e1c 100644 --- a/homeassistant/components/history/__init__.py +++ b/homeassistant/components/history/__init__.py @@ -5,34 +5,53 @@ import logging import time +from sqlalchemy import and_, func 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 import recorder from homeassistant.components.http import HomeAssistantView -from homeassistant.const import ATTR_HIDDEN -from homeassistant.components.recorder.util import session_scope, execute +from homeassistant.components.recorder.models import States +from homeassistant.components.recorder.util import execute, session_scope +from homeassistant.const import ( + ATTR_HIDDEN, + CONF_DOMAINS, + CONF_ENTITIES, + CONF_EXCLUDE, + CONF_INCLUDE, + HTTP_BAD_REQUEST, +) import homeassistant.helpers.config_validation as cv +import homeassistant.util.dt as dt_util -_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',) +# mypy: allow-untyped-defs, no-check-untyped-defs +_LOGGER = logging.getLogger(__name__) -def get_significant_states(hass, start_time, end_time=None, entity_ids=None, - filters=None, include_start_time_state=True): +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 = ("climate", "device_tracker", "thermostat", "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, + significant_changes_only=True, +): """ Return states changes during UTC period start_time - end_time. @@ -41,13 +60,18 @@ def get_significant_states(hass, start_time, end_time=None, entity_ids=None, 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 significant_changes_only: + query = session.query(States).filter( + ( + States.domain.in_(SIGNIFICANT_DOMAINS) + | (States.last_changed == States.last_updated) + ) + & (States.last_updated > start_time) + ) + else: + query = session.query(States).filter(States.last_updated > start_time) if filters: query = filters.apply(query, entity_ids) @@ -58,29 +82,28 @@ def get_significant_states(hass, start_time, end_time=None, entity_ids=None, 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))) + 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) + _LOGGER.debug("get_significant_states took %fs", elapsed) return states_to_json( - hass, states, start_time, entity_ids, filters, - include_start_time_state) + 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): +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)) + (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) @@ -90,21 +113,18 @@ def state_changes_during_period(hass, start_time, end_time=None, entity_ids = [entity_id] if entity_id is not None else None - states = execute( - query.order_by(States.last_updated)) + 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)) + 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()) @@ -112,18 +132,16 @@ def get_last_state_changes(hass, number_of_states, entity_id): 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)) + 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) + 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): +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) @@ -132,21 +150,21 @@ def get_states(hass, utc_point_in_time, entity_ids=None, run=None, if run is None: return [] - from sqlalchemy import and_, func - with session_scope(hass=hass) as session: + query = session.query(States) + 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) + query = ( + query.filter( + States.last_updated >= run.start, + States.last_updated < utc_point_in_time, + States.entity_id.in_(entity_ids), + ) + .order_by(States.last_updated.desc()) + .limit(1) + ) else: # We have more than one entity to look at (most commonly we want @@ -154,53 +172,55 @@ def get_states(hass, utc_point_in_time, entity_ids=None, run=None, # 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') + 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) + (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.filter(States.entity_id.in_(entity_ids)) most_recent_states_by_date = most_recent_states_by_date.group_by( - States.entity_id) + 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)) + 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.group_by(States.entity_id) - most_recent_state_ids = most_recent_state_ids.subquery() + 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))) + query = query.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) + if filters: + query = filters.apply(query, entity_ids) - return [state for state in execute(query) - if not state.attributes.get(ATTR_HIDDEN, False)] + 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): + 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 @@ -211,6 +231,10 @@ def states_to_json( axis correctly. """ result = defaultdict(list) + # Set all entity IDs to empty lists in result set to maintain the order + if entity_ids is not None: + for ent_id in entity_ids: + result[ent_id] = [] # Get the states at the start time timer_start = time.perf_counter() @@ -222,13 +246,14 @@ def states_to_json( if _LOGGER.isEnabledFor(logging.DEBUG): elapsed = time.perf_counter() - timer_start - _LOGGER.debug( - 'getting %d first datapoints took %fs', len(result), elapsed) + _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 + + # Filter out the empty lists if some states had 0 results. + return {key: val for key, val in result.items() if val} def get_state(hass, utc_point_in_time, entity_id, run=None): @@ -252,8 +277,9 @@ async def async_setup(hass, config): use_include_order = conf.get(CONF_ORDER) hass.http.register_view(HistoryPeriodView(filters, use_include_order)) - await hass.components.frontend.async_register_built_in_panel( - 'history', 'history', 'hass:poll-box') + hass.components.frontend.async_register_built_in_panel( + "history", "history", "hass:poll-box" + ) return True @@ -261,9 +287,9 @@ async def async_setup(hass, config): class HistoryPeriodView(HomeAssistantView): """Handle history period requests.""" - url = '/api/history/period' - name = 'api:history:view-period' - extra_urls = ['/api/history/period/{datetime}'] + 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.""" @@ -277,7 +303,7 @@ async def get(self, request, datetime=None): datetime = dt_util.parse_datetime(datetime) if datetime is None: - return self.json_message('Invalid datetime', HTTP_BAD_REQUEST) + return self.json_message("Invalid datetime", HTTP_BAD_REQUEST) now = dt_util.utcnow() @@ -290,34 +316,42 @@ async def get(self, request, datetime=None): if start_time > now: return self.json([]) - end_time = request.query.get('end_time') + 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) + 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') + 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 + entity_ids = entity_ids.lower().split(",") + include_start_time_state = "skip_initial_state" not in request.query + significant_changes_only = ( + request.query.get("significant_changes_only", "1") != "0" + ) - hass = request.app['hass'] + 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) + get_significant_states, + hass, + start_time, + end_time, + entity_ids, + self.filters, + include_start_time_state, + significant_changes_only, + ) 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) + _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: @@ -353,7 +387,6 @@ def apply(self, query, entity_ids=None): * 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: @@ -375,14 +408,19 @@ def apply(self, query, entity_ids=None): 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)) + 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)) + 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: + 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) @@ -398,5 +436,4 @@ def _is_significant(state): 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)) + return state.domain != "script" or state.attributes.get("can_cancel") diff --git a/homeassistant/components/history/manifest.json b/homeassistant/components/history/manifest.json index e0989958626a1..7185a8b63c43b 100644 --- a/homeassistant/components/history/manifest.json +++ b/homeassistant/components/history/manifest.json @@ -1,13 +1,8 @@ { "domain": "history", "name": "History", - "documentation": "https://www.home-assistant.io/components/history", - "requirements": [], - "dependencies": [ - "http", - "recorder" - ], - "codeowners": [ - "@home-assistant/core" - ] + "documentation": "https://www.home-assistant.io/integrations/history", + "dependencies": ["http", "recorder"], + "codeowners": ["@home-assistant/core"], + "quality_scale": "internal" } diff --git a/homeassistant/components/history_graph/__init__.py b/homeassistant/components/history_graph/__init__.py deleted file mode 100644 index 964d47d25025d..0000000000000 --- a/homeassistant/components/history_graph/__init__.py +++ /dev/null @@ -1,78 +0,0 @@ -"""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 deleted file mode 100644 index fa0d437a700c9..0000000000000 --- a/homeassistant/components/history_graph/manifest.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "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/manifest.json b/homeassistant/components/history_stats/manifest.json index ea0abd87c28c4..dad7cfa6a5a0b 100644 --- a/homeassistant/components/history_stats/manifest.json +++ b/homeassistant/components/history_stats/manifest.json @@ -1,10 +1,8 @@ { "domain": "history_stats", - "name": "History stats", - "documentation": "https://www.home-assistant.io/components/history_stats", - "requirements": [], - "dependencies": [ - "history" - ], - "codeowners": [] + "name": "History Stats", + "documentation": "https://www.home-assistant.io/integrations/history_stats", + "dependencies": ["history"], + "codeowners": [], + "quality_scale": "internal" } diff --git a/homeassistant/components/history_stats/sensor.py b/homeassistant/components/history_stats/sensor.py index a0a08d4833e2b..48d65145219aa 100644 --- a/homeassistant/components/history_stats/sensor.py +++ b/homeassistant/components/history_stats/sensor.py @@ -5,59 +5,71 @@ 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) + CONF_ENTITY_ID, + CONF_NAME, + CONF_STATE, + CONF_TYPE, + EVENT_HOMEASSISTANT_START, + TIME_HOURS, + UNIT_PERCENTAGE, +) +from homeassistant.core import callback from homeassistant.exceptions import TemplateError +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_state_change +import homeassistant.util.dt as dt_util _LOGGER = logging.getLogger(__name__) -DOMAIN = 'history_stats' -CONF_START = 'start' -CONF_END = 'end' -CONF_DURATION = 'duration' +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_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' +DEFAULT_NAME = "unnamed statistics" UNITS = { - CONF_TYPE_TIME: 'h', - CONF_TYPE_RATIO: '%', - CONF_TYPE_COUNT: '' + CONF_TYPE_TIME: TIME_HOURS, + CONF_TYPE_RATIO: UNIT_PERCENTAGE, + CONF_TYPE_COUNT: "", } -ICON = 'mdi:chart-line' +ICON = "mdi:chart-line" -ATTR_VALUE = 'value' +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') + 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) +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 @@ -75,8 +87,13 @@ def setup_platform(hass, config, add_entities, discovery_info=None): if template is not None: template.hass = hass - add_entities([HistoryStatsSensor(hass, entity_id, entity_state, start, end, - duration, sensor_type, name)]) + add_entities( + [ + HistoryStatsSensor( + hass, entity_id, entity_state, start, end, duration, sensor_type, name + ) + ] + ) return True @@ -85,8 +102,8 @@ class HistoryStatsSensor(Entity): """Representation of a HistoryStats sensor.""" def __init__( - self, hass, entity_id, entity_state, start, end, duration, - sensor_type, name): + 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 @@ -104,6 +121,7 @@ def __init__( @callback def start_refresh(*args): """Register state tracking.""" + @callback def force_refresh(*args): """Force the component to refresh.""" @@ -152,9 +170,7 @@ def device_state_attributes(self): return {} hsh = HistoryStatsHelper - return { - ATTR_VALUE: hsh.pretty_duration(self.value), - } + return {ATTR_VALUE: hsh.pretty_duration(self.value)} @property def icon(self): @@ -185,23 +201,25 @@ def update(self): 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: + 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)) + 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_state = last_state is not None and last_state == self._entity_state last_time = start_timestamp elapsed = 0 count = 0 @@ -240,16 +258,18 @@ def update_period(self): try: start_rendered = self._start.render() except (TemplateError, TypeError) as ex: - HistoryStatsHelper.handle_template_exception(ex, 'start') + 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)))) + 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") + _LOGGER.error( + "Parsing error: start must be a datetime or a timestamp" + ) return # Parse end @@ -257,16 +277,18 @@ def update_period(self): try: end_rendered = self._end.render() except (TemplateError, TypeError) as ex: - HistoryStatsHelper.handle_template_exception(ex, 'end') + 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)))) + 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") + _LOGGER.error( + "Parsing error: end must be a datetime or a timestamp" + ) return # Calculate start or end using the duration @@ -296,10 +318,10 @@ def pretty_duration(hours): hours, seconds = divmod(seconds, 3600) minutes, seconds = divmod(seconds, 60) if days > 0: - return '%dd %dh %dm' % (days, hours, minutes) + return "%dd %dh %dm" % (days, hours, minutes) if hours > 0: - return '%dh %dm' % (hours, minutes) - return '%dm' % minutes + return "%dh %dm" % (hours, minutes) + return "%dm" % minutes @staticmethod def pretty_ratio(value, period): @@ -313,8 +335,7 @@ def pretty_ratio(value, period): @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"): + 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 diff --git a/homeassistant/components/hitron_coda/device_tracker.py b/homeassistant/components/hitron_coda/device_tracker.py index e6f68d704fd02..a49e3cc6d2171 100644 --- a/homeassistant/components/hitron_coda/device_tracker.py +++ b/homeassistant/components/hitron_coda/device_tracker.py @@ -1,27 +1,36 @@ """Support for the Hitron CODA-4582U, provided by Rogers.""" -import logging from collections import namedtuple +import logging import requests import voluptuous as vol -import homeassistant.helpers.config_validation as cv from homeassistant.components.device_tracker import ( - DOMAIN, PLATFORM_SCHEMA, DeviceScanner) + DOMAIN, + PLATFORM_SCHEMA, + DeviceScanner, +) from homeassistant.const import ( - CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_TYPE + CONF_HOST, + CONF_PASSWORD, + CONF_TYPE, + CONF_USERNAME, + HTTP_OK, ) +import homeassistant.helpers.config_validation as cv _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, -}) +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): @@ -31,7 +40,7 @@ def get_scanner(_hass, config): return scanner if scanner.success_init else None -Device = namedtuple('Device', ['mac', 'name']) +Device = namedtuple("Device", ["mac", "name"]) class HitronCODADeviceScanner(DeviceScanner): @@ -41,16 +50,16 @@ 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._url = f"http://{host}/data/getConnectInfo.asp" + self._loginurl = f"http://{host}/goform/login" self._username = config.get(CONF_USERNAME) self._password = config.get(CONF_PASSWORD) if config.get(CONF_TYPE) == "shaw": - self._type = 'pwd' + self._type = "pwd" else: - self._type = 'pws' + self._type = "pws" self._userid = None @@ -65,9 +74,9 @@ def scan_devices(self): 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) + name = next( + (result.name for result in self.last_results if result.mac == device), None + ) return name def _login(self): @@ -75,21 +84,16 @@ def _login(self): _LOGGER.info("Logging in to CODA...") try: - data = [ - ('user', self._username), - (self._type, self._password), - ] + 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) + _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) + if res.status_code != HTTP_OK: + _LOGGER.error("Connection failed with http code %s", res.status_code) return False try: - self._userid = res.cookies['userid'] + self._userid = res.cookies["userid"] return True except KeyError: _LOGGER.error("Failed to log in to router") @@ -107,16 +111,12 @@ def _update_info(self): # doing a request try: - res = requests.get(self._url, timeout=10, cookies={ - 'userid': self._userid - }) + 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) + _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) + if res.status_code != HTTP_OK: + _LOGGER.error("Connection failed with http code %s", res.status_code) return False try: result = res.json() @@ -127,8 +127,8 @@ def _update_info(self): # parsing response for info in result: - mac = info['macAddr'] - name = info['hostName'] + mac = info["macAddr"] + name = info["hostName"] # No address = no item :) if mac is None: continue diff --git a/homeassistant/components/hitron_coda/manifest.json b/homeassistant/components/hitron_coda/manifest.json index 9f3c20fcca534..609e217128060 100644 --- a/homeassistant/components/hitron_coda/manifest.json +++ b/homeassistant/components/hitron_coda/manifest.json @@ -1,8 +1,6 @@ { "domain": "hitron_coda", - "name": "Hitron coda", - "documentation": "https://www.home-assistant.io/components/hitron_coda", - "requirements": [], - "dependencies": [], + "name": "Rogers Hitron CODA", + "documentation": "https://www.home-assistant.io/integrations/hitron_coda", "codeowners": [] } diff --git a/homeassistant/components/hive/__init__.py b/homeassistant/components/hive/__init__.py index fdda1f1f5426c..98d625cbb1d26 100644 --- a/homeassistant/components/hive/__init__.py +++ b/homeassistant/components/hive/__init__.py @@ -1,38 +1,78 @@ -"""Support for the Hive devices.""" +"""Support for the Hive devices and services.""" +from functools import wraps import logging +from pyhiveapi import Pyhiveapi import voluptuous as vol from homeassistant.const import ( - CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME) + ATTR_ENTITY_ID, + ATTR_TEMPERATURE, + CONF_PASSWORD, + CONF_SCAN_INTERVAL, + CONF_USERNAME, +) 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.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) -DOMAIN = 'hive' -DATA_HIVE = 'data_hive' +DOMAIN = "hive" +DATA_HIVE = "data_hive" +SERVICES = ["Heating", "HotWater", "TRV"] +SERVICE_BOOST_HOT_WATER = "boost_hot_water" +SERVICE_BOOST_HEATING = "boost_heating" +ATTR_TIME_PERIOD = "time_period" +ATTR_MODE = "on_off" DEVICETYPES = { - 'binary_sensor': 'device_list_binary_sensor', - 'climate': 'device_list_climate', - 'light': 'device_list_light', - 'switch': 'device_list_plug', - 'sensor': 'device_list_sensor', + "binary_sensor": "device_list_binary_sensor", + "climate": "device_list_climate", + "water_heater": "device_list_water_heater", + "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) +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, +) + +BOOST_HEATING_SCHEMA = vol.Schema( + { + vol.Required(ATTR_ENTITY_ID): cv.entity_id, + vol.Required(ATTR_TIME_PERIOD): vol.All( + cv.time_period, cv.positive_timedelta, lambda td: td.total_seconds() // 60 + ), + vol.Optional(ATTR_TEMPERATURE, default="25.0"): vol.Coerce(float), + } +) + +BOOST_HOT_WATER_SCHEMA = vol.Schema( + { + vol.Required(ATTR_ENTITY_ID): cv.entity_id, + vol.Optional(ATTR_TIME_PERIOD, default="00:30:00"): vol.All( + cv.time_period, cv.positive_timedelta, lambda td: td.total_seconds() // 60 + ), + vol.Required(ATTR_MODE): cv.string, + } +) class HiveSession: """Initiate Hive Session Class.""" - entities = [] + entity_lookup = {} core = None heating = None hotwater = None @@ -41,11 +81,39 @@ class HiveSession: switch = None weather = None attributes = None + trv = None def setup(hass, config): """Set up the Hive Component.""" - from pyhiveapi import Pyhiveapi + + def heating_boost(service): + """Handle the service call.""" + node_id = HiveSession.entity_lookup.get(service.data[ATTR_ENTITY_ID]) + if not node_id: + # log or raise error + _LOGGER.error("Cannot boost entity id entered") + return + + minutes = service.data[ATTR_TIME_PERIOD] + temperature = service.data[ATTR_TEMPERATURE] + + session.heating.turn_boost_on(node_id, minutes, temperature) + + def hot_water_boost(service): + """Handle the service call.""" + node_id = HiveSession.entity_lookup.get(service.data[ATTR_ENTITY_ID]) + if not node_id: + # log or raise error + _LOGGER.error("Cannot boost entity id entered") + return + minutes = service.data[ATTR_TIME_PERIOD] + mode = service.data[ATTR_MODE] + + if mode == "on": + session.hotwater.turn_boost_on(node_id, minutes) + elif mode == "off": + session.hotwater.turn_boost_off(node_id) session = HiveSession() session.core = Pyhiveapi() @@ -54,10 +122,9 @@ def setup(hass, config): password = config[DOMAIN][CONF_PASSWORD] update_interval = config[DOMAIN][CONF_SCAN_INTERVAL] - devicelist = session.core.initialise_api( - username, password, update_interval) + devices = session.core.initialise_api(username, password, update_interval) - if devicelist is None: + if devices is None: _LOGGER.error("Hive API initialization failed") return False @@ -70,9 +137,56 @@ def setup(hass, config): 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) + for ha_type in DEVICETYPES: + devicelist = devices.get(DEVICETYPES[ha_type]) + if devicelist: + load_platform(hass, ha_type, DOMAIN, devicelist, config) + if ha_type == "climate": + hass.services.register( + DOMAIN, + SERVICE_BOOST_HEATING, + heating_boost, + schema=BOOST_HEATING_SCHEMA, + ) + if ha_type == "water_heater": + hass.services.register( + DOMAIN, + SERVICE_BOOST_HOT_WATER, + hot_water_boost, + schema=BOOST_HOT_WATER_SCHEMA, + ) + return True + + +def refresh_system(func): + """Force update all entities after state change.""" + + @wraps(func) + def wrapper(self, *args, **kwargs): + func(self, *args, **kwargs) + dispatcher_send(self.hass, DOMAIN) + + return wrapper + + +class HiveEntity(Entity): + """Initiate Hive Base Class.""" + + def __init__(self, session, hive_device): + """Initialize the instance.""" + self.node_id = hive_device["Hive_NodeID"] + self.node_name = hive_device["Hive_NodeName"] + self.device_type = hive_device["HA_DeviceType"] + self.node_device_type = hive_device["Hive_DeviceType"] + self.session = session + self.attributes = {} + self._unique_id = f"{self.node_id}-{self.device_type}" + + async def async_added_to_hass(self): + """When entity is added to Home Assistant.""" + self.async_on_remove( + async_dispatcher_connect(self.hass, DOMAIN, self.async_write_ha_state) + ) + if self.device_type in SERVICES: + self.session.entity_lookup[self.entity_id] = self.node_id diff --git a/homeassistant/components/hive/binary_sensor.py b/homeassistant/components/hive/binary_sensor.py index 97900c2852e27..27c648f554bdd 100644 --- a/homeassistant/components/hive/binary_sensor.py +++ b/homeassistant/components/hive/binary_sensor.py @@ -1,39 +1,26 @@ """Support for the Hive binary sensors.""" -from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.components.binary_sensor import BinarySensorEntity -from . import DATA_HIVE, DOMAIN +from . import DATA_HIVE, DOMAIN, HiveEntity -DEVICETYPE_DEVICE_CLASS = { - 'motionsensor': 'motion', - 'contactsensor': 'opening', -} +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)]) + session = hass.data.get(DATA_HIVE) + devs = [] + for dev in discovery_info: + devs.append(HiveBinarySensorEntity(session, dev)) + add_entities(devs) -class HiveBinarySensorEntity(BinarySensorDevice): +class HiveBinarySensorEntity(HiveEntity, BinarySensorEntity): """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.""" @@ -42,17 +29,7 @@ def unique_id(self): @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() + return {"identifiers": {(DOMAIN, self.unique_id)}, "name": self.name} @property def device_class(self): @@ -72,11 +49,9 @@ def device_state_attributes(self): @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) + 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) + self.attributes = self.session.attributes.state_attributes(self.node_id) diff --git a/homeassistant/components/hive/climate.py b/homeassistant/components/hive/climate.py index ab9b63dad6094..33c8fed4ecad4 100644 --- a/homeassistant/components/hive/climate.py +++ b/homeassistant/components/hive/climate.py @@ -1,63 +1,63 @@ """Support for the Hive climate devices.""" -from homeassistant.components.climate import ClimateDevice +from homeassistant.components.climate import ClimateEntity 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 + CURRENT_HVAC_HEAT, + CURRENT_HVAC_IDLE, + CURRENT_HVAC_OFF, + HVAC_MODE_AUTO, + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + PRESET_BOOST, + PRESET_NONE, + SUPPORT_PRESET_MODE, + SUPPORT_TARGET_TEMPERATURE, +) +from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS + +from . import DATA_HIVE, DOMAIN, HiveEntity, refresh_system HIVE_TO_HASS_STATE = { - 'SCHEDULE': STATE_AUTO, - 'MANUAL': STATE_HEAT, - 'ON': STATE_ON, - 'OFF': STATE_OFF, + "SCHEDULE": HVAC_MODE_AUTO, + "MANUAL": HVAC_MODE_HEAT, + "OFF": HVAC_MODE_OFF, } HASS_TO_HIVE_STATE = { - STATE_AUTO: 'SCHEDULE', - STATE_HEAT: 'MANUAL', - STATE_ON: 'ON', - STATE_OFF: 'OFF', + HVAC_MODE_AUTO: "SCHEDULE", + HVAC_MODE_HEAT: "MANUAL", + HVAC_MODE_OFF: "OFF", +} + +HIVE_TO_HASS_HVAC_ACTION = { + "UNKNOWN": CURRENT_HVAC_OFF, + False: CURRENT_HVAC_IDLE, + True: CURRENT_HVAC_HEAT, } -SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE | - SUPPORT_OPERATION_MODE | - SUPPORT_AUX_HEAT) +SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_PRESET_MODE +SUPPORT_HVAC = [HVAC_MODE_AUTO, HVAC_MODE_HEAT, HVAC_MODE_OFF] +SUPPORT_PRESET = [PRESET_NONE, PRESET_BOOST] 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)]) + session = hass.data.get(DATA_HIVE) + devs = [] + for dev in discovery_info: + devs.append(HiveClimateEntity(session, dev)) + add_entities(devs) -class HiveClimateEntity(ClimateDevice): +class HiveClimateEntity(HiveEntity, ClimateEntity): """Hive Climate Device.""" - def __init__(self, hivesession, hivedevice): + def __init__(self, hive_session, hive_device): """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) + super().__init__(hive_session, hive_device) + self.thermostat_node_id = hive_device["Thermostat_NodeID"] @property def unique_id(self): @@ -67,33 +67,23 @@ def unique_id(self): @property def device_info(self): """Return device information.""" - return { - 'identifiers': { - (DOMAIN, self.unique_id) - }, - 'name': self.name - } + 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" + friendly_name = "Heating" + if self.node_name is not None: + if self.device_type == "TRV": + friendly_name = self.node_name + else: + friendly_name = f"{self.node_name} {friendly_name}" + return friendly_name @property @@ -101,6 +91,29 @@ def device_state_attributes(self): """Show Device Attributes.""" return self.attributes + @property + def hvac_modes(self): + """Return the list of available hvac operation modes. + + Need to be a subset of HVAC_MODES. + """ + return SUPPORT_HVAC + + @property + def hvac_mode(self): + """Return hvac operation ie. heat, cool mode. + + Need to be one of HVAC_MODE_*. + """ + return HIVE_TO_HASS_STATE[self.session.heating.get_mode(self.node_id)] + + @property + def hvac_action(self): + """Return current HVAC action.""" + return HIVE_TO_HASS_HVAC_ACTION[ + self.session.heating.operational_status(self.node_id, self.device_type) + ] + @property def temperature_unit(self): """Return the unit of measurement.""" @@ -109,105 +122,64 @@ def temperature_unit(self): @property def current_temperature(self): """Return the current temperature.""" - if self.device_type == "Heating": - return self.session.heating.current_temperature(self.node_id) + 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) + 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) + 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) + return self.session.heating.max_temperature(self.node_id) @property - def operation_list(self): - """List of the operation modes.""" - return self.modes + def preset_mode(self): + """Return the current preset mode, e.g., home, away, temp.""" + if ( + self.device_type == "Heating" + and self.session.heating.get_boost(self.node_id) == "ON" + ): + return PRESET_BOOST + return None @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 preset_modes(self): + """Return a list of available preset modes.""" + return SUPPORT_PRESET + @refresh_system + def set_hvac_mode(self, hvac_mode): + """Set new target hvac mode.""" + new_mode = HASS_TO_HIVE_STATE[hvac_mode] + self.session.heating.set_mode(self.node_id, new_mode) + + @refresh_system 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) + self.session.heating.set_target_temperature(self.node_id, new_temperature) - @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": + @refresh_system + def set_preset_mode(self, preset_mode): + """Set new preset mode.""" + if preset_mode == PRESET_NONE and self.preset_mode == PRESET_BOOST: 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) + elif preset_mode == PRESET_BOOST: + curtemp = round(self.current_temperature * 2) / 2 + temperature = curtemp + 0.5 + self.session.heating.turn_boost_on(self.node_id, 30, temperature) 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) + self.attributes = self.session.attributes.state_attributes( + self.thermostat_node_id + ) diff --git a/homeassistant/components/hive/light.py b/homeassistant/components/hive/light.py index 67331b12b35c4..d6a9d1f400b10 100644 --- a/homeassistant/components/hive/light.py +++ b/homeassistant/components/hive/light.py @@ -1,36 +1,37 @@ """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) + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP, + ATTR_HS_COLOR, + SUPPORT_BRIGHTNESS, + SUPPORT_COLOR, + SUPPORT_COLOR_TEMP, + LightEntity, +) import homeassistant.util.color as color_util -from . import DATA_HIVE, DOMAIN +from . import DATA_HIVE, DOMAIN, HiveEntity, refresh_system 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)]) + session = hass.data.get(DATA_HIVE) + devs = [] + for dev in discovery_info: + devs.append(HiveDeviceLight(session, dev)) + add_entities(devs) -class HiveDeviceLight(Light): +class HiveDeviceLight(HiveEntity, LightEntity): """Hive Active Light Device.""" - def __init__(self, hivesession, hivedevice): + def __init__(self, hive_session, hive_device): """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) + super().__init__(hive_session, hive_device) + self.light_device_type = hive_device["Hive_Light_DeviceType"] @property def unique_id(self): @@ -40,17 +41,7 @@ def unique_id(self): @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() + return {"identifiers": {(DOMAIN, self.unique_id)}, "name": self.name} @property def name(self): @@ -70,22 +61,28 @@ def brightness(self): @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": + 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": + 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": + if ( + self.light_device_type == "tuneablelight" + or self.light_device_type == "colourtuneablelight" + ): return self.session.light.get_color_temp(self.node_id) @property @@ -100,6 +97,7 @@ def is_on(self): """Return true if light is on.""" return self.session.light.get_state(self.node_id) + @refresh_system def turn_on(self, **kwargs): """Instruct the light to turn on.""" new_brightness = None @@ -107,7 +105,7 @@ def turn_on(self, **kwargs): new_color = None if ATTR_BRIGHTNESS in kwargs: tmp_new_brightness = kwargs.get(ATTR_BRIGHTNESS) - percentage_brightness = ((tmp_new_brightness / 255) * 100) + percentage_brightness = (tmp_new_brightness / 255) * 100 new_brightness = int(round(percentage_brightness / 5.0) * 5.0) if new_brightness == 0: new_brightness = 5 @@ -120,18 +118,18 @@ def turn_on(self, **kwargs): 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) + self.session.light.turn_on( + self.node_id, + self.light_device_type, + new_brightness, + new_color_temp, + new_color, + ) + @refresh_system 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): @@ -140,15 +138,13 @@ def supported_features(self): if self.light_device_type == "warmwhitelight": supported_features = SUPPORT_BRIGHTNESS elif self.light_device_type == "tuneablelight": - supported_features = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP) + supported_features = SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP elif self.light_device_type == "colourtuneablelight": - supported_features = ( - SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_COLOR) + 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) + self.attributes = self.session.attributes.state_attributes(self.node_id) diff --git a/homeassistant/components/hive/manifest.json b/homeassistant/components/hive/manifest.json index 76403f293ac0e..060a1a0a200d6 100644 --- a/homeassistant/components/hive/manifest.json +++ b/homeassistant/components/hive/manifest.json @@ -1,13 +1,7 @@ { "domain": "hive", "name": "Hive", - "documentation": "https://www.home-assistant.io/components/hive", - "requirements": [ - "pyhiveapi==0.2.17" - ], - "dependencies": [], - "codeowners": [ - "@Rendili", - "@KJonline" - ] + "documentation": "https://www.home-assistant.io/integrations/hive", + "requirements": ["pyhiveapi==0.2.20.1"], + "codeowners": ["@Rendili", "@KJonline"] } diff --git a/homeassistant/components/hive/sensor.py b/homeassistant/components/hive/sensor.py index b8887d27409b2..360fb61bfbee8 100644 --- a/homeassistant/components/hive/sensor.py +++ b/homeassistant/components/hive/sensor.py @@ -2,16 +2,16 @@ from homeassistant.const import TEMP_CELSIUS from homeassistant.helpers.entity import Entity -from . import DATA_HIVE, DOMAIN +from . import DATA_HIVE, DOMAIN, HiveEntity FRIENDLY_NAMES = { - 'Hub_OnlineStatus': 'Hive Hub Status', - 'Hive_OutsideTemperature': 'Outside Temperature', + "Hub_OnlineStatus": "Hive Hub Status", + "Hive_OutsideTemperature": "Outside Temperature", } DEVICETYPE_ICONS = { - 'Hub_OnlineStatus': 'mdi:switch', - 'Hive_OutsideTemperature': 'mdi:thermometer', + "Hub_OnlineStatus": "mdi:switch", + "Hive_OutsideTemperature": "mdi:thermometer", } @@ -19,27 +19,18 @@ 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)]) + session = hass.data.get(DATA_HIVE) + devs = [] + for dev in discovery_info: + if dev["HA_DeviceType"] in FRIENDLY_NAMES: + devs.append(HiveSensorEntity(session, dev)) + add_entities(devs) -class HiveSensorEntity(Entity): +class HiveSensorEntity(HiveEntity, 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.""" @@ -48,17 +39,7 @@ def unique_id(self): @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() + return {"identifiers": {(DOMAIN, self.unique_id)}, "name": self.name} @property def name(self): @@ -86,6 +67,4 @@ def icon(self): 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) + self.session.core.update_data(self.node_id) diff --git a/homeassistant/components/hive/services.yaml b/homeassistant/components/hive/services.yaml new file mode 100644 index 0000000000000..f09baea7655f2 --- /dev/null +++ b/homeassistant/components/hive/services.yaml @@ -0,0 +1,24 @@ +boost_heating: + description: Set the boost mode ON defining the period of time and the desired target temperature for the boost. + fields: + entity_id: + description: Enter the entity_id for the device required to set the boost mode. + example: "climate.heating" + time_period: + description: Set the time period for the boost. + example: "01:30:00" + temperature: + description: Set the target temperature for the boost period. + example: "20.5" +boost_hot_water: + description: "Set the boost mode ON or OFF defining the period of time for the boost." + fields: + entity_id: + description: Enter the entity_id for the device reuired to set the boost mode. + example: "water_heater.hot_water" + time_period: + description: Set the time period for the boost. + example: "01:30:00" + on_off: + description: Set the boost function on or off. + example: "on" diff --git a/homeassistant/components/hive/switch.py b/homeassistant/components/hive/switch.py index ea4094d573cef..734581b0db378 100644 --- a/homeassistant/components/hive/switch.py +++ b/homeassistant/components/hive/switch.py @@ -1,33 +1,24 @@ """Support for the Hive switches.""" -from homeassistant.components.switch import SwitchDevice +from homeassistant.components.switch import SwitchEntity -from . import DATA_HIVE, DOMAIN +from . import DATA_HIVE, DOMAIN, HiveEntity, refresh_system 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)]) + session = hass.data.get(DATA_HIVE) + devs = [] + for dev in discovery_info: + devs.append(HiveDevicePlug(session, dev)) + add_entities(devs) -class HiveDevicePlug(SwitchDevice): +class HiveDevicePlug(HiveEntity, SwitchEntity): """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.""" @@ -36,17 +27,7 @@ def unique_id(self): @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() + return {"identifiers": {(DOMAIN, self.unique_id)}, "name": self.name} @property def name(self): @@ -68,20 +49,17 @@ def is_on(self): """Return true if switch is on.""" return self.session.switch.get_state(self.node_id) + @refresh_system 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) + @refresh_system 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) + self.attributes = self.session.attributes.state_attributes(self.node_id) diff --git a/homeassistant/components/hive/water_heater.py b/homeassistant/components/hive/water_heater.py new file mode 100644 index 0000000000000..693fd6f322b8e --- /dev/null +++ b/homeassistant/components/hive/water_heater.py @@ -0,0 +1,80 @@ +"""Support for hive water heaters.""" +from homeassistant.components.water_heater import ( + STATE_ECO, + STATE_OFF, + STATE_ON, + SUPPORT_OPERATION_MODE, + WaterHeaterEntity, +) +from homeassistant.const import TEMP_CELSIUS + +from . import DATA_HIVE, DOMAIN, HiveEntity, refresh_system + +SUPPORT_FLAGS_HEATER = SUPPORT_OPERATION_MODE + +HIVE_TO_HASS_STATE = {"SCHEDULE": STATE_ECO, "ON": STATE_ON, "OFF": STATE_OFF} +HASS_TO_HIVE_STATE = {STATE_ECO: "SCHEDULE", STATE_ON: "ON", STATE_OFF: "OFF"} +SUPPORT_WATER_HEATER = [STATE_ECO, STATE_ON, STATE_OFF] + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Hive water heater devices.""" + if discovery_info is None: + return + + session = hass.data.get(DATA_HIVE) + devs = [] + for dev in discovery_info: + devs.append(HiveWaterHeater(session, dev)) + add_entities(devs) + + +class HiveWaterHeater(HiveEntity, WaterHeaterEntity): + """Hive Water Heater Device.""" + + @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_HEATER + + @property + def name(self): + """Return the name of the water heater.""" + if self.node_name is None: + self.node_name = "Hot Water" + return self.node_name + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return TEMP_CELSIUS + + @property + def current_operation(self): + """Return current operation.""" + return HIVE_TO_HASS_STATE[self.session.hotwater.get_mode(self.node_id)] + + @property + def operation_list(self): + """List of available operation modes.""" + return SUPPORT_WATER_HEATER + + @refresh_system + def set_operation_mode(self, operation_mode): + """Set operation mode.""" + new_mode = HASS_TO_HIVE_STATE[operation_mode] + self.session.hotwater.set_mode(self.node_id, new_mode) + + def update(self): + """Update all Node data from Hive.""" + self.session.core.update_data(self.node_id) diff --git a/homeassistant/components/hlk_sw16/__init__.py b/homeassistant/components/hlk_sw16/__init__.py index 79de0bd18be1c..3319ce6bee7bf 100644 --- a/homeassistant/components/hlk_sw16/__init__.py +++ b/homeassistant/components/hlk_sw16/__init__.py @@ -1,52 +1,65 @@ """Support for HLK-SW16 relay switches.""" import logging +from hlk_sw16 import create_hlk_sw16_connection import voluptuous as vol from homeassistant.const import ( - CONF_HOST, CONF_PORT, - EVENT_HOMEASSISTANT_STOP, CONF_SWITCHES, CONF_NAME) + CONF_HOST, + CONF_NAME, + CONF_PORT, + CONF_SWITCHES, + EVENT_HOMEASSISTANT_STOP, +) 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) + async_dispatcher_connect, + async_dispatcher_send, +) +from homeassistant.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) -DATA_DEVICE_REGISTER = 'hlk_sw16_device_register' +DATA_DEVICE_REGISTER = "hlk_sw16_device_register" DEFAULT_RECONNECT_INTERVAL = 10 +DEFAULT_KEEP_ALIVE_INTERVAL = 3 CONNECTION_TIMEOUT = 10 DEFAULT_PORT = 8080 -DOMAIN = 'hlk_sw16' - -SIGNAL_AVAILABILITY = 'hlk_sw16_device_available_{}' +DOMAIN = "hlk_sw16" -SWITCH_SCHEMA = vol.Schema({ - vol.Optional(CONF_NAME): cv.string, -}) +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) + 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): @@ -58,20 +71,18 @@ def add_device(device): @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) + _LOGGER.warning("HLK-SW16 %s disconnected", device) + async_dispatcher_send(hass, f"hlk_sw16_device_available_{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) + _LOGGER.warning("HLK-SW16 %s connected", device) + async_dispatcher_send(hass, f"hlk_sw16_device_available_{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) + _LOGGER.info("Initiating HLK-SW16 connection to %s", device) client = await create_hlk_sw16_connection( host=host, @@ -80,21 +91,23 @@ async def connect(): reconnect_callback=reconnected, loop=hass.loop, timeout=CONNECTION_TIMEOUT, - reconnect_interval=DEFAULT_RECONNECT_INTERVAL) + reconnect_interval=DEFAULT_RECONNECT_INTERVAL, + keep_alive_interval=DEFAULT_KEEP_ALIVE_INTERVAL, + ) hass.data[DATA_DEVICE_REGISTER][device] = client # Load platforms hass.async_create_task( - async_load_platform(hass, 'switch', DOMAIN, - (switches, device), - config)) + 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()) + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, lambda x: client.stop() + ) - _LOGGER.info('Connected to HLK-SW16 device: %s', device) + _LOGGER.info("Connected to HLK-SW16 device: %s", device) hass.loop.create_task(connect()) @@ -121,10 +134,9 @@ def __init__(self, relay_name, device_port, device_id, client): @callback def handle_event_callback(self, event): """Propagate changes through ha.""" - _LOGGER.debug("Relay %s new state callback: %r", - self._device_port, event) + _LOGGER.debug("Relay %s new state callback: %r", self._device_port, event) self._is_on = event - self.async_schedule_update_ha_state() + self.async_write_ha_state() @property def should_poll(self): @@ -144,13 +156,18 @@ def available(self): @callback def _availability_callback(self, availability): """Update availability state.""" - self.async_schedule_update_ha_state() + self.async_write_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._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) + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"hlk_sw16_device_available_{self._device_id}", + self._availability_callback, + ) + ) diff --git a/homeassistant/components/hlk_sw16/manifest.json b/homeassistant/components/hlk_sw16/manifest.json index 5266b81ab0383..7574076fd433f 100644 --- a/homeassistant/components/hlk_sw16/manifest.json +++ b/homeassistant/components/hlk_sw16/manifest.json @@ -1,10 +1,7 @@ { "domain": "hlk_sw16", - "name": "Hlk sw16", - "documentation": "https://www.home-assistant.io/components/hlk_sw16", - "requirements": [ - "hlk-sw16==0.0.7" - ], - "dependencies": [], + "name": "Hi-Link HLK-SW16", + "documentation": "https://www.home-assistant.io/integrations/hlk_sw16", + "requirements": ["hlk-sw16==0.0.8"], "codeowners": [] } diff --git a/homeassistant/components/hlk_sw16/switch.py b/homeassistant/components/hlk_sw16/switch.py index b7353f037c126..e9c190678a652 100644 --- a/homeassistant/components/hlk_sw16/switch.py +++ b/homeassistant/components/hlk_sw16/switch.py @@ -22,8 +22,7 @@ def devices_from_config(hass, domain_config): return devices -async def async_setup_platform( - hass, config, async_add_entities, discovery_info=None): +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)) diff --git a/homeassistant/components/homeassistant/__init__.py b/homeassistant/components/homeassistant/__init__.py index ef01d133cff62..e0a4d88ec6a4f 100644 --- a/homeassistant/components/homeassistant/__init__.py +++ b/homeassistant/components/homeassistant/__init__.py @@ -2,33 +2,39 @@ import asyncio import itertools as it import logging -from typing import Awaitable import voluptuous as vol -import homeassistant.core as ha +from homeassistant.auth.permissions.const import CAT_ENTITIES, POLICY_CONTROL 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_ENTITY_ID, + ATTR_LATITUDE, + ATTR_LONGITUDE, + RESTART_EXIT_CODE, + SERVICE_HOMEASSISTANT_RESTART, + SERVICE_HOMEASSISTANT_STOP, + SERVICE_TOGGLE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +import homeassistant.core as ha +from homeassistant.exceptions import HomeAssistantError, Unauthorized, UnknownUser from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.service import async_extract_entity_ids _LOGGER = logging.getLogger(__name__) DOMAIN = ha.DOMAIN -SERVICE_RELOAD_CORE_CONFIG = 'reload_core_config' -SERVICE_CHECK_CONFIG = 'check_config' -SERVICE_UPDATE_ENTITY = 'update_entity' -SCHEMA_UPDATE_ENTITY = vol.Schema({ - ATTR_ENTITY_ID: cv.entity_ids -}) +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]: +async def async_setup(hass: ha.HomeAssistant, config: dict) -> 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) @@ -36,17 +42,27 @@ async def async_handle_turn_service(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) + "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]) + by_domain = it.groupby( + sorted(entity_ids), lambda item: ha.split_entity_id(item)[0] + ) tasks = [] for domain, ent_ids in by_domain: + # This leads to endless loop. + if domain == DOMAIN: + _LOGGER.warning( + "Called service homeassistant.%s with invalid entity IDs %s", + service.service, + ", ".join(ent_ids), + ) + continue + # 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. @@ -61,24 +77,24 @@ async def async_handle_turn_service(service): # 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)) + tasks.append( + hass.services.async_call(domain, service.service, data, blocking) + ) - await asyncio.wait(tasks, loop=hass.loop) + if tasks: + await asyncio.gather(*tasks) + + service_schema = vol.Schema({ATTR_ENTITY_ID: cv.entity_ids}, extra=vol.ALLOW_EXTRA) hass.services.async_register( - ha.DOMAIN, SERVICE_TURN_OFF, async_handle_turn_service) + ha.DOMAIN, SERVICE_TURN_OFF, async_handle_turn_service, schema=service_schema + ) hass.services.async_register( - ha.DOMAIN, SERVICE_TURN_ON, async_handle_turn_service) + ha.DOMAIN, SERVICE_TURN_ON, async_handle_turn_service, schema=service_schema + ) 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 {}")) + ha.DOMAIN, SERVICE_TOGGLE, async_handle_turn_service, schema=service_schema + ) async def async_handle_core_service(call): """Service handler for handling core services.""" @@ -94,8 +110,10 @@ async def async_handle_core_service(call): 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)) + "Config error. See [the logs](/developer-tools/logs) for details.", + "Config validating", + f"{ha.DOMAIN}.check_config", + ) return if call.service == SERVICE_HOMEASSISTANT_RESTART: @@ -103,21 +121,48 @@ async def async_handle_core_service(call): 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 call.context.user_id: + user = await hass.auth.async_get_user(call.context.user_id) + + if user is None: + raise UnknownUser( + context=call.context, + permission=POLICY_CONTROL, + user_id=call.context.user_id, + ) + + for entity in call.data[ATTR_ENTITY_ID]: + if not user.permissions.check_entity(entity, POLICY_CONTROL): + raise Unauthorized( + context=call.context, + permission=POLICY_CONTROL, + user_id=call.context.user_id, + perm_category=CAT_ENTITIES, + ) + + tasks = [ + hass.helpers.entity_component.async_update_entity(entity) + for entity in call.data[ATTR_ENTITY_ID] + ] if tasks: await asyncio.wait(tasks) + hass.helpers.service.async_register_admin_service( + ha.DOMAIN, SERVICE_HOMEASSISTANT_STOP, async_handle_core_service + ) + hass.helpers.service.async_register_admin_service( + ha.DOMAIN, SERVICE_HOMEASSISTANT_RESTART, async_handle_core_service + ) + hass.helpers.service.async_register_admin_service( + ha.DOMAIN, SERVICE_CHECK_CONFIG, async_handle_core_service + ) 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) + 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.""" @@ -128,10 +173,23 @@ async def async_handle_reload_config(call): return # auth only processed during startup - await conf_util.async_process_ha_core_config( - hass, conf.get(ha.DOMAIN) or {}) - - hass.services.async_register( - ha.DOMAIN, SERVICE_RELOAD_CORE_CONFIG, async_handle_reload_config) + 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 index b612c3a9fa645..027d1b9376d6d 100644 --- a/homeassistant/components/homeassistant/manifest.json +++ b/homeassistant/components/homeassistant/manifest.json @@ -1,10 +1,7 @@ { "domain": "homeassistant", "name": "Home Assistant Core Integration", - "documentation": "https://www.home-assistant.io/components/homeassistant", - "requirements": [], - "dependencies": [], - "codeowners": [ - "@home-assistant/core" - ] + "documentation": "https://www.home-assistant.io/integrations/homeassistant", + "codeowners": ["@home-assistant/core"], + "quality_scale": "internal" } diff --git a/homeassistant/components/homeassistant/scene.py b/homeassistant/components/homeassistant/scene.py index 617b56241108d..d14ef438a6640 100644 --- a/homeassistant/components/homeassistant/scene.py +++ b/homeassistant/components/homeassistant/scene.py @@ -1,97 +1,313 @@ """Allow users to set and activate scenes.""" from collections import namedtuple +import logging +from typing import Any, List import voluptuous as vol +from homeassistant import config as conf_util +from homeassistant.components.light import ATTR_TRANSITION +from homeassistant.components.scene import DOMAIN as SCENE_DOMAIN, STATES, Scene 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 - + ATTR_ENTITY_ID, + ATTR_STATE, + CONF_ENTITIES, + CONF_ICON, + CONF_ID, + CONF_NAME, + CONF_PLATFORM, + SERVICE_RELOAD, + STATE_OFF, + STATE_ON, +) +from homeassistant.core import DOMAIN as HA_DOMAIN, HomeAssistant, State, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import ( + config_per_platform, + config_validation as cv, + entity_platform, +) +from homeassistant.helpers.state import async_reproduce_state +from homeassistant.loader import async_get_integration -def _process_config(scene_config): - """Process passed in config into a format to work with. - Async friendly. - """ - name = scene_config.get(CONF_NAME) +def _convert_states(states): + """Convert state definitions to State objects.""" + result = {} - states = {} - c_entities = dict(scene_config.get(CONF_ENTITIES, {})) + for entity_id in states: + entity_id = cv.entity_id(entity_id) - for entity_id in c_entities: - if isinstance(c_entities[entity_id], dict): - entity_attrs = c_entities[entity_id].copy() + if isinstance(states[entity_id], dict): + entity_attrs = states[entity_id].copy() state = entity_attrs.pop(ATTR_STATE, None) attributes = entity_attrs else: - state = c_entities[entity_id] + state = states[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) + elif not isinstance(state, str): + raise vol.Invalid(f"State for {entity_id} should be a string") + + result[entity_id] = State(entity_id, state, attributes) + + return result + + +def _ensure_no_intersection(value): + """Validate that entities and snapshot_entities do not overlap.""" + if ( + CONF_SNAPSHOT not in value + or CONF_ENTITIES not in value + or all( + entity_id not in value[CONF_SNAPSHOT] for entity_id in value[CONF_ENTITIES] + ) + ): + return value + + raise vol.Invalid("entities and snapshot_entities must not overlap") + + +CONF_SCENE_ID = "scene_id" +CONF_SNAPSHOT = "snapshot_entities" +DATA_PLATFORM = "homeassistant_scene" +STATES_SCHEMA = vol.All(dict, _convert_states) + + +PLATFORM_SCHEMA = vol.Schema( + { + vol.Required(CONF_PLATFORM): HA_DOMAIN, + vol.Required(STATES): vol.All( + cv.ensure_list, + [ + vol.Schema( + { + vol.Optional(CONF_ID): cv.string, + vol.Required(CONF_NAME): cv.string, + vol.Optional(CONF_ICON): cv.icon, + vol.Required(CONF_ENTITIES): STATES_SCHEMA, + } + ) + ], + ), + }, + extra=vol.ALLOW_EXTRA, +) + +CREATE_SCENE_SCHEMA = vol.All( + cv.has_at_least_one_key(CONF_ENTITIES, CONF_SNAPSHOT), + _ensure_no_intersection, + vol.Schema( + { + vol.Required(CONF_SCENE_ID): cv.slug, + vol.Optional(CONF_ENTITIES, default={}): STATES_SCHEMA, + vol.Optional(CONF_SNAPSHOT, default=[]): cv.entity_ids, + } + ), +) + +SERVICE_APPLY = "apply" +SERVICE_CREATE = "create" +SCENECONFIG = namedtuple("SceneConfig", [CONF_ID, CONF_NAME, CONF_ICON, STATES]) +_LOGGER = logging.getLogger(__name__) + + +@callback +def scenes_with_entity(hass: HomeAssistant, entity_id: str) -> List[str]: + """Return all scenes that reference the entity.""" + if DATA_PLATFORM not in hass.data: + return [] + + platform = hass.data[DATA_PLATFORM] + + return [ + scene_entity.entity_id + for scene_entity in platform.entities.values() + if entity_id in scene_entity.scene_config.states + ] + + +@callback +def entities_in_scene(hass: HomeAssistant, entity_id: str) -> List[str]: + """Return all entities in a scene.""" + if DATA_PLATFORM not in hass.data: + return [] + + platform = hass.data[DATA_PLATFORM] + + entity = platform.entities.get(entity_id) + + if entity is None: + return [] + + return list(entity.scene_config.states) - states[entity_id.lower()] = State(entity_id, state, attributes) - return SCENECONFIG(name, states) +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up Home Assistant scene entries.""" + _process_scenes_config(hass, async_add_entities, config) + + # This platform can be loaded multiple times. Only first time register the service. + if hass.services.has_service(SCENE_DOMAIN, SERVICE_RELOAD): + return + + # Store platform for later. + platform = hass.data[DATA_PLATFORM] = entity_platform.current_platform.get() + + async def reload_config(call): + """Reload the scene config.""" + try: + conf = await conf_util.async_hass_config_yaml(hass) + except HomeAssistantError as err: + _LOGGER.error(err) + return + + integration = await async_get_integration(hass, SCENE_DOMAIN) + + conf = await conf_util.async_process_component_config(hass, conf, integration) + + if not (conf and platform): + return + + await platform.async_reset() + + # Extract only the config for the Home Assistant platform, ignore the rest. + for p_type, p_config in config_per_platform(conf, SCENE_DOMAIN): + if p_type != HA_DOMAIN: + continue + + _process_scenes_config(hass, async_add_entities, p_config) + + hass.helpers.service.async_register_admin_service( + SCENE_DOMAIN, SERVICE_RELOAD, reload_config + ) + + async def apply_service(call): + """Apply a scene.""" + reproduce_options = {} + + if ATTR_TRANSITION in call.data: + reproduce_options[ATTR_TRANSITION] = call.data.get(ATTR_TRANSITION) + + await async_reproduce_state( + hass, + call.data[CONF_ENTITIES].values(), + context=call.context, + reproduce_options=reproduce_options, + ) + + hass.services.async_register( + SCENE_DOMAIN, + SERVICE_APPLY, + apply_service, + vol.Schema( + { + vol.Optional(ATTR_TRANSITION): vol.All( + vol.Coerce(float), vol.Clamp(min=0, max=6553) + ), + vol.Required(CONF_ENTITIES): STATES_SCHEMA, + } + ), + ) + + async def create_service(call): + """Create a scene.""" + snapshot = call.data[CONF_SNAPSHOT] + entities = call.data[CONF_ENTITIES] + + for entity_id in snapshot: + state = hass.states.get(entity_id) + if state is None: + _LOGGER.warning( + "Entity %s does not exist and therefore cannot be snapshotted", + entity_id, + ) + continue + entities[entity_id] = State(entity_id, state.state, state.attributes) + + if not entities: + _LOGGER.warning("Empty scenes are not allowed") + return + + scene_config = SCENECONFIG(None, call.data[CONF_SCENE_ID], None, entities) + entity_id = f"{SCENE_DOMAIN}.{scene_config.name}" + old = platform.entities.get(entity_id) + if old is not None: + if not old.from_service: + _LOGGER.warning("The scene %s already exists", entity_id) + return + await platform.async_remove_entity(entity_id) + async_add_entities([HomeAssistantScene(hass, scene_config, from_service=True)]) + + hass.services.async_register( + SCENE_DOMAIN, SERVICE_CREATE, create_service, CREATE_SCENE_SCHEMA + ) + + +def _process_scenes_config(hass, async_add_entities, config): + """Process multiple scenes and add them.""" + scene_config = config[STATES] + + # Check empty list + if not scene_config: + return + + async_add_entities( + HomeAssistantScene( + hass, + SCENECONFIG( + scene.get(CONF_ID), + scene[CONF_NAME], + scene.get(CONF_ICON), + scene[CONF_ENTITIES], + ), + ) + for scene in scene_config + ) class HomeAssistantScene(Scene): """A scene is a group of entities and the states we want them to be.""" - def __init__(self, hass, scene_config): + def __init__(self, hass, scene_config, from_service=False): """Initialize the scene.""" self.hass = hass self.scene_config = scene_config + self.from_service = from_service @property def name(self): """Return the name of the scene.""" return self.scene_config.name + @property + def icon(self): + """Return the icon of the scene.""" + return self.scene_config.icon + + @property + def unique_id(self): + """Return unique ID.""" + return self.scene_config.id + @property def device_state_attributes(self): """Return the scene state attributes.""" - return { - ATTR_ENTITY_ID: list(self.scene_config.states.keys()), - } + attributes = {ATTR_ENTITY_ID: list(self.scene_config.states)} + unique_id = self.unique_id + if unique_id is not None: + attributes[CONF_ID] = unique_id + return attributes - async def async_activate(self): + async def async_activate(self, **kwargs: Any) -> None: """Activate scene. Try to get entities into requested state.""" await async_reproduce_state( - self.hass, self.scene_config.states.values(), True) + self.hass, + self.scene_config.states.values(), + context=self._context, + reproduce_options=kwargs, + ) diff --git a/homeassistant/components/homeassistant/services.yaml b/homeassistant/components/homeassistant/services.yaml index 2219564abb875..cb3efb0d524a6 100644 --- a/homeassistant/components/homeassistant/services.yaml +++ b/homeassistant/components/homeassistant/services.yaml @@ -7,6 +7,16 @@ reload_core_config: restart: description: Restart the Home Assistant service. +set_location: + description: Update the Home Assistant location. + fields: + latitude: + description: Latitude of your location + example: 32.87336 + longitude: + description: Longitude of your location + example: 117.22743 + stop: description: Stop the Home Assistant service. diff --git a/homeassistant/components/homeassistant/translations/af.json b/homeassistant/components/homeassistant/translations/af.json new file mode 100644 index 0000000000000..04b5b760f604c --- /dev/null +++ b/homeassistant/components/homeassistant/translations/af.json @@ -0,0 +1,3 @@ +{ + "title": "Home Assistant" +} \ No newline at end of file diff --git a/homeassistant/components/homeassistant/translations/bg.json b/homeassistant/components/homeassistant/translations/bg.json new file mode 100644 index 0000000000000..04b5b760f604c --- /dev/null +++ b/homeassistant/components/homeassistant/translations/bg.json @@ -0,0 +1,3 @@ +{ + "title": "Home Assistant" +} \ No newline at end of file diff --git a/homeassistant/components/homeassistant/translations/ca.json b/homeassistant/components/homeassistant/translations/ca.json new file mode 100644 index 0000000000000..04b5b760f604c --- /dev/null +++ b/homeassistant/components/homeassistant/translations/ca.json @@ -0,0 +1,3 @@ +{ + "title": "Home Assistant" +} \ No newline at end of file diff --git a/homeassistant/components/homeassistant/translations/cs.json b/homeassistant/components/homeassistant/translations/cs.json new file mode 100644 index 0000000000000..04b5b760f604c --- /dev/null +++ b/homeassistant/components/homeassistant/translations/cs.json @@ -0,0 +1,3 @@ +{ + "title": "Home Assistant" +} \ No newline at end of file diff --git a/homeassistant/components/homeassistant/translations/cy.json b/homeassistant/components/homeassistant/translations/cy.json new file mode 100644 index 0000000000000..04b5b760f604c --- /dev/null +++ b/homeassistant/components/homeassistant/translations/cy.json @@ -0,0 +1,3 @@ +{ + "title": "Home Assistant" +} \ No newline at end of file diff --git a/homeassistant/components/homeassistant/translations/da.json b/homeassistant/components/homeassistant/translations/da.json new file mode 100644 index 0000000000000..04b5b760f604c --- /dev/null +++ b/homeassistant/components/homeassistant/translations/da.json @@ -0,0 +1,3 @@ +{ + "title": "Home Assistant" +} \ No newline at end of file diff --git a/homeassistant/components/homeassistant/translations/de.json b/homeassistant/components/homeassistant/translations/de.json new file mode 100644 index 0000000000000..04b5b760f604c --- /dev/null +++ b/homeassistant/components/homeassistant/translations/de.json @@ -0,0 +1,3 @@ +{ + "title": "Home Assistant" +} \ No newline at end of file diff --git a/homeassistant/components/homeassistant/translations/el.json b/homeassistant/components/homeassistant/translations/el.json new file mode 100644 index 0000000000000..04b5b760f604c --- /dev/null +++ b/homeassistant/components/homeassistant/translations/el.json @@ -0,0 +1,3 @@ +{ + "title": "Home Assistant" +} \ No newline at end of file diff --git a/homeassistant/components/homeassistant/translations/en.json b/homeassistant/components/homeassistant/translations/en.json new file mode 100644 index 0000000000000..04b5b760f604c --- /dev/null +++ b/homeassistant/components/homeassistant/translations/en.json @@ -0,0 +1,3 @@ +{ + "title": "Home Assistant" +} \ No newline at end of file diff --git a/homeassistant/components/homeassistant/translations/es-419.json b/homeassistant/components/homeassistant/translations/es-419.json new file mode 100644 index 0000000000000..04b5b760f604c --- /dev/null +++ b/homeassistant/components/homeassistant/translations/es-419.json @@ -0,0 +1,3 @@ +{ + "title": "Home Assistant" +} \ No newline at end of file diff --git a/homeassistant/components/homeassistant/translations/es.json b/homeassistant/components/homeassistant/translations/es.json new file mode 100644 index 0000000000000..04b5b760f604c --- /dev/null +++ b/homeassistant/components/homeassistant/translations/es.json @@ -0,0 +1,3 @@ +{ + "title": "Home Assistant" +} \ No newline at end of file diff --git a/homeassistant/components/homeassistant/translations/et.json b/homeassistant/components/homeassistant/translations/et.json new file mode 100644 index 0000000000000..04b5b760f604c --- /dev/null +++ b/homeassistant/components/homeassistant/translations/et.json @@ -0,0 +1,3 @@ +{ + "title": "Home Assistant" +} \ No newline at end of file diff --git a/homeassistant/components/homeassistant/translations/eu.json b/homeassistant/components/homeassistant/translations/eu.json new file mode 100644 index 0000000000000..04b5b760f604c --- /dev/null +++ b/homeassistant/components/homeassistant/translations/eu.json @@ -0,0 +1,3 @@ +{ + "title": "Home Assistant" +} \ No newline at end of file diff --git a/homeassistant/components/homeassistant/translations/fa.json b/homeassistant/components/homeassistant/translations/fa.json new file mode 100644 index 0000000000000..04b5b760f604c --- /dev/null +++ b/homeassistant/components/homeassistant/translations/fa.json @@ -0,0 +1,3 @@ +{ + "title": "Home Assistant" +} \ No newline at end of file diff --git a/homeassistant/components/homeassistant/translations/fi.json b/homeassistant/components/homeassistant/translations/fi.json new file mode 100644 index 0000000000000..04b5b760f604c --- /dev/null +++ b/homeassistant/components/homeassistant/translations/fi.json @@ -0,0 +1,3 @@ +{ + "title": "Home Assistant" +} \ No newline at end of file diff --git a/homeassistant/components/homeassistant/translations/fr.json b/homeassistant/components/homeassistant/translations/fr.json new file mode 100644 index 0000000000000..04b5b760f604c --- /dev/null +++ b/homeassistant/components/homeassistant/translations/fr.json @@ -0,0 +1,3 @@ +{ + "title": "Home Assistant" +} \ No newline at end of file diff --git a/homeassistant/components/homeassistant/translations/he.json b/homeassistant/components/homeassistant/translations/he.json new file mode 100644 index 0000000000000..04b5b760f604c --- /dev/null +++ b/homeassistant/components/homeassistant/translations/he.json @@ -0,0 +1,3 @@ +{ + "title": "Home Assistant" +} \ No newline at end of file diff --git a/homeassistant/components/homeassistant/translations/hr.json b/homeassistant/components/homeassistant/translations/hr.json new file mode 100644 index 0000000000000..04b5b760f604c --- /dev/null +++ b/homeassistant/components/homeassistant/translations/hr.json @@ -0,0 +1,3 @@ +{ + "title": "Home Assistant" +} \ No newline at end of file diff --git a/homeassistant/components/homeassistant/translations/hu.json b/homeassistant/components/homeassistant/translations/hu.json new file mode 100644 index 0000000000000..04b5b760f604c --- /dev/null +++ b/homeassistant/components/homeassistant/translations/hu.json @@ -0,0 +1,3 @@ +{ + "title": "Home Assistant" +} \ No newline at end of file diff --git a/homeassistant/components/homeassistant/translations/hy.json b/homeassistant/components/homeassistant/translations/hy.json new file mode 100644 index 0000000000000..d4578360f53cd --- /dev/null +++ b/homeassistant/components/homeassistant/translations/hy.json @@ -0,0 +1,3 @@ +{ + "title": "\u054f\u0576\u0561\u0575\u056b\u0576 \u0585\u0563\u0576\u0561\u056f\u0561\u0576" +} \ No newline at end of file diff --git a/homeassistant/components/homeassistant/translations/is.json b/homeassistant/components/homeassistant/translations/is.json new file mode 100644 index 0000000000000..04b5b760f604c --- /dev/null +++ b/homeassistant/components/homeassistant/translations/is.json @@ -0,0 +1,3 @@ +{ + "title": "Home Assistant" +} \ No newline at end of file diff --git a/homeassistant/components/homeassistant/translations/it.json b/homeassistant/components/homeassistant/translations/it.json new file mode 100644 index 0000000000000..04b5b760f604c --- /dev/null +++ b/homeassistant/components/homeassistant/translations/it.json @@ -0,0 +1,3 @@ +{ + "title": "Home Assistant" +} \ No newline at end of file diff --git a/homeassistant/components/homeassistant/translations/ko.json b/homeassistant/components/homeassistant/translations/ko.json new file mode 100644 index 0000000000000..04b5b760f604c --- /dev/null +++ b/homeassistant/components/homeassistant/translations/ko.json @@ -0,0 +1,3 @@ +{ + "title": "Home Assistant" +} \ No newline at end of file diff --git a/homeassistant/components/homeassistant/translations/lb.json b/homeassistant/components/homeassistant/translations/lb.json new file mode 100644 index 0000000000000..04b5b760f604c --- /dev/null +++ b/homeassistant/components/homeassistant/translations/lb.json @@ -0,0 +1,3 @@ +{ + "title": "Home Assistant" +} \ No newline at end of file diff --git a/homeassistant/components/homeassistant/translations/lt.json b/homeassistant/components/homeassistant/translations/lt.json new file mode 100644 index 0000000000000..04b5b760f604c --- /dev/null +++ b/homeassistant/components/homeassistant/translations/lt.json @@ -0,0 +1,3 @@ +{ + "title": "Home Assistant" +} \ No newline at end of file diff --git a/homeassistant/components/homeassistant/translations/lv.json b/homeassistant/components/homeassistant/translations/lv.json new file mode 100644 index 0000000000000..04b5b760f604c --- /dev/null +++ b/homeassistant/components/homeassistant/translations/lv.json @@ -0,0 +1,3 @@ +{ + "title": "Home Assistant" +} \ No newline at end of file diff --git a/homeassistant/components/homeassistant/translations/nb.json b/homeassistant/components/homeassistant/translations/nb.json new file mode 100644 index 0000000000000..d8a4c45301515 --- /dev/null +++ b/homeassistant/components/homeassistant/translations/nb.json @@ -0,0 +1,3 @@ +{ + "title": "" +} \ No newline at end of file diff --git a/homeassistant/components/homeassistant/translations/nl.json b/homeassistant/components/homeassistant/translations/nl.json new file mode 100644 index 0000000000000..04b5b760f604c --- /dev/null +++ b/homeassistant/components/homeassistant/translations/nl.json @@ -0,0 +1,3 @@ +{ + "title": "Home Assistant" +} \ No newline at end of file diff --git a/homeassistant/components/homeassistant/translations/nn.json b/homeassistant/components/homeassistant/translations/nn.json new file mode 100644 index 0000000000000..04b5b760f604c --- /dev/null +++ b/homeassistant/components/homeassistant/translations/nn.json @@ -0,0 +1,3 @@ +{ + "title": "Home Assistant" +} \ No newline at end of file diff --git a/homeassistant/components/homeassistant/translations/no.json b/homeassistant/components/homeassistant/translations/no.json new file mode 100644 index 0000000000000..774c815a5c292 --- /dev/null +++ b/homeassistant/components/homeassistant/translations/no.json @@ -0,0 +1,3 @@ +{ + "title": "Home Assistent" +} \ No newline at end of file diff --git a/homeassistant/components/homeassistant/translations/pl.json b/homeassistant/components/homeassistant/translations/pl.json new file mode 100644 index 0000000000000..04b5b760f604c --- /dev/null +++ b/homeassistant/components/homeassistant/translations/pl.json @@ -0,0 +1,3 @@ +{ + "title": "Home Assistant" +} \ No newline at end of file diff --git a/homeassistant/components/homeassistant/translations/pt-BR.json b/homeassistant/components/homeassistant/translations/pt-BR.json new file mode 100644 index 0000000000000..04b5b760f604c --- /dev/null +++ b/homeassistant/components/homeassistant/translations/pt-BR.json @@ -0,0 +1,3 @@ +{ + "title": "Home Assistant" +} \ No newline at end of file diff --git a/homeassistant/components/homeassistant/translations/pt.json b/homeassistant/components/homeassistant/translations/pt.json new file mode 100644 index 0000000000000..d8a4c45301515 --- /dev/null +++ b/homeassistant/components/homeassistant/translations/pt.json @@ -0,0 +1,3 @@ +{ + "title": "" +} \ No newline at end of file diff --git a/homeassistant/components/homeassistant/translations/ro.json b/homeassistant/components/homeassistant/translations/ro.json new file mode 100644 index 0000000000000..04b5b760f604c --- /dev/null +++ b/homeassistant/components/homeassistant/translations/ro.json @@ -0,0 +1,3 @@ +{ + "title": "Home Assistant" +} \ No newline at end of file diff --git a/homeassistant/components/homeassistant/translations/ru.json b/homeassistant/components/homeassistant/translations/ru.json new file mode 100644 index 0000000000000..04b5b760f604c --- /dev/null +++ b/homeassistant/components/homeassistant/translations/ru.json @@ -0,0 +1,3 @@ +{ + "title": "Home Assistant" +} \ No newline at end of file diff --git a/homeassistant/components/homeassistant/translations/sk.json b/homeassistant/components/homeassistant/translations/sk.json new file mode 100644 index 0000000000000..04b5b760f604c --- /dev/null +++ b/homeassistant/components/homeassistant/translations/sk.json @@ -0,0 +1,3 @@ +{ + "title": "Home Assistant" +} \ No newline at end of file diff --git a/homeassistant/components/homeassistant/translations/sl.json b/homeassistant/components/homeassistant/translations/sl.json new file mode 100644 index 0000000000000..04b5b760f604c --- /dev/null +++ b/homeassistant/components/homeassistant/translations/sl.json @@ -0,0 +1,3 @@ +{ + "title": "Home Assistant" +} \ No newline at end of file diff --git a/homeassistant/components/homeassistant/translations/sv.json b/homeassistant/components/homeassistant/translations/sv.json new file mode 100644 index 0000000000000..04b5b760f604c --- /dev/null +++ b/homeassistant/components/homeassistant/translations/sv.json @@ -0,0 +1,3 @@ +{ + "title": "Home Assistant" +} \ No newline at end of file diff --git a/homeassistant/components/homeassistant/translations/th.json b/homeassistant/components/homeassistant/translations/th.json new file mode 100644 index 0000000000000..04b5b760f604c --- /dev/null +++ b/homeassistant/components/homeassistant/translations/th.json @@ -0,0 +1,3 @@ +{ + "title": "Home Assistant" +} \ No newline at end of file diff --git a/homeassistant/components/homeassistant/translations/tr.json b/homeassistant/components/homeassistant/translations/tr.json new file mode 100644 index 0000000000000..04b5b760f604c --- /dev/null +++ b/homeassistant/components/homeassistant/translations/tr.json @@ -0,0 +1,3 @@ +{ + "title": "Home Assistant" +} \ No newline at end of file diff --git a/homeassistant/components/homeassistant/translations/uk.json b/homeassistant/components/homeassistant/translations/uk.json new file mode 100644 index 0000000000000..04b5b760f604c --- /dev/null +++ b/homeassistant/components/homeassistant/translations/uk.json @@ -0,0 +1,3 @@ +{ + "title": "Home Assistant" +} \ No newline at end of file diff --git a/homeassistant/components/homeassistant/translations/vi.json b/homeassistant/components/homeassistant/translations/vi.json new file mode 100644 index 0000000000000..04b5b760f604c --- /dev/null +++ b/homeassistant/components/homeassistant/translations/vi.json @@ -0,0 +1,3 @@ +{ + "title": "Home Assistant" +} \ No newline at end of file diff --git a/homeassistant/components/homeassistant/translations/zh-Hans.json b/homeassistant/components/homeassistant/translations/zh-Hans.json new file mode 100644 index 0000000000000..04b5b760f604c --- /dev/null +++ b/homeassistant/components/homeassistant/translations/zh-Hans.json @@ -0,0 +1,3 @@ +{ + "title": "Home Assistant" +} \ No newline at end of file diff --git a/homeassistant/components/homeassistant/translations/zh-Hant.json b/homeassistant/components/homeassistant/translations/zh-Hant.json new file mode 100644 index 0000000000000..04b5b760f604c --- /dev/null +++ b/homeassistant/components/homeassistant/translations/zh-Hant.json @@ -0,0 +1,3 @@ +{ + "title": "Home Assistant" +} \ No newline at end of file diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index a37b085c0dc61..184fce2309bba 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -1,36 +1,86 @@ """Support for Apple HomeKit.""" +import asyncio import ipaddress import logging -from zlib import adler32 +from aiohttp import web import voluptuous as vol +from zeroconf import InterfaceChoice -from homeassistant.components import cover -from homeassistant.components.media_player import DEVICE_CLASS_TV +from homeassistant.components.binary_sensor import DEVICE_CLASS_BATTERY_CHARGING +from homeassistant.components.http import HomeAssistantView +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry 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) + ATTR_BATTERY_CHARGING, + ATTR_BATTERY_LEVEL, + ATTR_ENTITY_ID, + ATTR_SERVICE, + CONF_IP_ADDRESS, + CONF_NAME, + CONF_PORT, + DEVICE_CLASS_BATTERY, + EVENT_HOMEASSISTANT_STARTED, + EVENT_HOMEASSISTANT_STOP, +) +from homeassistant.core import CoreState, HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryNotReady, Unauthorized +from homeassistant.helpers import device_registry, entity_registry import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entityfilter import FILTER_SCHEMA +from homeassistant.helpers.entityfilter import ( + BASE_FILTER_SCHEMA, + CONF_EXCLUDE_DOMAINS, + CONF_EXCLUDE_ENTITIES, + CONF_INCLUDE_DOMAINS, + CONF_INCLUDE_ENTITIES, + convert_filter, +) from homeassistant.util import get_local_ip -from homeassistant.util.decorator import Registry +from .accessories import get_accessory +from .aidmanager import AccessoryAidStorage 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) + AID_STORAGE, + ATTR_DISPLAY_NAME, + ATTR_VALUE, + BRIDGE_NAME, + CONF_ADVERTISE_IP, + CONF_AUTO_START, + CONF_ENTITY_CONFIG, + CONF_ENTRY_INDEX, + CONF_FILTER, + CONF_LINKED_BATTERY_CHARGING_SENSOR, + CONF_LINKED_BATTERY_SENSOR, + CONF_SAFE_MODE, + CONF_ZEROCONF_DEFAULT_INTERFACE, + CONFIG_OPTIONS, + DEFAULT_AUTO_START, + DEFAULT_PORT, + DEFAULT_SAFE_MODE, + DEFAULT_ZEROCONF_DEFAULT_INTERFACE, + DOMAIN, + EVENT_HOMEKIT_CHANGED, + HOMEKIT, + HOMEKIT_PAIRING_QR, + HOMEKIT_PAIRING_QR_SECRET, + MANUFACTURER, + SERVICE_HOMEKIT_RESET_ACCESSORY, + SERVICE_HOMEKIT_START, + SHUTDOWN_TIMEOUT, + UNDO_UPDATE_LISTENER, +) from .util import ( - show_setup_message, validate_entity_config, validate_media_player_features) + dismiss_setup_message, + get_persist_fullpath_for_entry_id, + migrate_filesystem_state_data_for_primary_imported_entry_id, + port_is_available, + remove_state_files_for_entry_id, + show_setup_message, + validate_entity_config, +) _LOGGER = logging.getLogger(__name__) -MAX_DEVICES = 100 -TYPES = Registry() +MAX_DEVICES = 150 # #### Driver Status #### STATUS_READY = 0 @@ -38,168 +88,308 @@ 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)), + +def _has_all_unique_names_and_ports(bridges): + """Validate that each homekit bridge configured has a unique name.""" + names = [bridge[CONF_NAME] for bridge in bridges] + ports = [bridge[CONF_PORT] for bridge in bridges] + vol.Schema(vol.Unique())(names) + vol.Schema(vol.Unique())(ports) + return bridges + + +BRIDGE_SCHEMA = vol.Schema( + { + 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_IP_ADDRESS): vol.All(ipaddress.ip_address, cv.string), + vol.Optional(CONF_ADVERTISE_IP): 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_FILTER, default={}): BASE_FILTER_SCHEMA, vol.Optional(CONF_ENTITY_CONFIG, default={}): validate_entity_config, - }) -}, extra=vol.ALLOW_EXTRA) + vol.Optional( + CONF_ZEROCONF_DEFAULT_INTERFACE, default=DEFAULT_ZEROCONF_DEFAULT_INTERFACE, + ): cv.boolean, + }, + extra=vol.ALLOW_EXTRA, +) + +CONFIG_SCHEMA = vol.Schema( + {DOMAIN: vol.All(cv.ensure_list, [BRIDGE_SCHEMA], _has_all_unique_names_and_ports)}, + extra=vol.ALLOW_EXTRA, +) + + +RESET_ACCESSORY_SERVICE_SCHEMA = vol.Schema( + {vol.Required(ATTR_ENTITY_ID): cv.entity_ids} +) + + +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the HomeKit from yaml.""" + + hass.data.setdefault(DOMAIN, {}) + + _async_register_events_and_services(hass) + + if DOMAIN not in config: + return True + + current_entries = hass.config_entries.async_entries(DOMAIN) + + entries_by_name = {entry.data[CONF_NAME]: entry for entry in current_entries} + + for index, conf in enumerate(config[DOMAIN]): + bridge_name = conf[CONF_NAME] + + if ( + bridge_name in entries_by_name + and entries_by_name[bridge_name].source == SOURCE_IMPORT + ): + entry = entries_by_name[bridge_name] + # If they alter the yaml config we import the changes + # since there currently is no practical way to support + # all the options in the UI at this time. + data = conf.copy() + options = {} + for key in CONFIG_OPTIONS: + options[key] = data[key] + del data[key] + + hass.config_entries.async_update_entry(entry, data=data, options=options) + continue + + conf[CONF_ENTRY_INDEX] = index + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=conf, + ) + ) + + return True + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up HomeKit from a config entry.""" + _async_import_options_from_data_if_missing(hass, entry) -async def async_setup(hass, config): - """Set up the HomeKit component.""" - _LOGGER.debug('Begin setup HomeKit') + conf = entry.data + options = entry.options - 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] + _LOGGER.debug("Begin setup HomeKit for %s", name) + + # If the previous instance hasn't cleaned up yet + # we need to wait a bit + if not await hass.async_add_executor_job(port_is_available, port): + raise ConfigEntryNotReady + + if CONF_ENTRY_INDEX in conf and conf[CONF_ENTRY_INDEX] == 0: + _LOGGER.debug("Migrating legacy HomeKit data for %s", name) + hass.async_add_executor_job( + migrate_filesystem_state_data_for_primary_imported_entry_id, + hass, + entry.entry_id, + ) + + aid_storage = AccessoryAidStorage(hass, entry.entry_id) - homekit = HomeKit(hass, name, port, ip_address, entity_filter, - entity_config, safe_mode) + await aid_storage.async_initialize() + # These are yaml only + ip_address = conf.get(CONF_IP_ADDRESS) + advertise_ip = conf.get(CONF_ADVERTISE_IP) + entity_config = conf.get(CONF_ENTITY_CONFIG, {}) + + auto_start = options.get(CONF_AUTO_START, DEFAULT_AUTO_START) + safe_mode = options.get(CONF_SAFE_MODE, DEFAULT_SAFE_MODE) + entity_filter = convert_filter( + options.get( + CONF_FILTER, + { + CONF_INCLUDE_DOMAINS: [], + CONF_EXCLUDE_DOMAINS: [], + CONF_INCLUDE_ENTITIES: [], + CONF_EXCLUDE_ENTITIES: [], + }, + ) + ) + interface_choice = ( + InterfaceChoice.Default + if options.get(CONF_ZEROCONF_DEFAULT_INTERFACE) + else None + ) + + homekit = HomeKit( + hass, + name, + port, + ip_address, + entity_filter, + entity_config, + safe_mode, + advertise_ip, + interface_choice, + entry.entry_id, + ) await hass.async_add_executor_job(homekit.setup) - if auto_start: - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, homekit.start) - return True + undo_listener = entry.add_update_listener(_async_update_listener) - 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.data[DOMAIN][entry.entry_id] = { + AID_STORAGE: aid_storage, + HOMEKIT: homekit, + UNDO_UPDATE_LISTENER: undo_listener, + } + + if hass.state == CoreState.running: + await homekit.async_start() + elif auto_start: + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, homekit.async_start) + + return True + + +async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry): + """Handle options update.""" + if entry.source == SOURCE_IMPORT: + return + await hass.config_entries.async_reload(entry.entry_id) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" - hass.services.async_register(DOMAIN, SERVICE_HOMEKIT_START, - handle_homekit_service_start) + dismiss_setup_message(hass, entry.entry_id) + + hass.data[DOMAIN][entry.entry_id][UNDO_UPDATE_LISTENER]() + + homekit = hass.data[DOMAIN][entry.entry_id][HOMEKIT] + + if homekit.status == STATUS_RUNNING: + await homekit.async_stop() + + for _ in range(0, SHUTDOWN_TIMEOUT): + if not await hass.async_add_executor_job( + port_is_available, entry.data[CONF_PORT] + ): + _LOGGER.info("Waiting for the HomeKit server to shutdown.") + await asyncio.sleep(1) + + hass.data[DOMAIN].pop(entry.entry_id) 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(): +async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry): + """Remove a config entry.""" + return await hass.async_add_executor_job( + remove_state_files_for_entry_id, hass, entry.entry_id + ) + + +@callback +def _async_import_options_from_data_if_missing(hass: HomeAssistant, entry: ConfigEntry): + options = dict(entry.options) + data = dict(entry.data) + modified = False + for importable_option in CONFIG_OPTIONS: + if importable_option not in entry.options and importable_option in entry.data: + options[importable_option] = entry.data[importable_option] + del data[importable_option] + modified = True + + if modified: + hass.config_entries.async_update_entry(entry, data=data, options=options) + + +@callback +def _async_register_events_and_services(hass: HomeAssistant): + """Register events and services for HomeKit.""" + + hass.http.register_view(HomeKitPairingQRView) + + def handle_homekit_reset_accessory(service): + """Handle start HomeKit service call.""" + for entry_id in hass.data[DOMAIN]: + if HOMEKIT not in hass.data[DOMAIN][entry_id]: + continue + homekit = hass.data[DOMAIN][entry_id][HOMEKIT] + if homekit.status != STATUS_RUNNING: + _LOGGER.warning( + "HomeKit is not running. Either it is waiting to be " + "started or has been stopped." + ) + continue + + entity_ids = service.data.get("entity_id") + homekit.reset_accessories(entity_ids) + + hass.services.async_register( + DOMAIN, + SERVICE_HOMEKIT_RESET_ACCESSORY, + handle_homekit_reset_accessory, + schema=RESET_ACCESSORY_SERVICE_SCHEMA, + ) + + @callback + def async_describe_logbook_event(event): + """Describe a logbook event.""" + data = event.data + entity_id = data.get(ATTR_ENTITY_ID) + value = data.get(ATTR_VALUE) + + value_msg = f" to {value}" if value else "" + message = f"send command {data[ATTR_SERVICE]}{value_msg} for {data[ATTR_DISPLAY_NAME]}" + + return { + "name": "HomeKit", + "message": message, + "entity_id": entity_id, + } + + hass.components.logbook.async_describe_event( + DOMAIN, EVENT_HOMEKIT_CHANGED, async_describe_logbook_event + ) + + async def async_handle_homekit_service_start(service): + """Handle start HomeKit service call.""" + for entry_id in hass.data[DOMAIN]: + if HOMEKIT not in hass.data[DOMAIN][entry_id]: + continue + homekit = hass.data[DOMAIN][entry_id][HOMEKIT] + if homekit.status != STATUS_READY: + _LOGGER.warning( + "HomeKit is not ready. Either it is already running or has " + "been stopped." + ) + continue + await homekit.async_start() + + hass.services.async_register( + DOMAIN, SERVICE_HOMEKIT_START, async_handle_homekit_service_start + ) + + +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): + def __init__( + self, + hass, + name, + port, + ip_address, + entity_filter, + entity_config, + safe_mode, + advertise_ip=None, + interface_choice=None, + entry_id=None, + ): """Initialize a HomeKit object.""" self.hass = hass self._name = name @@ -208,6 +398,9 @@ def __init__(self, hass, name, port, ip_address, entity_filter, self._filter = entity_filter self._config = entity_config self._safe_mode = safe_mode + self._advertise_ip = advertise_ip + self._interface_choice = interface_choice + self._entry_id = entry_id self.status = STATUS_READY self.bridge = None @@ -215,62 +408,216 @@ def __init__(self, hass, name, port, ip_address, entity_filter, def setup(self): """Set up bridge and accessory driver.""" + # pylint: disable=import-outside-toplevel from .accessories import HomeBridge, HomeDriver - self.hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_STOP, self.stop) - + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.async_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) + persist_file = get_persist_fullpath_for_entry_id(self.hass, self._entry_id) + self.driver = HomeDriver( + self.hass, + self._entry_id, + self._name, + address=ip_addr, + port=self._port, + persist_file=persist_file, + advertised_address=self._advertise_ip, + interface_choice=self._interface_choice, + ) self.bridge = HomeBridge(self.hass, self.driver, self._name) if self._safe_mode: - _LOGGER.debug('Safe_mode selected') + _LOGGER.debug("Safe_mode selected for %s", self._name) self.driver.safe_mode = True + def reset_accessories(self, entity_ids): + """Reset the accessory to load the latest configuration.""" + aid_storage = self.hass.data[DOMAIN][self._entry_id][AID_STORAGE] + removed = [] + for entity_id in entity_ids: + aid = aid_storage.get_or_allocate_aid_for_entity_id(entity_id) + if aid not in self.bridge.accessories: + _LOGGER.warning( + "Could not reset accessory. entity_id not found %s", entity_id + ) + continue + acc = self.remove_bridge_accessory(aid) + removed.append(acc) + self.driver.config_changed() + + for acc in removed: + self.bridge.add_accessory(acc) + self.driver.config_changed() + def add_bridge_accessory(self, state): """Try adding accessory to bridge if configured beforehand.""" - if not state or not self._filter(state.entity_id): + if not self._filter(state.entity_id): + return + + # The bridge itself counts as an accessory + if len(self.bridge.accessories) + 1 >= MAX_DEVICES: + _LOGGER.warning( + "Cannot add %s as this would exceeded the %d device limit. Consider using the filter option.", + state.entity_id, + MAX_DEVICES, + ) return - aid = generate_aid(state.entity_id) + + aid = self.hass.data[DOMAIN][self._entry_id][ + AID_STORAGE + ].get_or_allocate_aid_for_entity_id(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) + # If an accessory cannot be created or added due to an exception + # of any kind (usually in pyhap) it should not prevent + # the rest of the accessories from being created + try: + acc = get_accessory(self.hass, self.driver, state, aid, conf) + if acc is not None: + self.bridge.add_accessory(acc) + except Exception: # pylint: disable=broad-except + _LOGGER.exception( + "Failed to create a HomeKit accessory for %s", state.entity_id + ) + + def remove_bridge_accessory(self, aid): + """Try adding accessory to bridge if configured beforehand.""" + acc = None + if aid in self.bridge.accessories: + acc = self.bridge.accessories.pop(aid) + return acc - def start(self, *args): + async def async_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(): + ent_reg = await entity_registry.async_get_registry(self.hass) + + device_lookup = ent_reg.async_get_device_class_lookup( + { + ("binary_sensor", DEVICE_CLASS_BATTERY_CHARGING), + ("sensor", DEVICE_CLASS_BATTERY), + } + ) + + bridged_states = [] + for state in self.hass.states.async_all(): + if not self._filter(state.entity_id): + continue + + self._async_configure_linked_battery_sensors(ent_reg, device_lookup, state) + bridged_states.append(state) + + await self.hass.async_add_executor_job(self._start, bridged_states) + await self._async_register_bridge() + + async def _async_register_bridge(self): + """Register the bridge as a device so homekit_controller and exclude it from discovery.""" + registry = await device_registry.async_get_registry(self.hass) + registry.async_get_or_create( + config_entry_id=self._entry_id, + connections={ + (device_registry.CONNECTION_NETWORK_MAC, self.driver.state.mac) + }, + manufacturer=MANUFACTURER, + name=self._name, + model="Home Assistant HomeKit Bridge", + ) + + def _start(self, bridged_states): + from . import ( # noqa: F401 pylint: disable=unused-import, import-outside-toplevel + type_covers, + type_fans, + type_lights, + type_locks, + type_media_players, + type_security_systems, + type_sensors, + type_switches, + type_thermostats, + ) + + for state in bridged_states: 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') + show_setup_message( + self.hass, + self._entry_id, + self._name, + self.driver.state.pincode, + self.bridge.xhm_uri(), + ) + + _LOGGER.debug("Driver start for %s", self._name) self.hass.add_job(self.driver.start) self.status = STATUS_RUNNING - def stop(self, *args): + async def async_stop(self, *args): """Stop the accessory driver.""" if self.status != STATUS_RUNNING: return self.status = STATUS_STOPPED - - _LOGGER.debug('Driver stop') + _LOGGER.debug("Driver stop for %s", self._name) self.hass.add_job(self.driver.stop) + + @callback + def _async_configure_linked_battery_sensors(self, ent_reg, device_lookup, state): + entry = ent_reg.async_get(state.entity_id) + + if ( + entry is None + or entry.device_id is None + or entry.device_id not in device_lookup + or entry.device_class + in (DEVICE_CLASS_BATTERY_CHARGING, DEVICE_CLASS_BATTERY) + ): + return + + if ATTR_BATTERY_CHARGING not in state.attributes: + battery_charging_binary_sensor_entity_id = device_lookup[ + entry.device_id + ].get(("binary_sensor", DEVICE_CLASS_BATTERY_CHARGING)) + if battery_charging_binary_sensor_entity_id: + self._config.setdefault(state.entity_id, {}).setdefault( + CONF_LINKED_BATTERY_CHARGING_SENSOR, + battery_charging_binary_sensor_entity_id, + ) + + if ATTR_BATTERY_LEVEL not in state.attributes: + battery_sensor_entity_id = device_lookup[entry.device_id].get( + ("sensor", DEVICE_CLASS_BATTERY) + ) + if battery_sensor_entity_id: + self._config.setdefault(state.entity_id, {}).setdefault( + CONF_LINKED_BATTERY_SENSOR, battery_sensor_entity_id + ) + + +class HomeKitPairingQRView(HomeAssistantView): + """Display the homekit pairing code at a protected url.""" + + url = "/api/homekit/pairingqr" + name = "api:homekit:pairingqr" + requires_auth = False + + # pylint: disable=no-self-use + async def get(self, request): + """Retrieve the pairing QRCode image.""" + if not request.query_string: + raise Unauthorized() + entry_id, secret = request.query_string.split("-") + + if ( + entry_id not in request.app["hass"].data[DOMAIN] + or secret + != request.app["hass"].data[DOMAIN][entry_id][HOMEKIT_PAIRING_QR_SECRET] + ): + raise Unauthorized() + return web.Response( + body=request.app["hass"].data[DOMAIN][entry_id][HOMEKIT_PAIRING_QR], + content_type="image/svg+xml", + ) diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index 13dfc90841f9b..ddafbd8fa66a5 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -8,27 +8,88 @@ from pyhap.accessory_driver import AccessoryDriver from pyhap.const import CATEGORY_OTHER +from homeassistant.components import cover, vacuum +from homeassistant.components.cover import DEVICE_CLASS_GARAGE, DEVICE_CLASS_GATE +from homeassistant.components.media_player import DEVICE_CLASS_TV from homeassistant.const import ( - ATTR_BATTERY_CHARGING, ATTR_BATTERY_LEVEL, ATTR_ENTITY_ID, ATTR_SERVICE, - __version__) + ATTR_BATTERY_CHARGING, + ATTR_BATTERY_LEVEL, + ATTR_DEVICE_CLASS, + ATTR_ENTITY_ID, + ATTR_SERVICE, + ATTR_SUPPORTED_FEATURES, + ATTR_UNIT_OF_MEASUREMENT, + CONF_NAME, + CONF_TYPE, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_ILLUMINANCE, + DEVICE_CLASS_TEMPERATURE, + STATE_ON, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, + UNIT_PERCENTAGE, + __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) + async_track_state_change, + track_point_in_utc_time, +) from homeassistant.util import dt as dt_util +from homeassistant.util.decorator import Registry 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 + ATTR_DISPLAY_NAME, + ATTR_VALUE, + BRIDGE_MODEL, + BRIDGE_SERIAL_NUMBER, + CHAR_BATTERY_LEVEL, + CHAR_CHARGING_STATE, + CHAR_STATUS_LOW_BATTERY, + CONF_FEATURE_LIST, + CONF_LINKED_BATTERY_CHARGING_SENSOR, + CONF_LINKED_BATTERY_SENSOR, + CONF_LOW_BATTERY_THRESHOLD, + DEBOUNCE_TIMEOUT, + DEFAULT_LOW_BATTERY_THRESHOLD, + DEVICE_CLASS_CO, + DEVICE_CLASS_CO2, + DEVICE_CLASS_PM25, + EVENT_HOMEKIT_CHANGED, + HK_CHARGING, + HK_NOT_CHARGABLE, + HK_NOT_CHARGING, + MANUFACTURER, + SERV_BATTERY_SERVICE, + TYPE_FAUCET, + TYPE_OUTLET, + TYPE_SHOWER, + TYPE_SPRINKLER, + TYPE_SWITCH, + TYPE_VALVE, +) +from .util import ( + convert_to_float, + dismiss_setup_message, + show_setup_message, + validate_media_player_features, +) _LOGGER = logging.getLogger(__name__) +SWITCH_TYPES = { + TYPE_FAUCET: "Valve", + TYPE_OUTLET: "Outlet", + TYPE_SHOWER: "Valve", + TYPE_SPRINKLER: "Valve", + TYPE_SWITCH: "Switch", + TYPE_VALVE: "Valve", +} +TYPES = Registry() def debounce(func): """Decorate function to debounce callbacks from HomeKit.""" + @ha_callback def call_later_listener(self, *args): """Handle call_later callback.""" @@ -43,59 +104,190 @@ def wrapper(self, *args): 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.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_', '')) + logger.debug( + "%s: Start %s timeout", self.entity_id, func.__name__.replace("set_", "") + ) name = getmodule(func).__name__ logger = logging.getLogger(name) return wrapper +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 in (DEVICE_CLASS_GARAGE, DEVICE_CLASS_GATE) 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 == UNIT_PERCENTAGE: + 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 == "vacuum": + features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if features & (vacuum.SUPPORT_START | vacuum.SUPPORT_RETURN_HOME): + a_type = "DockVacuum" + else: + a_type = "Switch" + + 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) + + class HomeAccessory(Accessory): """Adapter class for Accessory.""" - def __init__(self, hass, driver, name, entity_id, aid, config, - category=CATEGORY_OTHER): + 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) + 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) + self._char_battery = None + self._char_charging = None + self._char_low_battery = None + self.linked_battery_sensor = self.config.get(CONF_LINKED_BATTERY_SENSOR) + self.linked_battery_charging_sensor = self.config.get( + CONF_LINKED_BATTERY_CHARGING_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 + entity_attributes = self.hass.states.get(self.entity_id).attributes + battery_found = entity_attributes.get(ATTR_BATTERY_LEVEL) - if battery_found is None: + if self.linked_battery_sensor: + state = self.hass.states.get(self.linked_battery_sensor) + if state is not None: + battery_found = state.state + else: + self.linked_battery_sensor = None + _LOGGER.warning( + "%s: Battery sensor state missing: %s", + self.entity_id, + self.linked_battery_sensor, + ) + + if not battery_found: return - _LOGGER.debug('%s: Found battery level', self.entity_id) - self._support_battery_level = True + + _LOGGER.debug("%s: Found battery level", self.entity_id) + + if self.linked_battery_charging_sensor: + state = self.hass.states.get(self.linked_battery_charging_sensor) + if state is None: + self.linked_battery_charging_sensor = None + _LOGGER.warning( + "%s: Battery charging binary_sensor state missing: %s", + self.entity_id, + self.linked_battery_charging_sensor, + ) + else: + _LOGGER.debug("%s: Found battery charging", self.entity_id) + serv_battery = self.add_preload_service(SERV_BATTERY_SERVICE) - self._char_battery = serv_battery.configure_char( - CHAR_BATTERY_LEVEL, value=0) + self._char_battery = serv_battery.configure_char(CHAR_BATTERY_LEVEL, value=0) self._char_charging = serv_battery.configure_char( - CHAR_CHARGING_STATE, value=2) + CHAR_CHARGING_STATE, value=HK_NOT_CHARGABLE + ) self._char_low_battery = serv_battery.configure_char( - CHAR_STATUS_LOW_BATTERY, value=0) + CHAR_STATUS_LOW_BATTERY, value=0 + ) async def run(self): """Handle accessory driver started event. @@ -110,61 +302,119 @@ async def run_handler(self): 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) + await self.async_update_state_callback(None, None, state) async_track_state_change( - self.hass, self.entity_id, self.update_state_callback) + self.hass, self.entity_id, self.async_update_state_callback + ) + battery_charging_state = None + battery_state = None 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) + linked_battery_sensor_state = self.hass.states.get( + self.linked_battery_sensor + ) + battery_state = linked_battery_sensor_state.state + battery_charging_state = linked_battery_sensor_state.attributes.get( + ATTR_BATTERY_CHARGING + ) 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): + self.hass, self.linked_battery_sensor, self.async_update_linked_battery + ) + else: + battery_state = state.attributes.get(ATTR_BATTERY_LEVEL) + if self.linked_battery_charging_sensor: + battery_charging_state = ( + self.hass.states.get(self.linked_battery_charging_sensor).state + == STATE_ON + ) + async_track_state_change( + self.hass, + self.linked_battery_charging_sensor, + self.async_update_linked_battery_charging, + ) + elif battery_charging_state is None: + battery_charging_state = state.attributes.get(ATTR_BATTERY_CHARGING) + + if battery_state is not None or battery_charging_state is not None: + self.hass.async_add_executor_job( + self.update_battery, battery_state, battery_charging_state + ) + + async def async_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) + _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): + battery_state = None + battery_charging_state = None + if ( + not self.linked_battery_sensor + and ATTR_BATTERY_LEVEL in new_state.attributes + ): + battery_state = new_state.attributes.get(ATTR_BATTERY_LEVEL) + if ( + not self.linked_battery_charging_sensor + and ATTR_BATTERY_CHARGING in new_state.attributes + ): + battery_charging_state = new_state.attributes.get(ATTR_BATTERY_CHARGING) + if battery_state is not None or battery_charging_state is not None: + await self.hass.async_add_executor_job( + self.update_battery, battery_state, battery_charging_state + ) + await self.hass.async_add_executor_job(self.update_state, new_state) + + async def async_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): + if self.linked_battery_charging_sensor: + battery_charging_state = None + else: + battery_charging_state = new_state.attributes.get(ATTR_BATTERY_CHARGING) + await self.hass.async_add_executor_job( + self.update_battery, new_state.state, battery_charging_state, + ) + + async def async_update_linked_battery_charging( + self, entity_id=None, old_state=None, new_state=None + ): + """Handle linked battery charging sensor state change listener callback.""" + await self.hass.async_add_executor_job( + self.update_battery, None, new_state.state == STATE_ON + ) + + def update_battery(self, battery_level, battery_charging): """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: + if not self._char_battery: + # Battery appeared after homekit was started return - charging = new_state.attributes.get(ATTR_BATTERY_CHARGING) - if charging is None: - self._support_battery_charging = False + + battery_level = convert_to_float(battery_level) + if battery_level is not None: + if self._char_battery.value != battery_level: + self._char_battery.set_value(battery_level) + is_low_battery = 1 if battery_level < self.low_battery_threshold else 0 + if self._char_low_battery.value != is_low_battery: + self._char_low_battery.set_value(is_low_battery) + _LOGGER.debug( + "%s: Updated battery level to %d", self.entity_id, battery_level + ) + + # Charging state can appear after homekit was started + if battery_charging is None or not self._char_charging: 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) + + hk_charging = HK_CHARGING if battery_charging else HK_NOT_CHARGING + if self._char_charging.value != hk_charging: + 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. @@ -175,11 +425,9 @@ def update_state(self, new_state): 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) + 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): + 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. @@ -188,7 +436,7 @@ async def async_call_service(self, domain, service, service_data, ATTR_ENTITY_ID: self.entity_id, ATTR_DISPLAY_NAME: self.display_name, ATTR_SERVICE: service, - ATTR_VALUE: value + ATTR_VALUE: value, } self.hass.bus.async_fire(EVENT_HOMEKIT_CHANGED, event_data) @@ -202,31 +450,41 @@ 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) + 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): + def __init__(self, hass, entry_id, bridge_name, **kwargs): """Initialize a AccessoryDriver object.""" super().__init__(**kwargs) self.hass = hass + self._entry_id = entry_id + self._bridge_name = bridge_name 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) + dismiss_setup_message(self.hass, self._entry_id) 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) + show_setup_message( + self.hass, + self._entry_id, + self._bridge_name, + self.state.pincode, + self.accessory.xhm_uri(), + ) diff --git a/homeassistant/components/homekit/aidmanager.py b/homeassistant/components/homekit/aidmanager.py new file mode 100644 index 0000000000000..487865f22ab9e --- /dev/null +++ b/homeassistant/components/homekit/aidmanager.py @@ -0,0 +1,162 @@ +""" +Manage allocation of accessory ID's. + +HomeKit needs to allocate unique numbers to each accessory. These need to +be stable between reboots and upgrades. + +Using a hash function to generate them means collisions. It also means you +can't change the hash without causing breakages for HA users. + +This module generates and stores them in a HA storage. +""" +import logging +import random +from zlib import adler32 + +from fnvhash import fnv1a_32 + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_registry import RegistryEntry +from homeassistant.helpers.storage import Store + +from .util import get_aid_storage_filename_for_entry_id + +AID_MANAGER_STORAGE_VERSION = 1 +AID_MANAGER_SAVE_DELAY = 2 + +ALLOCATIONS_KEY = "allocations" +UNIQUE_IDS_KEY = "unique_ids" + +INVALID_AIDS = (0, 1) + +AID_MIN = 2 +AID_MAX = 18446744073709551615 + +_LOGGER = logging.getLogger(__name__) + + +def get_system_unique_id(entity: RegistryEntry): + """Determine the system wide unique_id for an entity.""" + return f"{entity.platform}.{entity.domain}.{entity.unique_id}" + + +def _generate_aids(unique_id: str, entity_id: str) -> int: + """Generate accessory aid.""" + + # Backward compatibility: Previously HA used to *only* do adler32 on the entity id. + # Not stable if entity ID changes + # Not robust against collisions + yield adler32(entity_id.encode("utf-8")) + + if unique_id: + # Use fnv1a_32 of the unique id as + # fnv1a_32 has less collisions than + # adler32 + yield fnv1a_32(unique_id.encode("utf-8")) + + # If there is no unique id we use + # fnv1a_32 as it is unlikely to collide + yield fnv1a_32(entity_id.encode("utf-8")) + + # If called again resort to random allocations. + # Given the size of the range its unlikely we'll encounter duplicates + # But try a few times regardless + for _ in range(5): + yield random.randrange(AID_MIN, AID_MAX) + + +class AccessoryAidStorage: + """ + Holds a map of entity ID to HomeKit ID. + + Will generate new ID's, ensure they are unique and store them to make sure they + persist over reboots. + """ + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry): + """Create a new entity map store.""" + self.hass = hass + self.allocations = {} + self.allocated_aids = set() + self._entry = entry + self.store = None + self._entity_registry = None + + async def async_initialize(self): + """Load the latest AID data.""" + self._entity_registry = ( + await self.hass.helpers.entity_registry.async_get_registry() + ) + aidstore = get_aid_storage_filename_for_entry_id(self._entry) + self.store = Store(self.hass, AID_MANAGER_STORAGE_VERSION, aidstore) + + raw_storage = await self.store.async_load() + if not raw_storage: + # There is no data about aid allocations yet + return + + # Remove the UNIQUE_IDS_KEY in 0.112 and later + # The beta version used UNIQUE_IDS_KEY but + # since we now have entity ids in the dict + # we use ALLOCATIONS_KEY but check for + # UNIQUE_IDS_KEY in case the database has not + # been upgraded yet + self.allocations = raw_storage.get( + ALLOCATIONS_KEY, raw_storage.get(UNIQUE_IDS_KEY, {}) + ) + self.allocated_aids = set(self.allocations.values()) + + def get_or_allocate_aid_for_entity_id(self, entity_id: str): + """Generate a stable aid for an entity id.""" + entity = self._entity_registry.async_get(entity_id) + if not entity: + return self._get_or_allocate_aid(None, entity_id) + + sys_unique_id = get_system_unique_id(entity) + return self._get_or_allocate_aid(sys_unique_id, entity_id) + + def _get_or_allocate_aid(self, unique_id: str, entity_id: str): + """Allocate (and return) a new aid for an accessory.""" + # Prefer the unique_id over the + # entitiy_id + storage_key = unique_id or entity_id + + if storage_key in self.allocations: + return self.allocations[storage_key] + + for aid in _generate_aids(unique_id, entity_id): + if aid in INVALID_AIDS: + continue + if aid not in self.allocated_aids: + self.allocations[storage_key] = aid + self.allocated_aids.add(aid) + self.async_schedule_save() + return aid + + raise ValueError( + f"Unable to generate unique aid allocation for {entity_id} [{unique_id}]" + ) + + def delete_aid(self, storage_key: str): + """Delete an aid allocation.""" + if storage_key not in self.allocations: + return + + aid = self.allocations.pop(storage_key) + self.allocated_aids.discard(aid) + 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, AID_MANAGER_SAVE_DELAY) + + async def async_save(self): + """Save the entity map cache.""" + return await self.store.async_save(self._data_to_save()) + + @callback + def _data_to_save(self): + """Return data of entity map to store in a file.""" + return {ALLOCATIONS_KEY: self.allocations} diff --git a/homeassistant/components/homekit/config_flow.py b/homeassistant/components/homekit/config_flow.py new file mode 100644 index 0000000000000..0f83b7a3c24fa --- /dev/null +++ b/homeassistant/components/homekit/config_flow.py @@ -0,0 +1,301 @@ +"""Config flow for HomeKit integration.""" +import logging +import random +import string + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.const import CONF_NAME, CONF_PORT +from homeassistant.core import callback, split_entity_id +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entityfilter import ( + CONF_EXCLUDE_DOMAINS, + CONF_EXCLUDE_ENTITIES, + CONF_INCLUDE_DOMAINS, + CONF_INCLUDE_ENTITIES, +) + +from .const import ( + CONF_AUTO_START, + CONF_FILTER, + CONF_SAFE_MODE, + CONF_ZEROCONF_DEFAULT_INTERFACE, + DEFAULT_AUTO_START, + DEFAULT_CONFIG_FLOW_PORT, + DEFAULT_SAFE_MODE, + DEFAULT_ZEROCONF_DEFAULT_INTERFACE, + SHORT_BRIDGE_NAME, +) +from .const import DOMAIN # pylint:disable=unused-import +from .util import find_next_available_port + +_LOGGER = logging.getLogger(__name__) + +CONF_DOMAINS = "domains" +SUPPORTED_DOMAINS = [ + "alarm_control_panel", + "automation", + "binary_sensor", + "climate", + "cover", + "demo", + "device_tracker", + "fan", + "input_boolean", + "light", + "lock", + "media_player", + "person", + "remote", + "scene", + "script", + "sensor", + "switch", + "vacuum", + "water_heater", +] + +DEFAULT_DOMAINS = [ + "alarm_control_panel", + "climate", + "cover", + "light", + "lock", + "media_player", + "switch", + "vacuum", + "water_heater", +] + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for HomeKit.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH + + def __init__(self): + """Initialize config flow.""" + self.homekit_data = {} + self.entry_title = None + + async def async_step_pairing(self, user_input=None): + """Pairing instructions.""" + if user_input is not None: + return self.async_create_entry( + title=self.entry_title, data=self.homekit_data + ) + return self.async_show_form( + step_id="pairing", + description_placeholders={CONF_NAME: self.homekit_data[CONF_NAME]}, + ) + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + if user_input is not None: + port = await self._async_available_port() + name = self._async_available_name() + title = f"{name}:{port}" + self.homekit_data = user_input.copy() + self.homekit_data[CONF_NAME] = name + self.homekit_data[CONF_PORT] = port + self.homekit_data[CONF_FILTER] = { + CONF_INCLUDE_DOMAINS: user_input[CONF_INCLUDE_DOMAINS], + CONF_INCLUDE_ENTITIES: [], + CONF_EXCLUDE_DOMAINS: [], + CONF_EXCLUDE_ENTITIES: [], + } + del self.homekit_data[CONF_INCLUDE_DOMAINS] + self.entry_title = title + return await self.async_step_pairing() + + default_domains = [] if self._async_current_entries() else DEFAULT_DOMAINS + setup_schema = vol.Schema( + { + vol.Optional(CONF_AUTO_START, default=DEFAULT_AUTO_START): bool, + vol.Required( + CONF_INCLUDE_DOMAINS, default=default_domains + ): cv.multi_select(SUPPORTED_DOMAINS), + } + ) + + return self.async_show_form( + step_id="user", data_schema=setup_schema, errors=errors + ) + + async def async_step_import(self, user_input=None): + """Handle import from yaml.""" + if not self._async_is_unique_name_port(user_input): + return self.async_abort(reason="port_name_in_use") + return self.async_create_entry( + title=f"{user_input[CONF_NAME]}:{user_input[CONF_PORT]}", data=user_input + ) + + async def _async_available_port(self): + """Return an available port the bridge.""" + return await self.hass.async_add_executor_job( + find_next_available_port, DEFAULT_CONFIG_FLOW_PORT + ) + + @callback + def _async_available_name(self): + """Return an available for the bridge.""" + current_entries = self._async_current_entries() + + # We always pick a RANDOM name to avoid Zeroconf + # name collisions. If the name has been seen before + # pairing will probably fail. + acceptable_chars = string.ascii_uppercase + string.digits + trailer = "".join(random.choices(acceptable_chars, k=4)) + all_names = {entry.data[CONF_NAME] for entry in current_entries} + suggested_name = f"{SHORT_BRIDGE_NAME} {trailer}" + while suggested_name in all_names: + trailer = "".join(random.choices(acceptable_chars, k=4)) + suggested_name = f"{SHORT_BRIDGE_NAME} {trailer}" + + return suggested_name + + @callback + def _async_is_unique_name_port(self, user_input): + """Determine is a name or port is already used.""" + name = user_input[CONF_NAME] + port = user_input[CONF_PORT] + for entry in self._async_current_entries(): + if entry.data[CONF_NAME] == name or entry.data[CONF_PORT] == port: + return False + return True + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get the options flow for this handler.""" + return OptionsFlowHandler(config_entry) + + +class OptionsFlowHandler(config_entries.OptionsFlow): + """Handle a option flow for tado.""" + + def __init__(self, config_entry: config_entries.ConfigEntry): + """Initialize options flow.""" + self.config_entry = config_entry + self.homekit_options = {} + + async def async_step_yaml(self, user_input=None): + """No options for yaml managed entries.""" + if user_input is not None: + # Apparently not possible to abort an options flow + # at the moment + return self.async_create_entry(title="", data=self.config_entry.options) + + return self.async_show_form(step_id="yaml") + + async def async_step_advanced(self, user_input=None): + """Choose advanced options.""" + if user_input is not None: + self.homekit_options.update(user_input) + del self.homekit_options[CONF_INCLUDE_DOMAINS] + return self.async_create_entry(title="", data=self.homekit_options) + + schema_base = {} + + if self.show_advanced_options: + schema_base[ + vol.Optional( + CONF_AUTO_START, + default=self.homekit_options.get( + CONF_AUTO_START, DEFAULT_AUTO_START + ), + ) + ] = bool + else: + self.homekit_options[CONF_AUTO_START] = self.homekit_options.get( + CONF_AUTO_START, DEFAULT_AUTO_START + ) + + schema_base.update( + { + vol.Optional( + CONF_SAFE_MODE, + default=self.homekit_options.get(CONF_SAFE_MODE, DEFAULT_SAFE_MODE), + ): bool, + vol.Optional( + CONF_ZEROCONF_DEFAULT_INTERFACE, + default=self.homekit_options.get( + CONF_ZEROCONF_DEFAULT_INTERFACE, + DEFAULT_ZEROCONF_DEFAULT_INTERFACE, + ), + ): bool, + } + ) + + return self.async_show_form( + step_id="advanced", data_schema=vol.Schema(schema_base) + ) + + async def async_step_exclude(self, user_input=None): + """Choose entities to exclude from the domain.""" + if user_input is not None: + self.homekit_options[CONF_FILTER] = { + CONF_INCLUDE_DOMAINS: self.homekit_options[CONF_INCLUDE_DOMAINS], + CONF_EXCLUDE_DOMAINS: self.homekit_options.get( + CONF_EXCLUDE_DOMAINS, [] + ), + CONF_INCLUDE_ENTITIES: self.homekit_options.get( + CONF_INCLUDE_ENTITIES, [] + ), + CONF_EXCLUDE_ENTITIES: user_input[CONF_EXCLUDE_ENTITIES], + } + return await self.async_step_advanced() + + entity_filter = self.homekit_options.get(CONF_FILTER, {}) + all_supported_entities = await self.hass.async_add_executor_job( + _get_entities_matching_domains, + self.hass, + self.homekit_options[CONF_INCLUDE_DOMAINS], + ) + data_schema = vol.Schema( + { + vol.Optional( + CONF_EXCLUDE_ENTITIES, + default=entity_filter.get(CONF_EXCLUDE_ENTITIES, []), + ): cv.multi_select(all_supported_entities), + } + ) + return self.async_show_form(step_id="exclude", data_schema=data_schema) + + async def async_step_init(self, user_input=None): + """Handle options flow.""" + if self.config_entry.source == SOURCE_IMPORT: + return await self.async_step_yaml(user_input) + + if user_input is not None: + self.homekit_options.update(user_input) + return await self.async_step_exclude() + + self.homekit_options = dict(self.config_entry.options) + entity_filter = self.homekit_options.get(CONF_FILTER, {}) + + data_schema = vol.Schema( + { + vol.Optional( + CONF_INCLUDE_DOMAINS, + default=entity_filter.get(CONF_INCLUDE_DOMAINS, []), + ): cv.multi_select(SUPPORTED_DOMAINS) + } + ) + return self.async_show_form(step_id="init", data_schema=data_schema) + + +def _get_entities_matching_domains(hass, domains): + """List entities in the given domains.""" + included_domains = set(domains) + entity_ids = [ + state.entity_id + for state in hass.states.all() + if (split_entity_id(state.entity_id))[0] in included_domains + ] + entity_ids.sort() + return entity_ids diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index 11c0314abf233..ab0c15ee9a7eb 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -1,170 +1,189 @@ """Constants used be the HomeKit component.""" + # #### Misc #### DEBOUNCE_TIMEOUT = 0.5 -DOMAIN = 'homekit' -HOMEKIT_FILE = '.homekit.state' -HOMEKIT_NOTIFY_ID = 4663548 +DEVICE_PRECISION_LEEWAY = 6 +DOMAIN = "homekit" +HOMEKIT_FILE = ".homekit.state" +AID_STORAGE = "homekit-aid-allocations" +HOMEKIT_PAIRING_QR = "homekit-pairing-qr" +HOMEKIT_PAIRING_QR_SECRET = "homekit-pairing-qr-secret" +HOMEKIT = "homekit" +UNDO_UPDATE_LISTENER = "undo_update_listener" +SHUTDOWN_TIMEOUT = 30 +CONF_ENTRY_INDEX = "index" # #### Attributes #### -ATTR_DISPLAY_NAME = 'display_name' -ATTR_VALUE = 'value' +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' +CONF_ADVERTISE_IP = "advertise_ip" +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_LINKED_BATTERY_CHARGING_SENSOR = "linked_battery_charging_sensor" +CONF_LOW_BATTERY_THRESHOLD = "low_battery_threshold" +CONF_SAFE_MODE = "safe_mode" +CONF_ZEROCONF_DEFAULT_INTERFACE = "zeroconf_default_interface" # #### Config Defaults #### DEFAULT_AUTO_START = True DEFAULT_LOW_BATTERY_THRESHOLD = 20 DEFAULT_PORT = 51827 +DEFAULT_CONFIG_FLOW_PORT = 51828 DEFAULT_SAFE_MODE = False +DEFAULT_ZEROCONF_DEFAULT_INTERFACE = False # #### Features #### -FEATURE_ON_OFF = 'on_off' -FEATURE_PLAY_PAUSE = 'play_pause' -FEATURE_PLAY_STOP = 'play_stop' -FEATURE_TOGGLE_MUTE = 'toggle_mute' +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' +EVENT_HOMEKIT_CHANGED = "homekit_state_change" # #### HomeKit Component Services #### -SERVICE_HOMEKIT_START = 'start' +SERVICE_HOMEKIT_START = "start" +SERVICE_HOMEKIT_RESET_ACCESSORY = "reset_accessory" # #### String Constants #### -BRIDGE_MODEL = 'Bridge' -BRIDGE_NAME = 'Home Assistant Bridge' -BRIDGE_SERIAL_NUMBER = 'homekit.bridge' -MANUFACTURER = 'Home Assistant' +BRIDGE_MODEL = "Bridge" +BRIDGE_NAME = "Home Assistant Bridge" +SHORT_BRIDGE_NAME = "HASS 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' +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' +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' +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_TILT_ANGLE = "CurrentHorizontalTiltAngle" +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_HUMIDITY = "TargetRelativeHumidity" +CHAR_TARGET_SECURITY_STATE = "SecuritySystemTargetState" +CHAR_TARGET_TEMPERATURE = "TargetTemperature" +CHAR_TARGET_TILT_ANGLE = "TargetHorizontalTiltAngle" +CHAR_HOLD_POSITION = "HoldPosition" +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} +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' +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 @@ -173,3 +192,28 @@ # #### Default values #### DEFAULT_MIN_TEMP_WATER_HEATER = 40 # °C DEFAULT_MAX_TEMP_WATER_HEATER = 60 # °C + +# #### Door states #### +HK_DOOR_OPEN = 0 +HK_DOOR_CLOSED = 1 +HK_DOOR_OPENING = 2 +HK_DOOR_CLOSING = 3 +HK_DOOR_STOPPED = 4 + +# ### Position State #### +HK_POSITION_GOING_TO_MIN = 0 +HK_POSITION_GOING_TO_MAX = 1 +HK_POSITION_STOPPED = 2 + +# ### Charging State ### +HK_NOT_CHARGING = 0 +HK_CHARGING = 1 +HK_NOT_CHARGABLE = 2 + +# ### Config Options ### +CONFIG_OPTIONS = [ + CONF_FILTER, + CONF_AUTO_START, + CONF_ZEROCONF_DEFAULT_INTERFACE, + CONF_SAFE_MODE, +] diff --git a/homeassistant/components/homekit/manifest.json b/homeassistant/components/homekit/manifest.json index e4aabfeb6cd95..796bb3933f75d 100644 --- a/homeassistant/components/homekit/manifest.json +++ b/homeassistant/components/homekit/manifest.json @@ -1,12 +1,10 @@ { "domain": "homekit", - "name": "Homekit", - "documentation": "https://www.home-assistant.io/components/homekit", - "requirements": [ - "HAP-python==2.5.0" - ], - "dependencies": [], - "codeowners": [ - "@cdce8p" - ] + "name": "HomeKit", + "documentation": "https://www.home-assistant.io/integrations/homekit", + "requirements": ["HAP-python==2.8.3","fnvhash==0.1.0","PyQRCode==1.2.1","base36==0.1.1"], + "dependencies": ["http"], + "after_dependencies": ["logbook"], + "codeowners": ["@bdraco"], + "config_flow": true } diff --git a/homeassistant/components/homekit/services.yaml b/homeassistant/components/homekit/services.yaml index e30e71301b3e0..b33ba642c8d79 100644 --- a/homeassistant/components/homekit/services.yaml +++ b/homeassistant/components/homekit/services.yaml @@ -1,4 +1,11 @@ # Describes the format for available HomeKit services start: - description: Starts the HomeKit component driver. + description: Starts the HomeKit driver. + +reset_accessory: + description: Reset a HomeKit accessory. This can be useful when changing a media_player’s device class to tv, linking a battery, or whenever Home Assistant adds support for new HomeKit features to existing entities. + fields: + entity_id: + description: Name of the entity to reset. + example: "binary_sensor.grid_status" diff --git a/homeassistant/components/homekit/strings.json b/homeassistant/components/homekit/strings.json new file mode 100644 index 0000000000000..08ebfa44c4556 --- /dev/null +++ b/homeassistant/components/homekit/strings.json @@ -0,0 +1,53 @@ +{ + "title": "HomeKit Bridge", + "options": { + "step": { + "yaml": { + "title": "Adjust HomeKit Bridge Options", + "description": "This entry is controlled via YAML" + }, + "init": { + "data": { + "include_domains": "[%key:component::homekit::config::step::user::data::include_domains%]" + }, + "description": "Entities in the \u201cDomains to include\u201d will be bridged to HomeKit. You will be able to select which entities to exclude from this list on the next screen.", + "title": "Select domains to bridge." + }, + "exclude": { + "data": { + "exclude_entities": "Entities to exclude" + }, + "description": "Choose the entities that you do NOT want to be bridged.", + "title": "Exclude entities in selected domains from bridge" + }, + "advanced": { + "data": { + "auto_start": "[%key:component::homekit::config::step::user::data::auto_start%]", + "safe_mode": "Safe Mode (enable only if pairing fails)", + "zeroconf_default_interface": "Use default zeroconf interface (enable if the bridge cannot be found in the Home app)" + }, + "description": "These settings only need to be adjusted if the HomeKit bridge is not functional.", + "title": "Advanced Configuration" + } + } + }, + "config": { + "step": { + "user": { + "data": { + "auto_start": "Autostart (disable if using Z-Wave or other delayed start system)", + "include_domains": "Domains to include" + }, + "description": "A HomeKit Bridge will allow you to access your Home Assistant entities in HomeKit. HomeKit Bridges are limited to 150 accessories per instance including the bridge itself. If you wish to bridge more than the maximum number of accessories, it is recommended that you use multiple HomeKit bridges for different domains. Detailed entity configuration is only available via YAML for the primary bridge.", + "title": "Activate HomeKit Bridge" + }, + "pairing": { + "title": "Pair HomeKit Bridge", + "description": "As soon as the {name} bridge is ready, pairing will be available in \u201cNotifications\u201d as \u201cHomeKit Bridge Setup\u201d." + } + }, + "abort": { + "port_name_in_use": "A bridge with the same name or port is already configured." + } + } +} diff --git a/homeassistant/components/homekit/translations/ca.json b/homeassistant/components/homekit/translations/ca.json new file mode 100644 index 0000000000000..63cf68c99d1f8 --- /dev/null +++ b/homeassistant/components/homekit/translations/ca.json @@ -0,0 +1,53 @@ +{ + "config": { + "abort": { + "port_name_in_use": "Ja hi ha un enlla\u00e7 configurat amb aquest nom o port." + }, + "step": { + "pairing": { + "description": "Tan aviat com l'enlla\u00e7 {name} estigui llest, rebr\u00e0s una \"Notificaci\u00f3\" de configuraci\u00f3 de l'enlla\u00e7 HomeKit informan-te de que la vinculaci\u00f3 est\u00e0 disponible.", + "title": "Vinculaci\u00f3 de l'enlla\u00e7 HomeKit" + }, + "user": { + "data": { + "auto_start": "Autoarrencada (desactiva-ho si fas servir Z-Wave o algun altre sistema d'inici lent)", + "include_domains": "Dominis a incloure" + }, + "description": "L'enlla\u00e7 HomeKit et permet accedir a les teves entitats de Home Assistant directament a HomeKit. Aquests enll\u00e7os estan limitats a un m\u00e0xim de 150 accessoris per inst\u00e0ncia (incl\u00f2s el propi enlla\u00e7). Si volguessis enlla\u00e7ar m\u00e9s accessoris, \u00e9s recomanable que utilitzis diferents enlla\u00e7os HomeKit per a dominis diferents. La configuraci\u00f3 avan\u00e7ada d'entitat per l'enlla\u00e7 prinipal nom\u00e9s est\u00e0 disponible amb YAML.", + "title": "Activaci\u00f3 de l'enlla\u00e7 HomeKit" + } + } + }, + "options": { + "step": { + "advanced": { + "data": { + "auto_start": "[%key::component::homekit::config::step::user::data::auto_start%]", + "safe_mode": "Mode segur (habilita-ho nom\u00e9s si falla la vinculaci\u00f3)", + "zeroconf_default_interface": "Utilitza la interf\u00edcie zeroconf predeterminada. Activa-ho si no es pot trobar l'enlla\u00e7 a l'aplicaci\u00f3 Casa (Home app)." + }, + "description": "Aquests par\u00e0metres nom\u00e9s s'han d'ajustar si l'enlla\u00e7 HomeKit no \u00e9s funcional.", + "title": "Configuraci\u00f3 avan\u00e7ada" + }, + "exclude": { + "data": { + "exclude_entities": "Entitats a excloure" + }, + "description": "Selecciona les entitats que NO vulguis que siguin enlla\u00e7ades.", + "title": "Exclusi\u00f3 d'entitats de l'enlla\u00e7 en dominis seleccionats" + }, + "init": { + "data": { + "include_domains": "[%key::component::homekit::config::step::user::data::include_domains%]" + }, + "description": "Les entitats a \"Dominis a incloure\" s'enlla\u00e7aran a HomeKit. A la seg\u00fcent pantalla podr\u00e0s seleccionar quines entitats vols excloure d'aquesta llista.", + "title": "Selecci\u00f3 dels dominis a enlla\u00e7ar." + }, + "yaml": { + "description": "Aquesta entrada es controla en YAML", + "title": "Ajusta les opcions de l'enlla\u00e7 HomeKit" + } + } + }, + "title": "Enlla\u00e7 HomeKit" +} \ No newline at end of file diff --git a/homeassistant/components/homekit/translations/cs.json b/homeassistant/components/homekit/translations/cs.json new file mode 100644 index 0000000000000..3e96cd44af844 --- /dev/null +++ b/homeassistant/components/homekit/translations/cs.json @@ -0,0 +1,3 @@ +{ + "title": "HomeKit Bridge" +} \ No newline at end of file diff --git a/homeassistant/components/homekit/translations/en.json b/homeassistant/components/homekit/translations/en.json new file mode 100644 index 0000000000000..08ebfa44c4556 --- /dev/null +++ b/homeassistant/components/homekit/translations/en.json @@ -0,0 +1,53 @@ +{ + "title": "HomeKit Bridge", + "options": { + "step": { + "yaml": { + "title": "Adjust HomeKit Bridge Options", + "description": "This entry is controlled via YAML" + }, + "init": { + "data": { + "include_domains": "[%key:component::homekit::config::step::user::data::include_domains%]" + }, + "description": "Entities in the \u201cDomains to include\u201d will be bridged to HomeKit. You will be able to select which entities to exclude from this list on the next screen.", + "title": "Select domains to bridge." + }, + "exclude": { + "data": { + "exclude_entities": "Entities to exclude" + }, + "description": "Choose the entities that you do NOT want to be bridged.", + "title": "Exclude entities in selected domains from bridge" + }, + "advanced": { + "data": { + "auto_start": "[%key:component::homekit::config::step::user::data::auto_start%]", + "safe_mode": "Safe Mode (enable only if pairing fails)", + "zeroconf_default_interface": "Use default zeroconf interface (enable if the bridge cannot be found in the Home app)" + }, + "description": "These settings only need to be adjusted if the HomeKit bridge is not functional.", + "title": "Advanced Configuration" + } + } + }, + "config": { + "step": { + "user": { + "data": { + "auto_start": "Autostart (disable if using Z-Wave or other delayed start system)", + "include_domains": "Domains to include" + }, + "description": "A HomeKit Bridge will allow you to access your Home Assistant entities in HomeKit. HomeKit Bridges are limited to 150 accessories per instance including the bridge itself. If you wish to bridge more than the maximum number of accessories, it is recommended that you use multiple HomeKit bridges for different domains. Detailed entity configuration is only available via YAML for the primary bridge.", + "title": "Activate HomeKit Bridge" + }, + "pairing": { + "title": "Pair HomeKit Bridge", + "description": "As soon as the {name} bridge is ready, pairing will be available in \u201cNotifications\u201d as \u201cHomeKit Bridge Setup\u201d." + } + }, + "abort": { + "port_name_in_use": "A bridge with the same name or port is already configured." + } + } +} diff --git a/homeassistant/components/homekit/translations/fr.json b/homeassistant/components/homekit/translations/fr.json new file mode 100644 index 0000000000000..4e746d4c15cbb --- /dev/null +++ b/homeassistant/components/homekit/translations/fr.json @@ -0,0 +1,53 @@ +{ + "config": { + "abort": { + "port_name_in_use": "Une passerelle avec le m\u00eame nom ou port est d\u00e9j\u00e0 configur\u00e9e." + }, + "step": { + "pairing": { + "description": "D\u00e8s que le pont {name} est pr\u00eat, l'appairage sera disponible dans \"Notifications\" sous \"Configuration de la Passerelle HomeKit\".", + "title": "Appairage de la Passerelle Homekit" + }, + "user": { + "data": { + "auto_start": "D\u00e9marrage automatique (d\u00e9sactiver si vous utilisez Z-Wave ou un autre syst\u00e8me de d\u00e9marrage diff\u00e9r\u00e9)", + "include_domains": "Domaines \u00e0 inclure" + }, + "description": "La passerelle HomeKit vous permettra d'acc\u00e9der \u00e0 vos entit\u00e9s Home Assistant dans HomeKit. Les passerelles HomeKit sont limit\u00e9es \u00e0 150 accessoires par instance, y compris la passerelle elle-m\u00eame. Si vous souhaitez connecter plus que le nombre maximum d'accessoires, il est recommand\u00e9 d'utiliser plusieurs passerelles HomeKit pour diff\u00e9rents domaines. La configuration d\u00e9taill\u00e9e des entit\u00e9s est uniquement disponible via YAML pour la passerelle principale.", + "title": "Activer la Passerelle HomeKit" + } + } + }, + "options": { + "step": { + "advanced": { + "data": { + "auto_start": "D\u00e9marrage automatique (d\u00e9sactiver si vous utilisez Z-Wave ou un autre syst\u00e8me de d\u00e9marrage diff\u00e9r\u00e9)", + "safe_mode": "Mode sans \u00e9chec (activez uniquement si le jumelage \u00e9choue)", + "zeroconf_default_interface": "Utiliser l'interface zeroconf par d\u00e9faut (activer si le pont est introuvable dans l'application Home)" + }, + "description": "Ces param\u00e8tres ne doivent \u00eatre ajust\u00e9s que si le pont HomeKit n'est pas fonctionnel.", + "title": "Configuration avanc\u00e9e" + }, + "exclude": { + "data": { + "exclude_entities": "Entit\u00e9s \u00e0 exclure" + }, + "description": "Choisissez les entit\u00e9s que vous ne souhaitez PAS voir reli\u00e9es.", + "title": "Exclure les entit\u00e9s des domaines s\u00e9lectionn\u00e9s de la passerelle" + }, + "init": { + "data": { + "include_domains": "Domaine \u00e0 inclure" + }, + "description": "Les entit\u00e9s des \u00abdomaines \u00e0 inclure\u00bb seront pont\u00e9es vers HomeKit. Vous pourrez s\u00e9lectionner les entit\u00e9s \u00e0 exclure de cette liste sur l'\u00e9cran suivant.", + "title": "S\u00e9lectionnez les domaines \u00e0 relier." + }, + "yaml": { + "description": "Cette entr\u00e9e est contr\u00f4l\u00e9e via YAML", + "title": "Ajuster les options de la passerelle HomeKit" + } + } + }, + "title": "Passerelle HomeKit" +} \ No newline at end of file diff --git a/homeassistant/components/homekit/translations/pl.json b/homeassistant/components/homekit/translations/pl.json new file mode 100644 index 0000000000000..6aacc0deed65d --- /dev/null +++ b/homeassistant/components/homekit/translations/pl.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "port_name_in_use": "Mostek o okre\u015blonej nazwie lub adresie IP jest ju\u017c skonfigurowany." + }, + "step": { + "pairing": { + "title": "Sparuj z mostkiem HomeKit" + }, + "user": { + "data": { + "include_domains": "Domeny do uwzgl\u0119dnienia" + }, + "title": "Aktywowanie mostka HomeKit" + } + } + }, + "options": { + "step": { + "advanced": { + "data": { + "safe_mode": "Tryb awaryjny (w\u0142\u0105cz tylko wtedy, gdy parowanie nie powiedzie si\u0119)" + } + }, + "init": { + "title": "Domeny do uwzgl\u0119dnienia." + }, + "yaml": { + "description": "Ten wpis jest kontrolowany przez YAML", + "title": "Dostosowywanie opcji mostka HomeKit" + } + } + }, + "title": "Mostek HomeKit" +} \ No newline at end of file diff --git a/homeassistant/components/homekit/translations/ru.json b/homeassistant/components/homekit/translations/ru.json new file mode 100644 index 0000000000000..00bd865ad9e71 --- /dev/null +++ b/homeassistant/components/homekit/translations/ru.json @@ -0,0 +1,53 @@ +{ + "config": { + "abort": { + "port_name_in_use": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0441 \u0442\u0430\u043a\u0438\u043c \u0436\u0435 \u043d\u0430\u0437\u0432\u0430\u043d\u0438\u0435\u043c \u0438\u043b\u0438 \u043f\u043e\u0440\u0442\u043e\u043c \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430." + }, + "step": { + "pairing": { + "description": "\u041a\u0430\u043a \u0442\u043e\u043b\u044c\u043a\u043e \u0431\u0440\u0438\u0434\u0436 {name} \u0431\u0443\u0434\u0435\u0442 \u0433\u043e\u0442\u043e\u0432, \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u0435 \u0431\u0443\u0434\u0435\u0442 \u0434\u043e\u0441\u0442\u0443\u043f\u043d\u043e \u0432 \"\u0423\u0432\u0435\u0434\u043e\u043c\u043b\u0435\u043d\u0438\u044f\u0445\" \u043a\u0430\u043a \"HomeKit Bridge Setup\".", + "title": "\u0421\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u0435 \u0441 HomeKit Bridge" + }, + "user": { + "data": { + "auto_start": "\u0410\u0432\u0442\u043e\u0437\u0430\u043f\u0443\u0441\u043a (\u043e\u0442\u043a\u043b\u044e\u0447\u0438\u0442\u044c \u043f\u0440\u0438 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0438 Z-Wave \u0438\u043b\u0438 \u0434\u0440\u0443\u0433\u043e\u0439 \u0441\u0438\u0441\u0442\u0435\u043c\u044b \u043e\u0442\u043b\u043e\u0436\u0435\u043d\u043d\u043e\u0433\u043e \u0437\u0430\u043f\u0443\u0441\u043a\u0430)", + "include_domains": "\u0412\u044b\u0431\u0440\u0430\u0442\u044c \u0434\u043e\u043c\u0435\u043d\u044b" + }, + "description": "HomeKit Bridge \u043f\u043e\u0437\u0432\u043e\u043b\u0438\u0442 \u0432\u0430\u043c \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u0434\u043e\u0441\u0442\u0443\u043f \u043a \u043e\u0431\u044a\u0435\u043a\u0442\u0430\u043c Home Assistant \u0432 HomeKit. HomeKit Bridge \u043e\u0433\u0440\u0430\u043d\u0438\u0447\u0435\u043d 150 \u0430\u043a\u0441\u0435\u0441\u0441\u0443\u0430\u0440\u0430\u043c\u0438 \u043d\u0430 \u044d\u043a\u0437\u0435\u043c\u043f\u043b\u044f\u0440, \u0432\u043a\u043b\u044e\u0447\u0430\u044f \u0441\u0430\u043c \u043c\u043e\u0441\u0442. \u0415\u0441\u043b\u0438 \u0412\u0430\u043c \u043d\u0443\u0436\u043d\u043e \u0431\u043e\u043b\u044c\u0448\u0435, \u0440\u0435\u043a\u043e\u043c\u0435\u043d\u0434\u0443\u0435\u0442\u0441\u044f \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c \u043d\u0435\u0441\u043a\u043e\u043b\u044c\u043a\u043e HomeKit Bridge \u0434\u043b\u044f \u0440\u0430\u0437\u043d\u044b\u0445 \u0434\u043e\u043c\u0435\u043d\u043e\u0432. \u0414\u0435\u0442\u0430\u043b\u044c\u043d\u0430\u044f \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0434\u043b\u044f \u043a\u0430\u0436\u0434\u043e\u0433\u043e \u043e\u0431\u044a\u0435\u043a\u0442\u0430 \u0434\u043e\u0441\u0442\u0443\u043f\u043d\u0430 \u0442\u043e\u043b\u044c\u043a\u043e \u0447\u0435\u0440\u0435\u0437 YAML \u0434\u043b\u044f \u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0433\u043e \u0431\u0440\u0438\u0434\u0436\u0430.", + "title": "\u0410\u043a\u0442\u0438\u0432\u0438\u0440\u043e\u0432\u0430\u0442\u044c HomeKit Bridge" + } + } + }, + "options": { + "step": { + "advanced": { + "data": { + "auto_start": "\u0410\u0432\u0442\u043e\u0437\u0430\u043f\u0443\u0441\u043a (\u043e\u0442\u043a\u043b\u044e\u0447\u0438\u0442\u044c \u043f\u0440\u0438 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0438 Z-Wave \u0438\u043b\u0438 \u0434\u0440\u0443\u0433\u043e\u0439 \u0441\u0438\u0441\u0442\u0435\u043c\u044b \u043e\u0442\u043b\u043e\u0436\u0435\u043d\u043d\u043e\u0433\u043e \u0437\u0430\u043f\u0443\u0441\u043a\u0430)", + "safe_mode": "\u0411\u0435\u0437\u043e\u043f\u0430\u0441\u043d\u044b\u0439 \u0440\u0435\u0436\u0438\u043c (\u0432\u043a\u043b\u044e\u0447\u0438\u0442\u0435 \u0442\u043e\u043b\u044c\u043a\u043e \u0432 \u0441\u043b\u0443\u0447\u0430\u0435 \u0441\u0431\u043e\u044f \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u044f)", + "zeroconf_default_interface": "\u0418\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c zeroconf \u043f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e (\u0432\u043a\u043b\u044e\u0447\u0438\u0442\u0435, \u0435\u0441\u043b\u0438 \u0431\u0440\u0438\u0434\u0436 \u043d\u0435 \u043c\u043e\u0436\u0435\u0442 \u0431\u044b\u0442\u044c \u043d\u0430\u0439\u0434\u0435\u043d \u0432 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0438 '\u0414\u043e\u043c')." + }, + "description": "\u042d\u0442\u0438 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u043d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u044b, \u0442\u043e\u043b\u044c\u043a\u043e \u0435\u0441\u043b\u0438 HomeKit Bridge \u043d\u0435 \u0440\u0430\u0431\u043e\u0442\u0430\u0435\u0442.", + "title": "\u0420\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u043d\u0430\u044f \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430" + }, + "exclude": { + "data": { + "exclude_entities": "\u0418\u0441\u043a\u043b\u044e\u0447\u0438\u0442\u044c \u043e\u0431\u044a\u0435\u043a\u0442\u044b" + }, + "description": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u043e\u0431\u044a\u0435\u043a\u0442\u044b, \u043a\u043e\u0442\u043e\u0440\u044b\u0435 \u041d\u0415 \u0434\u043e\u043b\u0436\u043d\u044b \u0431\u044b\u0442\u044c \u043f\u0435\u0440\u0435\u0434\u0430\u043d\u044b \u0432 HomeKit.", + "title": "\u0418\u0441\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435 \u043e\u0431\u044a\u0435\u043a\u0442\u043e\u0432 \u0432 \u0432\u044b\u0431\u0440\u0430\u043d\u043d\u044b\u0445 \u0434\u043e\u043c\u0435\u043d\u0430\u0445" + }, + "init": { + "data": { + "include_domains": "\u0412\u044b\u0431\u0440\u0430\u0442\u044c \u0434\u043e\u043c\u0435\u043d\u044b" + }, + "description": "\u041e\u0431\u044a\u0435\u043a\u0442\u044b, \u043f\u0440\u0438\u043d\u0430\u0434\u043b\u0435\u0436\u0430\u0449\u0438\u0435 \u0432\u044b\u0431\u0440\u0430\u043d\u043d\u044b\u043c \u0434\u043e\u043c\u0435\u043d\u0430\u043c, \u0431\u0443\u0434\u0443\u0442 \u043f\u0435\u0440\u0435\u0434\u0430\u043d\u044b \u0432 HomeKit. \u041d\u0430 \u0441\u043b\u0435\u0434\u0443\u044e\u0449\u0435\u043c \u044d\u0442\u0430\u043f\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0412\u044b \u0441\u043c\u043e\u0436\u0435\u0442\u0435 \u0432\u044b\u0431\u0440\u0430\u0442\u044c, \u043a\u0430\u043a\u0438\u0435 \u043e\u0431\u044a\u0435\u043a\u0442\u044b \u0438\u0441\u043a\u043b\u044e\u0447\u0438\u0442\u044c \u0438\u0437 \u044d\u0442\u0438\u0445 \u0434\u043e\u043c\u0435\u043d\u043e\u0432.", + "title": "\u0412\u044b\u0431\u043e\u0440 \u0434\u043e\u043c\u0435\u043d\u043e\u0432 \u0434\u043b\u044f \u043f\u0435\u0440\u0435\u0434\u0430\u0447\u0438 \u0432 HomeKit" + }, + "yaml": { + "description": "\u042d\u0442\u0430 \u0437\u0430\u043f\u0438\u0441\u044c \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d\u0430 \u0447\u0435\u0440\u0435\u0437 YAML", + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 HomeKit Bridge" + } + } + }, + "title": "HomeKit Bridge" +} \ No newline at end of file diff --git a/homeassistant/components/homekit/translations/zh-Hant.json b/homeassistant/components/homekit/translations/zh-Hant.json new file mode 100644 index 0000000000000..ea53860a91af8 --- /dev/null +++ b/homeassistant/components/homekit/translations/zh-Hant.json @@ -0,0 +1,53 @@ +{ + "config": { + "abort": { + "port_name_in_use": "\u4f7f\u7528\u76f8\u540c\u540d\u7a31\u6216\u901a\u8a0a\u57e0\u7684 Bridge \u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210\u3002" + }, + "step": { + "pairing": { + "description": "\u65bc {name} bridge \u5c31\u7dd2\u5f8c\u3001\u5c07\u6703\u65bc\u300c\u901a\u77e5\u300d\u4e2d\u986f\u793a\u300cHomeKit Bridge \u8a2d\u5b9a\u300d\u7684\u914d\u5c0d\u8cc7\u8a0a\u3002", + "title": "\u914d\u5c0d HomeKit Bridge" + }, + "user": { + "data": { + "auto_start": "\u81ea\u52d5\u555f\u52d5\uff08\u5047\u5982\u4f7f\u7528 Z-Wave \u6216\u5176\u4ed6\u5ef6\u9072\u555f\u52d5\u7cfb\u7d71\u6642\u3001\u8acb\u95dc\u9589\uff09", + "include_domains": "\u5305\u542b Domain" + }, + "description": "HomeKit Bridge \u5c07\u53ef\u5141\u8a31\u65bc Homekit \u4e2d\u4f7f\u7528 Home Assistant \u7269\u4ef6\u3002HomeKit Bridges \u6700\u9ad8\u9650\u5236\u70ba 150 \u500b\u914d\u4ef6\u3001\u5305\u542b Bridge \u672c\u8eab\u3002\u5047\u5982\u60f3\u8981\u4f7f\u7528\u8d85\u904e\u9650\u5236\u4ee5\u4e0a\u7684\u914d\u4ef6\uff0c\u5efa\u8b70\u53ef\u4ee5\u4e0d\u540c Domain \u4f7f\u7528\u591a\u500b HomeKit bridges \u9054\u5230\u6b64\u9700\u6c42\u3002\u50c5\u80fd\u65bc\u4e3b Bridge \u4ee5 YAML \u8a2d\u5b9a\u8a73\u7d30\u7269\u4ef6\u3002", + "title": "\u555f\u7528 HomeKit Bridge" + } + } + }, + "options": { + "step": { + "advanced": { + "data": { + "auto_start": "\u81ea\u52d5\u555f\u52d5\uff08\u5047\u5982\u4f7f\u7528 Z-Wave \u6216\u5176\u4ed6\u5ef6\u9072\u555f\u52d5\u7cfb\u7d71\u6642\u3001\u8acb\u95dc\u9589\uff09", + "safe_mode": "\u5b89\u5168\u6a21\u5f0f\uff08\u50c5\u65bc\u914d\u5c0d\u5931\u6557\u6642\u4f7f\u7528\uff09", + "zeroconf_default_interface": "\u4f7f\u7528\u9810\u8a2d zeroconf \u4ecb\u9762\uff08\u50c5\u65bc\u5bb6\u5ead App \u627e\u4e0d\u5230 Bridge \u6642\u958b\u555f\uff09" + }, + "description": "\u50c5\u65bc Homekit bridge \u7121\u6cd5\u6b63\u5e38\u4f7f\u7528\u6642\uff0c\u8abf\u6574\u6b64\u4e9b\u8a2d\u5b9a\u3002", + "title": "\u9032\u968e\u8a2d\u5b9a" + }, + "exclude": { + "data": { + "exclude_entities": "\u6392\u9664\u7269\u4ef6" + }, + "description": "\u9078\u64c7\u4e0d\u9032\u884c\u6a4b\u63a5\u7684\u7269\u4ef6\u3002", + "title": "\u65bc\u6240\u9078 Domain \u4e2d\u6240\u8981\u6392\u9664\u7684\u7269\u4ef6" + }, + "init": { + "data": { + "include_domains": "\u5305\u542b Domain" + }, + "description": "\u300c\u5305\u542b Domain\u300d\u4e2d\u7684\u7269\u4ef6\u5c07\u6703\u6a4b\u63a5\u81f3 Homekit\u3001\u53ef\u4ee5\u65bc\u4e0b\u4e00\u500b\u756b\u9762\u4e2d\u9078\u64c7\u6240\u8981\u5305\u542b\u6216\u6392\u9664\u7684\u7269\u4ef6\u5217\u8868\u3002", + "title": "\u9078\u64c7\u6240\u8981\u6a4b\u63a5\u7684 Domain\u3002" + }, + "yaml": { + "description": "\u6b64\u7269\u4ef6\u70ba\u900f\u904e YAML \u63a7\u5236", + "title": "\u8abf\u6574 HomeKit Bridge \u9078\u9805" + } + } + }, + "title": "HomeKit Bridge" +} \ No newline at end of file diff --git a/homeassistant/components/homekit/type_covers.py b/homeassistant/components/homekit/type_covers.py index 5273480b6cef0..25d1782b392e0 100644 --- a/homeassistant/components/homekit/type_covers.py +++ b/homeassistant/components/homekit/type_covers.py @@ -4,23 +4,75 @@ from pyhap.const import CATEGORY_GARAGE_DOOR_OPENER, CATEGORY_WINDOW_COVERING from homeassistant.components.cover import ( - ATTR_CURRENT_POSITION, ATTR_POSITION, DOMAIN, SUPPORT_STOP) + ATTR_CURRENT_POSITION, + ATTR_CURRENT_TILT_POSITION, + ATTR_POSITION, + ATTR_TILT_POSITION, + DOMAIN, + SUPPORT_SET_TILT_POSITION, + 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) + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + SERVICE_CLOSE_COVER, + SERVICE_OPEN_COVER, + SERVICE_SET_COVER_POSITION, + SERVICE_SET_COVER_TILT_POSITION, + SERVICE_STOP_COVER, + STATE_CLOSED, + STATE_CLOSING, + STATE_OPEN, + STATE_OPENING, +) -from . import TYPES -from .accessories import HomeAccessory, debounce +from .accessories import TYPES, 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) + CHAR_CURRENT_DOOR_STATE, + CHAR_CURRENT_POSITION, + CHAR_CURRENT_TILT_ANGLE, + CHAR_HOLD_POSITION, + CHAR_POSITION_STATE, + CHAR_TARGET_DOOR_STATE, + CHAR_TARGET_POSITION, + CHAR_TARGET_TILT_ANGLE, + DEVICE_PRECISION_LEEWAY, + HK_DOOR_CLOSED, + HK_DOOR_CLOSING, + HK_DOOR_OPEN, + HK_DOOR_OPENING, + HK_POSITION_GOING_TO_MAX, + HK_POSITION_GOING_TO_MIN, + HK_POSITION_STOPPED, + SERV_GARAGE_DOOR_OPENER, + SERV_WINDOW_COVERING, +) + +DOOR_CURRENT_HASS_TO_HK = { + STATE_OPEN: HK_DOOR_OPEN, + STATE_CLOSED: HK_DOOR_CLOSED, + STATE_OPENING: HK_DOOR_OPENING, + STATE_CLOSING: HK_DOOR_CLOSING, +} + +# HomeKit only has two states for +# Target Door State: +# 0: Open +# 1: Closed +# Opening is mapped to 0 since the target is Open +# Closing is mapped to 1 since the target is Closed +DOOR_TARGET_HASS_TO_HK = { + STATE_OPEN: HK_DOOR_OPEN, + STATE_CLOSED: HK_DOOR_CLOSED, + STATE_OPENING: HK_DOOR_OPEN, + STATE_CLOSING: HK_DOOR_CLOSED, +} + _LOGGER = logging.getLogger(__name__) -@TYPES.register('GarageDoorOpener') +@TYPES.register("GarageDoorOpener") class GarageDoorOpener(HomeAccessory): """Generate a Garage Door Opener accessory for a cover entity. @@ -31,42 +83,133 @@ class GarageDoorOpener(HomeAccessory): def __init__(self, *args): """Initialize a GarageDoorOpener accessory object.""" super().__init__(*args, category=CATEGORY_GARAGE_DOOR_OPENER) - self._flag_state = False + state = self.hass.states.get(self.entity_id) 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) + 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) + CHAR_TARGET_DOOR_STATE, value=0, setter_callback=self.set_state + ) + self.update_state(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 + _LOGGER.debug("%s: Set state to %d", self.entity_id, value) params = {ATTR_ENTITY_ID: self.entity_id} - if value == 0: + if value == HK_DOOR_OPEN: if self.char_current_state.value != value: - self.char_current_state.set_value(3) + self.char_current_state.set_value(HK_DOOR_OPENING) self.call_service(DOMAIN, SERVICE_OPEN_COVER, params) - elif value == 1: + elif value == HK_DOOR_CLOSED: if self.char_current_state.value != value: - self.char_current_state.set_value(2) + self.char_current_state.set_value(HK_DOOR_CLOSING) 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 + target_door_state = DOOR_TARGET_HASS_TO_HK.get(hass_state) + current_door_state = DOOR_CURRENT_HASS_TO_HK.get(hass_state) + + if ( + target_door_state is not None + and self.char_target_state.value != target_door_state + ): + self.char_target_state.set_value(target_door_state) + if ( + current_door_state is not None + and self.char_current_state.value != current_door_state + ): + self.char_current_state.set_value(current_door_state) + + +class WindowCoveringBase(HomeAccessory): + """Generate a base Window accessory for a cover entity. + + This class is used for WindowCoveringBasic and + WindowCovering + """ + + def __init__(self, *args, category): + """Initialize a WindowCoveringBase accessory object.""" + super().__init__(*args, category=CATEGORY_WINDOW_COVERING) + state = self.hass.states.get(self.entity_id) + + self.features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + self._supports_stop = self.features & SUPPORT_STOP + self._homekit_target_tilt = None + self.chars = [] + if self._supports_stop: + self.chars.append(CHAR_HOLD_POSITION) + self._supports_tilt = self.features & SUPPORT_SET_TILT_POSITION + + if self._supports_tilt: + self.chars.extend([CHAR_TARGET_TILT_ANGLE, CHAR_CURRENT_TILT_ANGLE]) + + self.serv_cover = self.add_preload_service(SERV_WINDOW_COVERING, self.chars) + + if self._supports_stop: + self.char_hold_position = self.serv_cover.configure_char( + CHAR_HOLD_POSITION, setter_callback=self.set_stop + ) + if self._supports_tilt: + self.char_target_tilt = self.serv_cover.configure_char( + CHAR_TARGET_TILT_ANGLE, setter_callback=self.set_tilt + ) + self.char_current_tilt = self.serv_cover.configure_char( + CHAR_CURRENT_TILT_ANGLE, value=0 + ) -@TYPES.register('WindowCovering') -class WindowCovering(HomeAccessory): + def set_stop(self, value): + """Stop the cover motion from HomeKit.""" + if value != 1: + return + self.call_service(DOMAIN, SERVICE_STOP_COVER, {ATTR_ENTITY_ID: self.entity_id}) + + @debounce + def set_tilt(self, value): + """Set tilt to value if call came from HomeKit.""" + self._homekit_target_tilt = value + _LOGGER.info("%s: Set tilt to %d", self.entity_id, value) + + # HomeKit sends values between -90 and 90. + # We'll have to normalize to [0,100] + value = round((value + 90) / 180.0 * 100.0) + + params = {ATTR_ENTITY_ID: self.entity_id, ATTR_TILT_POSITION: value} + + self.call_service(DOMAIN, SERVICE_SET_COVER_TILT_POSITION, params, value) + + def update_state(self, new_state): + """Update cover position and tilt after state changed.""" + # update tilt + current_tilt = new_state.attributes.get(ATTR_CURRENT_TILT_POSITION) + if isinstance(current_tilt, (float, int)): + # HomeKit sends values between -90 and 90. + # We'll have to normalize to [0,100] + current_tilt = (current_tilt / 100.0 * 180.0) - 90.0 + current_tilt = int(current_tilt) + if self.char_current_tilt.value != current_tilt: + self.char_current_tilt.set_value(current_tilt) + + # We have to assume that the device has worse precision than HomeKit. + # If it reports back a state that is only _close_ to HK's requested + # state, we'll "fix" what HomeKit requested so that it won't appear + # out of sync. + if self._homekit_target_tilt is None or abs( + current_tilt - self._homekit_target_tilt < DEVICE_PRECISION_LEEWAY + ): + if self.char_target_tilt.value != current_tilt: + self.char_target_tilt.set_value(current_tilt) + self._homekit_target_tilt = None + + +@TYPES.register("WindowCovering") +class WindowCovering(WindowCoveringBase, HomeAccessory): """Generate a Window accessory for a cover entity. The cover entity must support: set_cover_position. @@ -75,36 +218,64 @@ class WindowCovering(HomeAccessory): def __init__(self, *args): """Initialize a WindowCovering accessory object.""" super().__init__(*args, category=CATEGORY_WINDOW_COVERING) + state = self.hass.states.get(self.entity_id) 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) + self.char_current_position = self.serv_cover.configure_char( + CHAR_CURRENT_POSITION, value=0 + ) + self.char_target_position = self.serv_cover.configure_char( + CHAR_TARGET_POSITION, value=0, setter_callback=self.move_cover + ) + self.char_position_state = self.serv_cover.configure_char( + CHAR_POSITION_STATE, value=HK_POSITION_STOPPED + ) + self.update_state(state) @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) + _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.""" + """Update cover position and tilt 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) + if isinstance(current_position, (float, int)): + current_position = int(current_position) + if self.char_current_position.value != current_position: + self.char_current_position.set_value(current_position) + + # We have to assume that the device has worse precision than HomeKit. + # If it reports back a state that is only _close_ to HK's requested + # state, we'll "fix" what HomeKit requested so that it won't appear + # out of sync. + if ( + self._homekit_target is None + or abs(current_position - self._homekit_target) + < DEVICE_PRECISION_LEEWAY + ): + if self.char_target_position.value != current_position: + self.char_target_position.set_value(current_position) self._homekit_target = None + if new_state.state == STATE_OPENING: + if self.char_position_state.value != HK_POSITION_GOING_TO_MAX: + self.char_position_state.set_value(HK_POSITION_GOING_TO_MAX) + elif new_state.state == STATE_CLOSING: + if self.char_position_state.value != HK_POSITION_GOING_TO_MIN: + self.char_position_state.set_value(HK_POSITION_GOING_TO_MIN) + else: + if self.char_position_state.value != HK_POSITION_STOPPED: + self.char_position_state.set_value(HK_POSITION_STOPPED) + + super().update_state(new_state) -@TYPES.register('WindowCoveringBasic') -class WindowCoveringBasic(HomeAccessory): +@TYPES.register("WindowCoveringBasic") +class WindowCoveringBasic(WindowCoveringBase, HomeAccessory): """Generate a Window accessory for a cover entity. The cover entity must support: open_cover, close_cover, @@ -114,22 +285,22 @@ class WindowCoveringBasic(HomeAccessory): 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) + state = self.hass.states.get(self.entity_id) + self.char_current_position = self.serv_cover.configure_char( + CHAR_CURRENT_POSITION, value=0 + ) + self.char_target_position = self.serv_cover.configure_char( + CHAR_TARGET_POSITION, value=0, setter_callback=self.move_cover + ) + self.char_position_state = self.serv_cover.configure_char( + CHAR_POSITION_STATE, value=HK_POSITION_STOPPED + ) + self.update_state(state) @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) + _LOGGER.debug("%s: Set position to %d", self.entity_id, value) if self._supports_stop: if value > 70: @@ -150,13 +321,24 @@ def move_cover(self, value): # 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) + if self.char_current_position.value != hk_position: + self.char_current_position.set_value(hk_position) + if self.char_target_position.value != hk_position: + self.char_target_position.set_value(hk_position) + if new_state.state == STATE_OPENING: + if self.char_position_state.value != HK_POSITION_GOING_TO_MAX: + self.char_position_state.set_value(HK_POSITION_GOING_TO_MAX) + elif new_state.state == STATE_CLOSING: + if self.char_position_state.value != HK_POSITION_GOING_TO_MIN: + self.char_position_state.set_value(HK_POSITION_GOING_TO_MIN) + else: + if self.char_position_state.value != HK_POSITION_STOPPED: + self.char_position_state.set_value(HK_POSITION_STOPPED) + + super().update_state(new_state) diff --git a/homeassistant/components/homekit/type_fans.py b/homeassistant/components/homekit/type_fans.py index d2777a296dcbf..b80a65eede1ab 100644 --- a/homeassistant/components/homekit/type_fans.py +++ b/homeassistant/components/homekit/type_fans.py @@ -4,25 +4,43 @@ 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) + 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 + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, +) + +from .accessories import TYPES, HomeAccessory from .const import ( - CHAR_ACTIVE, CHAR_ROTATION_DIRECTION, CHAR_ROTATION_SPEED, CHAR_SWING_MODE, - SERV_FANV2) + CHAR_ACTIVE, + CHAR_ROTATION_DIRECTION, + CHAR_ROTATION_SPEED, + CHAR_SWING_MODE, + SERV_FANV2, +) from .util import HomeKitSpeedMapping _LOGGER = logging.getLogger(__name__) -@TYPES.register('Fan') +@TYPES.register("Fan") class Fan(HomeAccessory): """Generate a Fan accessory for a fan entity. @@ -32,27 +50,24 @@ class Fan(HomeAccessory): 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) + state = self.hass.states.get(self.entity_id) + + features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + 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) + 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_active = serv_fan.configure_char(CHAR_ACTIVE, value=0) self.char_direction = None self.char_speed = None @@ -60,49 +75,75 @@ def __init__(self, *args): if CHAR_ROTATION_DIRECTION in chars: self.char_direction = serv_fan.configure_char( - CHAR_ROTATION_DIRECTION, value=0, - setter_callback=self.set_direction) + CHAR_ROTATION_DIRECTION, value=0 + ) if CHAR_ROTATION_SPEED in chars: - self.char_speed = serv_fan.configure_char( - CHAR_ROTATION_SPEED, value=0, setter_callback=self.set_speed) + # Initial value is set to 100 because 0 is a special value (off). 100 is + # an arbitrary non-zero value. It is updated immediately by update_state + # to set to the correct initial value. + self.char_speed = serv_fan.configure_char(CHAR_ROTATION_SPEED, value=100) if CHAR_SWING_MODE in chars: - self.char_swing = serv_fan.configure_char( - CHAR_SWING_MODE, value=0, setter_callback=self.set_oscillating) + self.char_swing = serv_fan.configure_char(CHAR_SWING_MODE, value=0) + self.update_state(state) + serv_fan.setter_callback = self._set_chars + + def _set_chars(self, char_values): + _LOGGER.debug("Fan _set_chars: %s", char_values) + if CHAR_ACTIVE in char_values: + if char_values[CHAR_ACTIVE]: + # If the device supports set speed we + # do not want to turn on as it will take + # the fan to 100% than to the desired speed. + # + # Setting the speed will take care of turning + # on the fan if SUPPORT_SET_SPEED is set. + if not self.char_speed or CHAR_ROTATION_SPEED not in char_values: + self.set_state(1) + else: + # Its off, nothing more to do as setting the + # other chars will likely turn it back on which + # is what we want to avoid + self.set_state(0) + return + + if CHAR_SWING_MODE in char_values: + self.set_oscillating(char_values[CHAR_SWING_MODE]) + if CHAR_ROTATION_DIRECTION in char_values: + self.set_direction(char_values[CHAR_ROTATION_DIRECTION]) + + # We always do this LAST to ensure they + # get the speed they asked for + if CHAR_ROTATION_SPEED in char_values: + self.set_speed(char_values[CHAR_ROTATION_SPEED]) 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 + _LOGGER.debug("%s: Set state to %d", self.entity_id, value) 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 + _LOGGER.debug("%s: Set direction to %d", self.entity_id, value) 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 + _LOGGER.debug("%s: Set oscillating to %d", self.entity_id, value) oscillating = value == 1 - params = {ATTR_ENTITY_ID: self.entity_id, - ATTR_OSCILLATING: oscillating} + 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) + _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} + 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): @@ -111,35 +152,44 @@ def update_state(self, new_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: + if 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): + if 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: + if self.char_speed is not None and state != STATE_OFF: + # We do not change the homekit speed when turning off + # as it will clear the restore state 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) + if hk_speed_value is not None and self.char_speed.value != hk_speed_value: + # If the homeassistant component reports its speed as the first entry + # in its speed list but is not off, the hk_speed_value is 0. But 0 + # is a special value in homekit. When you turn on a homekit accessory + # it will try to restore the last rotation speed state which will be + # the last value saved by char_speed.set_value. But if it is set to + # 0, HomeKit will update the rotation speed to 100 as it thinks 0 is + # off. + # + # Therefore, if the hk_speed_value is 0 and the device is still on, + # the rotation speed is mapped to 1 otherwise the update is ignored + # in order to avoid this incorrect behavior. + if hk_speed_value == 0 and state == STATE_ON: + hk_speed_value = 1 + if 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): + if isinstance(oscillating, bool): 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 index f549958f755a3..3f4c251820250 100644 --- a/homeassistant/components/homekit/type_lights.py +++ b/homeassistant/components/homekit/type_lights.py @@ -4,25 +4,44 @@ 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) + 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 + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, +) + +from .accessories import TYPES, HomeAccessory from .const import ( - CHAR_BRIGHTNESS, CHAR_COLOR_TEMPERATURE, CHAR_HUE, CHAR_ON, - CHAR_SATURATION, PROP_MAX_VALUE, PROP_MIN_VALUE, SERV_LIGHTBULB) + 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' +RGB_COLOR = "rgb_color" -@TYPES.register('Light') +@TYPES.register("Light") class Light(HomeAccessory): """Generate a Light accessory for a light entity. @@ -32,142 +51,137 @@ class Light(HomeAccessory): 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) + state = self.hass.states.get(self.entity_id) + + self._features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + 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 + elif self._features & SUPPORT_COLOR_TEMP: + # ColorTemperature and Hue characteristic should not be + # exposed both. Both states are tracked separately in HomeKit, + # causing "source of truth" problems. + self.chars.append(CHAR_COLOR_TEMPERATURE) 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) + + self.char_on = serv_light.configure_char(CHAR_ON, value=0) if CHAR_BRIGHTNESS in self.chars: - self.char_brightness = serv_light.configure_char( - CHAR_BRIGHTNESS, value=0, setter_callback=self.set_brightness) + # Initial value is set to 100 because 0 is a special value (off). 100 is + # an arbitrary non-zero value. It is updated immediately by update_state + # to set to the correct initial value. + self.char_brightness = serv_light.configure_char(CHAR_BRIGHTNESS, value=100) + 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) + 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) + CHAR_COLOR_TEMPERATURE, + value=min_mireds, + properties={PROP_MIN_VALUE: min_mireds, PROP_MAX_VALUE: max_mireds}, + ) + if CHAR_HUE in self.chars: - self.char_hue = serv_light.configure_char( - CHAR_HUE, value=0, setter_callback=self.set_hue) + self.char_hue = serv_light.configure_char(CHAR_HUE, value=0) + if CHAR_SATURATION in self.chars: - self.char_saturation = serv_light.configure_char( - CHAR_SATURATION, value=75, setter_callback=self.set_saturation) + self.char_saturation = serv_light.configure_char(CHAR_SATURATION, value=75) + + self.update_state(state) - def set_state(self, value): - """Set state if call came from HomeKit.""" - if self._state == value: - return + serv_light.setter_callback = self._set_chars - _LOGGER.debug('%s: Set state to %d', self.entity_id, value) - self._flag[CHAR_ON] = True + def _set_chars(self, char_values): + _LOGGER.debug("Light _set_chars: %s", char_values) + events = [] + service = SERVICE_TURN_ON 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)) + if CHAR_ON in char_values: + if not char_values[CHAR_ON]: + service = SERVICE_TURN_OFF + events.append(f"Set state to {char_values[CHAR_ON]}") + + if CHAR_BRIGHTNESS in char_values: + if char_values[CHAR_BRIGHTNESS] == 0: + events[-1] = "Set state to 0" + service = SERVICE_TURN_OFF + else: + params[ATTR_BRIGHTNESS_PCT] = char_values[CHAR_BRIGHTNESS] + events.append(f"brightness at {char_values[CHAR_BRIGHTNESS]}%") + + if CHAR_COLOR_TEMPERATURE in char_values: + params[ATTR_COLOR_TEMP] = char_values[CHAR_COLOR_TEMPERATURE] + events.append(f"color temperature at {char_values[CHAR_COLOR_TEMPERATURE]}") + + if ( + self._features & SUPPORT_COLOR + and CHAR_HUE in char_values + and CHAR_SATURATION in char_values + ): + color = (char_values[CHAR_HUE], char_values[CHAR_SATURATION]) + _LOGGER.debug("%s: Set hs_color to %s", self.entity_id, color) + params[ATTR_HS_COLOR] = color + events.append(f"set color at {color}") + + self.call_service(DOMAIN, service, params, ", ".join(events)) 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 + if state == STATE_ON and self.char_on.value != 1: + self.char_on.set_value(1) + elif state == STATE_OFF and self.char_on.value != 0: + self.char_on.set_value(0) # 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): + if isinstance(brightness, (int, float)): brightness = round(brightness / 255 * 100, 0) + # The homeassistant component might report its brightness as 0 but is + # not off. But 0 is a special value in homekit. When you turn on a + # homekit accessory it will try to restore the last brightness state + # which will be the last value saved by char_brightness.set_value. + # But if it is set to 0, HomeKit will update the brightness to 100 as + # it thinks 0 is off. + # + # Therefore, if the the brightness is 0 and the device is still on, + # the brightness is mapped to 1 otherwise the update is ignored in + # order to avoid this incorrect behavior. + if brightness == 0 and state == STATE_ON: + brightness = 1 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 + if isinstance(color_temperature, (int, float)): + color_temperature = round(color_temperature, 0) + if self.char_color_temperature.value != color_temperature: + self.char_color_temperature.set_value(color_temperature) # 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 + hue, saturation = new_state.attributes.get(ATTR_HS_COLOR, (None, None)) + if isinstance(hue, (int, float)) and isinstance(saturation, (int, float)): + hue = round(hue, 0) + saturation = round(saturation, 0) + if hue != self.char_hue.value: + self.char_hue.set_value(hue) + if saturation != self.char_saturation.value: + self.char_saturation.set_value(saturation) diff --git a/homeassistant/components/homekit/type_locks.py b/homeassistant/components/homekit/type_locks.py index 4ed1cebd20774..5697306bf32ec 100644 --- a/homeassistant/components/homekit/type_locks.py +++ b/homeassistant/components/homekit/type_locks.py @@ -3,12 +3,10 @@ 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 homeassistant.components.lock import DOMAIN, STATE_LOCKED, STATE_UNLOCKED +from homeassistant.const import ATTR_CODE, ATTR_ENTITY_ID, STATE_UNKNOWN -from . import TYPES -from .accessories import HomeAccessory +from .accessories import TYPES, HomeAccessory from .const import CHAR_LOCK_CURRENT_STATE, CHAR_LOCK_TARGET_STATE, SERV_LOCK _LOGGER = logging.getLogger(__name__) @@ -22,13 +20,10 @@ HOMEKIT_TO_HASS = {c: s for s, c in HASS_TO_HOMEKIT.items()} -STATE_TO_SERVICE = { - STATE_LOCKED: 'lock', - STATE_UNLOCKED: 'unlock', -} +STATE_TO_SERVICE = {STATE_LOCKED: "lock", STATE_UNLOCKED: "unlock"} -@TYPES.register('Lock') +@TYPES.register("Lock") class Lock(HomeAccessory): """Generate a Lock accessory for a lock entity. @@ -39,24 +34,29 @@ 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 + state = self.hass.states.get(self.entity_id) 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]) + 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) + CHAR_LOCK_TARGET_STATE, + value=HASS_TO_HOMEKIT[STATE_LOCKED], + setter_callback=self.set_state, + ) + self.update_state(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] + if self.char_current_state.value != value: + self.char_current_state.set_value(value) + params = {ATTR_ENTITY_ID: self.entity_id} if self._code: params[ATTR_CODE] = self._code @@ -67,12 +67,21 @@ def update_state(self, new_state): 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) - + _LOGGER.debug( + "%s: Updated current state to %s (%d)", + self.entity_id, + hass_state, + current_lock_state, + ) # LockTargetState only supports locked and unlocked + # Must set lock target state before current state + # or there will be no notification if hass_state in (STATE_LOCKED, STATE_UNLOCKED): - if not self._flag_state: + if self.char_target_state.value != current_lock_state: self.char_target_state.set_value(current_lock_state) - self._flag_state = False + + # Set lock current state ONLY after ensuring that + # target state is correct or there will be no + # notification + if self.char_current_state.value != current_lock_state: + self.char_current_state.set_value(current_lock_state) diff --git a/homeassistant/components/homekit/type_media_players.py b/homeassistant/components/homekit/type_media_players.py index b0c4be35e1b36..154355a0da382 100644 --- a/homeassistant/components/homekit/type_media_players.py +++ b/homeassistant/components/homekit/type_media_players.py @@ -4,27 +4,66 @@ 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) + ATTR_INPUT_SOURCE, + ATTR_INPUT_SOURCE_LIST, + ATTR_MEDIA_VOLUME_LEVEL, + ATTR_MEDIA_VOLUME_MUTED, + DOMAIN, + SERVICE_SELECT_SOURCE, + SUPPORT_PAUSE, + SUPPORT_PLAY, + SUPPORT_SELECT_SOURCE, + SUPPORT_VOLUME_MUTE, + SUPPORT_VOLUME_SET, + SUPPORT_VOLUME_STEP, +) 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 + 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_DOWN, + SERVICE_VOLUME_MUTE, + SERVICE_VOLUME_SET, + SERVICE_VOLUME_UP, + STATE_OFF, + STATE_PAUSED, + STATE_PLAYING, + STATE_STANDBY, + STATE_UNKNOWN, +) + +from .accessories import TYPES, 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) + CHAR_ACTIVE, + CHAR_ACTIVE_IDENTIFIER, + CHAR_CONFIGURED_NAME, + CHAR_CURRENT_VISIBILITY_STATE, + CHAR_IDENTIFIER, + CHAR_INPUT_SOURCE_TYPE, + CHAR_IS_CONFIGURED, + CHAR_MUTE, + CHAR_NAME, + CHAR_ON, + CHAR_REMOTE_KEY, + CHAR_SLEEP_DISCOVER_MODE, + CHAR_VOLUME, + CHAR_VOLUME_CONTROL_TYPE, + CHAR_VOLUME_SELECTOR, + CONF_FEATURE_LIST, + FEATURE_ON_OFF, + FEATURE_PLAY_PAUSE, + FEATURE_PLAY_STOP, + FEATURE_TOGGLE_MUTE, + SERV_INPUT_SOURCE, + SERV_SWITCH, + SERV_TELEVISION, + SERV_TELEVISION_SPEAKER, +) _LOGGER = logging.getLogger(__name__) @@ -45,24 +84,27 @@ } MODE_FRIENDLY_NAME = { - FEATURE_ON_OFF: 'Power', - FEATURE_PLAY_PAUSE: 'Play/Pause', - FEATURE_PLAY_STOP: 'Play/Stop', - FEATURE_TOGGLE_MUTE: 'Mute', + FEATURE_ON_OFF: "Power", + FEATURE_PLAY_PAUSE: "Play/Pause", + FEATURE_PLAY_STOP: "Play/Stop", + FEATURE_TOGGLE_MUTE: "Mute", } -@TYPES.register('MediaPlayer') +@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} + state = self.hass.states.get(self.entity_id) + 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: @@ -70,67 +112,69 @@ def __init__(self, *args): 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) + 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) + 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) + 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) + CHAR_ON, value=False, setter_callback=self.set_toggle_mute + ) + self.update_state(state) def generate_service_name(self, mode): """Generate name for individual service.""" - return '{} {}'.format(self.display_name, MODE_FRIENDLY_NAME[mode]) + return f"{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 + _LOGGER.debug('%s: Set switch state for "on_off" to %s', self.entity_id, value) 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 + _LOGGER.debug( + '%s: Set switch state for "play_pause" to %s', self.entity_id, value + ) 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 + _LOGGER.debug( + '%s: Set switch state for "play_stop" to %s', self.entity_id, value + ) 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} + _LOGGER.debug( + '%s: Set switch state for "toggle_mute" to %s', self.entity_id, value + ) + 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): @@ -138,48 +182,56 @@ def update_state(self, new_state): 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) + hk_state = current_state not in ( + STATE_OFF, + STATE_UNKNOWN, + STATE_STANDBY, + "None", + ) + _LOGGER.debug( + '%s: Set current state for "on_off" to %s', self.entity_id, hk_state + ) + if self.chars[FEATURE_ON_OFF].value != 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) + _LOGGER.debug( + '%s: Set current state for "play_pause" to %s', + self.entity_id, + hk_state, + ) + if self.chars[FEATURE_PLAY_PAUSE].value != 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) + _LOGGER.debug( + '%s: Set current state for "play_stop" to %s', self.entity_id, hk_state, + ) + if self.chars[FEATURE_PLAY_STOP].value != 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) + _LOGGER.debug( + '%s: Set current state for "toggle_mute" to %s', + self.entity_id, + current_state, + ) + if self.chars[FEATURE_TOGGLE_MUTE].value != current_state: self.chars[FEATURE_TOGGLE_MUTE].set_value(current_state) - self._flag[FEATURE_TOGGLE_MUTE] = False -@TYPES.register('TelevisionMediaPlayer') +@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) + state = self.hass.states.get(self.entity_id) - self._flag = {CHAR_ACTIVE: False, CHAR_ACTIVE_IDENTIFIER: False, - CHAR_MUTE: False} self.support_select_source = False self.sources = [] @@ -187,15 +239,16 @@ def __init__(self, *args): # 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) + 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)) + 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) @@ -207,110 +260,114 @@ def __init__(self, *args): 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) + 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) + 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_TELEVISION_SPEAKER, self.chars_speaker + ) serv_tv.add_linked_service(serv_speaker) - name = '{} {}'.format(self.display_name, 'Volume') + name = f"{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) + 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) + 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) + 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) + 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.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) + 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_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_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) + 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) + + self.update_state(state) 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 + _LOGGER.debug('%s: Set switch state for "on_off" to %s', self.entity_id, value) 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} + _LOGGER.debug( + '%s: Set switch state for "toggle_mute" to %s', self.entity_id, value + ) + 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} + _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) + _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) + _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} + 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) + _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 \ + 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) @@ -319,34 +376,38 @@ def update_state(self, new_state): 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) + hk_state = 0 + if current_state not in ("None", STATE_OFF, STATE_UNKNOWN): + hk_state = 1 + + _LOGGER.debug("%s: Set current active state to %s", self.entity_id, hk_state) + if self.char_active.value != 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) + current_mute_state = new_state.attributes.get(ATTR_MEDIA_VOLUME_MUTED) + _LOGGER.debug( + "%s: Set current mute state to %s", self.entity_id, current_mute_state, + ) + if self.char_mute.value != 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 self.sources: + _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) + if self.char_input_source.value != index: + 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 + _LOGGER.warning( + "%s: Sources out of sync. Restart Home Assistant", + self.entity_id, + ) + if self.char_input_source.value != 0: + self.char_input_source.set_value(0) diff --git a/homeassistant/components/homekit/type_security_systems.py b/homeassistant/components/homekit/type_security_systems.py index 10befb4af61e7..8a2bb971cf15f 100644 --- a/homeassistant/components/homekit/type_security_systems.py +++ b/homeassistant/components/homekit/type_security_systems.py @@ -5,16 +5,25 @@ 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) + 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 .accessories import TYPES, HomeAccessory from .const import ( - CHAR_CURRENT_SECURITY_STATE, CHAR_TARGET_SECURITY_STATE, - SERV_SECURITY_SYSTEM) + CHAR_CURRENT_SECURITY_STATE, + CHAR_TARGET_SECURITY_STATE, + SERV_SECURITY_SYSTEM, +) _LOGGER = logging.getLogger(__name__) @@ -36,28 +45,30 @@ } -@TYPES.register('SecuritySystem') +@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) + state = self.hass.states.get(self.entity_id) 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) + 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) + CHAR_TARGET_SECURITY_STATE, value=3, setter_callback=self.set_security_state + ) + # Set the state so it is in sync on initial + # GET to avoid an event storm after homekit startup + self.update_state(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 + _LOGGER.debug("%s: Set security state to %d", self.entity_id, value) hass_value = HOMEKIT_TO_HASS[value] service = STATE_TO_SERVICE[hass_value] @@ -71,12 +82,18 @@ def update_state(self, new_state): 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) + if self.char_current_state.value != current_security_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: + if ( + hass_state != STATE_ALARM_TRIGGERED + and self.char_target_state.value != current_security_state + ): 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 index 0d7dd94d01459..c37755bc1c508 100644 --- a/homeassistant/components/homekit/type_sensors.py +++ b/homeassistant/components/homekit/type_sensors.py @@ -4,49 +4,75 @@ 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 + ATTR_DEVICE_CLASS, + ATTR_UNIT_OF_MEASUREMENT, + STATE_HOME, + STATE_ON, + TEMP_CELSIUS, +) + +from .accessories import TYPES, 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) + 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), + DEVICE_CLASS_CO2: (SERV_CARBON_DIOXIDE_SENSOR, CHAR_CARBON_DIOXIDE_DETECTED, int), + DEVICE_CLASS_DOOR: (SERV_CONTACT_SENSOR, CHAR_CONTACT_SENSOR_STATE, int), + DEVICE_CLASS_GARAGE_DOOR: (SERV_CONTACT_SENSOR, CHAR_CONTACT_SENSOR_STATE, int), + DEVICE_CLASS_GAS: (SERV_CARBON_MONOXIDE_SENSOR, CHAR_CARBON_MONOXIDE_DETECTED, int), + DEVICE_CLASS_MOISTURE: (SERV_LEAK_SENSOR, CHAR_LEAK_DETECTED, int), + DEVICE_CLASS_MOTION: (SERV_MOTION_SENSOR, CHAR_MOTION_DETECTED, bool), + DEVICE_CLASS_OCCUPANCY: (SERV_OCCUPANCY_SENSOR, CHAR_OCCUPANCY_DETECTED, int), + DEVICE_CLASS_OPENING: (SERV_CONTACT_SENSOR, CHAR_CONTACT_SENSOR_STATE, int), + DEVICE_CLASS_SMOKE: (SERV_SMOKE_SENSOR, CHAR_SMOKE_DETECTED, int), + DEVICE_CLASS_WINDOW: (SERV_CONTACT_SENSOR, CHAR_CONTACT_SENSOR_STATE, int), } -@TYPES.register('TemperatureSensor') +@TYPES.register("TemperatureSensor") class TemperatureSensor(HomeAccessory): """Generate a TemperatureSensor accessory for a temperature sensor. @@ -56,9 +82,14 @@ class TemperatureSensor(HomeAccessory): def __init__(self, *args): """Initialize a TemperatureSensor accessory object.""" super().__init__(*args, category=CATEGORY_SENSOR) + state = self.hass.states.get(self.entity_id) serv_temp = self.add_preload_service(SERV_TEMPERATURE_SENSOR) self.char_temp = serv_temp.configure_char( - CHAR_CURRENT_TEMPERATURE, value=0, properties=PROP_CELSIUS) + CHAR_CURRENT_TEMPERATURE, value=0, properties=PROP_CELSIUS + ) + # Set the state so it is in sync on initial + # GET to avoid an event storm after homekit startup + self.update_state(state) def update_state(self, new_state): """Update temperature after state changed.""" @@ -66,150 +97,196 @@ def update_state(self, new_state): 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) + if self.char_temp.value != temperature: + self.char_temp.set_value(temperature) + _LOGGER.debug( + "%s: Current temperature set to %.1f°C", self.entity_id, temperature + ) -@TYPES.register('HumiditySensor') +@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) + state = self.hass.states.get(self.entity_id) serv_humidity = self.add_preload_service(SERV_HUMIDITY_SENSOR) self.char_humidity = serv_humidity.configure_char( - CHAR_CURRENT_HUMIDITY, value=0) + CHAR_CURRENT_HUMIDITY, value=0 + ) + # Set the state so it is in sync on initial + # GET to avoid an event storm after homekit startup + self.update_state(state) def update_state(self, new_state): """Update accessory after state change.""" humidity = convert_to_float(new_state.state) - if humidity: + if humidity and self.char_humidity.value != humidity: self.char_humidity.set_value(humidity) - _LOGGER.debug('%s: Percent set to %d%%', - self.entity_id, humidity) + _LOGGER.debug("%s: Percent set to %d%%", self.entity_id, humidity) -@TYPES.register('AirQualitySensor') +@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) - + state = self.hass.states.get(self.entity_id) 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) + 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) + CHAR_AIR_PARTICULATE_DENSITY, value=0 + ) + # Set the state so it is in sync on initial + # GET to avoid an event storm after homekit startup + self.update_state(state) 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) + if self.char_density.value != density: + self.char_density.set_value(density) + _LOGGER.debug("%s: Set density to %d", self.entity_id, density) + air_quality = density_to_air_quality(density) + if self.char_quality.value != air_quality: + self.char_quality.set_value(air_quality) + _LOGGER.debug("%s: Set air_quality to %d", self.entity_id, air_quality) -@TYPES.register('CarbonMonoxideSensor') +@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) + state = self.hass.states.get(self.entity_id) + 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) + CHAR_CARBON_MONOXIDE_PEAK_LEVEL, value=0 + ) self.char_detected = serv_co.configure_char( - CHAR_CARBON_MONOXIDE_DETECTED, value=0) + CHAR_CARBON_MONOXIDE_DETECTED, value=0 + ) + # Set the state so it is in sync on initial + # GET to avoid an event storm after homekit startup + self.update_state(state) 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 self.char_level.value != 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) + co_detected = value > THRESHOLD_CO + if self.char_detected.value is not co_detected: + self.char_detected.set_value(co_detected) + _LOGGER.debug("%s: Set to %d", self.entity_id, value) -@TYPES.register('CarbonDioxideSensor') +@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) + state = self.hass.states.get(self.entity_id) + 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) + CHAR_CARBON_DIOXIDE_PEAK_LEVEL, value=0 + ) self.char_detected = serv_co2.configure_char( - CHAR_CARBON_DIOXIDE_DETECTED, value=0) + CHAR_CARBON_DIOXIDE_DETECTED, value=0 + ) + # Set the state so it is in sync on initial + # GET to avoid an event storm after homekit startup + self.update_state(state) 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 self.char_level.value != 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) + co2_detected = value > THRESHOLD_CO2 + if self.char_detected.value is not co2_detected: + self.char_detected.set_value(co2_detected) + _LOGGER.debug("%s: Set to %d", self.entity_id, value) -@TYPES.register('LightSensor') +@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) - + state = self.hass.states.get(self.entity_id) serv_light = self.add_preload_service(SERV_LIGHT_SENSOR) self.char_light = serv_light.configure_char( - CHAR_CURRENT_AMBIENT_LIGHT_LEVEL, value=0) + CHAR_CURRENT_AMBIENT_LIGHT_LEVEL, value=0 + ) + # Set the state so it is in sync on initial + # GET to avoid an event storm after homekit startup + self.update_state(state) def update_state(self, new_state): """Update accessory after state change.""" luminance = convert_to_float(new_state.state) - if luminance: + if luminance and self.char_light.value != luminance: self.char_light.set_value(luminance) - _LOGGER.debug('%s: Set to %d', self.entity_id, luminance) + _LOGGER.debug("%s: Set to %d", self.entity_id, luminance) -@TYPES.register('BinarySensor') +@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 \ + state = self.hass.states.get(self.entity_id) + device_class = state.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] + ) + self.format = service_char[2] service = self.add_preload_service(service_char[0]) - self.char_detected = service.configure_char(service_char[1], value=0) + initial_value = False if self.format is bool else 0 + self.char_detected = service.configure_char( + service_char[1], value=initial_value + ) + # Set the state so it is in sync on initial + # GET to avoid an event storm after homekit startup + self.update_state(state) 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) + detected = self.format(state in (STATE_ON, STATE_HOME)) + if self.char_detected.value != detected: + 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 index 7629e33a4d71b..072c8681a5039 100644 --- a/homeassistant/components/homekit/type_switches.py +++ b/homeassistant/components/homekit/type_switches.py @@ -2,22 +2,46 @@ import logging from pyhap.const import ( - CATEGORY_FAUCET, CATEGORY_OUTLET, CATEGORY_SHOWER_HEAD, CATEGORY_SPRINKLER, - CATEGORY_SWITCH) + 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.components.vacuum import ( + DOMAIN as VACUUM_DOMAIN, + SERVICE_RETURN_TO_BASE, + SERVICE_START, + STATE_CLEANING, +) from homeassistant.const import ( - ATTR_ENTITY_ID, CONF_TYPE, SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_ON) + 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 .accessories import TYPES, 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) + 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__) @@ -29,41 +53,42 @@ } -@TYPES.register('Outlet') +@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 + state = self.hass.states.get(self.entity_id) serv_outlet = self.add_preload_service(SERV_OUTLET) self.char_on = serv_outlet.configure_char( - CHAR_ON, value=False, setter_callback=self.set_state) + CHAR_ON, value=False, setter_callback=self.set_state + ) self.char_outlet_in_use = serv_outlet.configure_char( - CHAR_OUTLET_IN_USE, value=True) + CHAR_OUTLET_IN_USE, value=True + ) + # Set the state so it is in sync on initial + # GET to avoid an event storm after homekit startup + self.update_state(state) 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 + _LOGGER.debug("%s: Set switch state to %s", self.entity_id, 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) + current_state = new_state.state == STATE_ON + if self.char_on.value is not current_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') +@TYPES.register("Switch") class Switch(HomeAccessory): """Generate a Switch accessory.""" @@ -71,37 +96,39 @@ 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 + state = self.hass.states.get(self.entity_id) - self.activate_only = self.is_activate( - self.hass.states.get(self.entity_id)) + 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) + CHAR_ON, value=False, setter_callback=self.set_state + ) + # Set the state so it is in sync on initial + # GET to avoid an event storm after homekit startup + self.update_state(state) def is_activate(self, state): """Check if entity is activate only.""" can_cancel = state.attributes.get(ATTR_CAN_CANCEL) - if self._domain == 'scene': + if self._domain == "scene": return True - if self._domain == 'script' and not can_cancel: + 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) + _LOGGER.debug("%s: Reset switch to off", self.entity_id) + if self.char_on.value is not False: + self.char_on.set_value(False) 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) + _LOGGER.debug("%s: Set switch state to %s", self.entity_id, value) + if self.activate_only and not value: + _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) @@ -113,42 +140,62 @@ 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) + _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) + current_state = new_state.state == STATE_ON + if self.char_on.value is not current_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') +@TYPES.register("DockVacuum") +class DockVacuum(Switch): + """Generate a Switch accessory.""" + + 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) + params = {ATTR_ENTITY_ID: self.entity_id} + service = SERVICE_START if value else SERVICE_RETURN_TO_BASE + self.call_service(VACUUM_DOMAIN, service, params) + + def update_state(self, new_state): + """Update switch state after state changed.""" + current_state = new_state.state in (STATE_CLEANING, STATE_ON) + if self.char_on.value is not current_state: + _LOGGER.debug("%s: Set current state to %s", self.entity_id, current_state) + self.char_on.set_value(current_state) + + +@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 + state = self.hass.states.get(self.entity_id) 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) + 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]) + CHAR_VALVE_TYPE, value=VALVE_TYPE[valve_type][1] + ) + # Set the state so it is in sync on initial + # GET to avoid an event storm after homekit startup + self.update_state(state) 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 + _LOGGER.debug("%s: Set switch state to %s", self.entity_id, value) self.char_in_use.set_value(value) params = {ATTR_ENTITY_ID: self.entity_id} service = SERVICE_TURN_ON if value else SERVICE_TURN_OFF @@ -156,10 +203,10 @@ def set_state(self, value): 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) + current_state = 1 if new_state.state == STATE_ON else 0 + if self.char_active.value != current_state: + _LOGGER.debug("%s: Set active state to %s", self.entity_id, current_state) self.char_active.set_value(current_state) + if self.char_in_use.value != current_state: + _LOGGER.debug("%s: Set in_use state to %s", self.entity_id, 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 index 85cf7938fbde5..84cefced60257 100644 --- a/homeassistant/components/homekit/type_thermostats.py +++ b/homeassistant/components/homekit/type_thermostats.py @@ -4,46 +4,111 @@ 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, + ATTR_CURRENT_HUMIDITY, + ATTR_CURRENT_TEMPERATURE, + ATTR_HUMIDITY, + ATTR_HVAC_ACTION, + ATTR_HVAC_MODE, + ATTR_HVAC_MODES, + ATTR_MAX_TEMP, + ATTR_MIN_HUMIDITY, + ATTR_MIN_TEMP, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + CURRENT_HVAC_COOL, + CURRENT_HVAC_DRY, + CURRENT_HVAC_FAN, + CURRENT_HVAC_HEAT, + CURRENT_HVAC_IDLE, + CURRENT_HVAC_OFF, + DEFAULT_MAX_TEMP, + DEFAULT_MIN_HUMIDITY, + 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) + HVAC_MODE_AUTO, + HVAC_MODE_COOL, + HVAC_MODE_DRY, + HVAC_MODE_FAN_ONLY, + HVAC_MODE_HEAT, + HVAC_MODE_HEAT_COOL, + HVAC_MODE_OFF, + SERVICE_SET_HUMIDITY, + SERVICE_SET_HVAC_MODE as SERVICE_SET_HVAC_MODE_THERMOSTAT, + SERVICE_SET_TEMPERATURE as SERVICE_SET_TEMPERATURE_THERMOSTAT, + SUPPORT_TARGET_HUMIDITY, + SUPPORT_TARGET_TEMPERATURE, + SUPPORT_TARGET_TEMPERATURE_RANGE, +) from homeassistant.components.water_heater import ( DOMAIN as DOMAIN_WATER_HEATER, - SERVICE_SET_TEMPERATURE as SERVICE_SET_TEMPERATURE_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 + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + ATTR_TEMPERATURE, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, + UNIT_PERCENTAGE, +) + +from .accessories import TYPES, HomeAccessory 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) + CHAR_COOLING_THRESHOLD_TEMPERATURE, + CHAR_CURRENT_HEATING_COOLING, + CHAR_CURRENT_HUMIDITY, + CHAR_CURRENT_TEMPERATURE, + CHAR_HEATING_THRESHOLD_TEMPERATURE, + CHAR_TARGET_HEATING_COOLING, + CHAR_TARGET_HUMIDITY, + CHAR_TARGET_TEMPERATURE, + CHAR_TEMP_DISPLAY_UNITS, + DEFAULT_MAX_TEMP_WATER_HEATER, + DEFAULT_MIN_TEMP_WATER_HEATER, + PROP_MAX_VALUE, + PROP_MIN_VALUE, + SERV_THERMOSTAT, +) from .util import temperature_to_homekit, temperature_to_states _LOGGER = logging.getLogger(__name__) +HC_HOMEKIT_VALID_MODES_WATER_HEATER = {"Heat": 1} UNIT_HASS_TO_HOMEKIT = {TEMP_CELSIUS: 0, TEMP_FAHRENHEIT: 1} + +HC_HEAT_COOL_OFF = 0 +HC_HEAT_COOL_HEAT = 1 +HC_HEAT_COOL_COOL = 2 +HC_HEAT_COOL_AUTO = 3 + +HC_MIN_TEMP = 10 +HC_MAX_TEMP = 38 + 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_HASS_TO_HOMEKIT = { + HVAC_MODE_OFF: HC_HEAT_COOL_OFF, + HVAC_MODE_HEAT: HC_HEAT_COOL_HEAT, + HVAC_MODE_COOL: HC_HEAT_COOL_COOL, + HVAC_MODE_AUTO: HC_HEAT_COOL_AUTO, + HVAC_MODE_HEAT_COOL: HC_HEAT_COOL_AUTO, + HVAC_MODE_DRY: HC_HEAT_COOL_COOL, + HVAC_MODE_FAN_ONLY: HC_HEAT_COOL_COOL, +} 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 +HC_HASS_TO_HOMEKIT_ACTION = { + CURRENT_HVAC_OFF: HC_HEAT_COOL_OFF, + CURRENT_HVAC_IDLE: HC_HEAT_COOL_OFF, + CURRENT_HVAC_HEAT: HC_HEAT_COOL_HEAT, + CURRENT_HVAC_COOL: HC_HEAT_COOL_COOL, + CURRENT_HVAC_DRY: HC_HEAT_COOL_COOL, + CURRENT_HVAC_FAN: HC_HEAT_COOL_COOL, +} + +HEAT_COOL_DEADBAND = 5 -@TYPES.register('Thermostat') +@TYPES.register("Thermostat") class Thermostat(HomeAccessory): """Generate a Thermostat accessory for a climate.""" @@ -51,237 +116,395 @@ 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 + self._state_updates = 0 + self.hc_homekit_to_hass = None + self.hc_hass_to_homekit = None min_temp, max_temp = self.get_temperature_range() + # Homekit only supports 10-38, overwriting + # the max to appears to work, but less than 0 causes + # a crash on the home app + hc_min_temp = max(min_temp, 0) + hc_max_temp = max_temp + + min_humidity = self.hass.states.get(self.entity_id).attributes.get( + ATTR_MIN_HUMIDITY, DEFAULT_MIN_HUMIDITY + ) + # 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)) + state = self.hass.states.get(self.entity_id) + features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + + if features & SUPPORT_TARGET_TEMPERATURE_RANGE: + self.chars.extend( + (CHAR_COOLING_THRESHOLD_TEMPERATURE, CHAR_HEATING_THRESHOLD_TEMPERATURE) + ) + + if features & SUPPORT_TARGET_HUMIDITY: + self.chars.extend((CHAR_TARGET_HUMIDITY, CHAR_CURRENT_HUMIDITY)) serv_thermostat = self.add_preload_service(SERV_THERMOSTAT, self.chars) - # Current and target mode characteristics + # Current mode characteristics self.char_current_heat_cool = serv_thermostat.configure_char( - CHAR_CURRENT_HEATING_COOLING, value=0) + CHAR_CURRENT_HEATING_COOLING, value=0 + ) + + self._configure_hvac_modes(state) + # Must set the value first as setting + # valid_values happens before setting + # the value and if 0 is not a valid + # value this will throw self.char_target_heat_cool = serv_thermostat.configure_char( - CHAR_TARGET_HEATING_COOLING, value=0, - setter_callback=self.set_heat_cool) - + CHAR_TARGET_HEATING_COOLING, value=list(self.hc_homekit_to_hass)[0] + ) + self.char_target_heat_cool.override_properties( + valid_values=self.hc_hass_to_homekit + ) # Current and target temperature characteristics + self.char_current_temp = serv_thermostat.configure_char( - CHAR_CURRENT_TEMPERATURE, value=21.0) + 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) + CHAR_TARGET_TEMPERATURE, + value=21.0, + # We do not set PROP_MIN_STEP here and instead use the HomeKit + # default of 0.1 in order to have enough precision to convert + # temperature units and avoid setting to 73F will result in 74F + properties={PROP_MIN_VALUE: hc_min_temp, PROP_MAX_VALUE: hc_max_temp}, + ) # Display units characteristic self.char_display_units = serv_thermostat.configure_char( - CHAR_TEMP_DISPLAY_UNITS, value=0) + 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) + CHAR_COOLING_THRESHOLD_TEMPERATURE, + value=23.0, + # We do not set PROP_MIN_STEP here and instead use the HomeKit + # default of 0.1 in order to have enough precision to convert + # temperature units and avoid setting to 73F will result in 74F + properties={PROP_MIN_VALUE: hc_min_temp, PROP_MAX_VALUE: hc_max_temp}, + ) 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) + CHAR_HEATING_THRESHOLD_TEMPERATURE, + value=19.0, + # We do not set PROP_MIN_STEP here and instead use the HomeKit + # default of 0.1 in order to have enough precision to convert + # temperature units and avoid setting to 73F will result in 74F + properties={PROP_MIN_VALUE: hc_min_temp, PROP_MAX_VALUE: hc_max_temp}, + ) + self.char_target_humidity = None + self.char_current_humidity = None + if CHAR_TARGET_HUMIDITY in self.chars: + self.char_target_humidity = serv_thermostat.configure_char( + CHAR_TARGET_HUMIDITY, + value=50, + # We do not set a max humidity because + # homekit currently has a bug that will show the lower bound + # shifted upwards. For example if you have a max humidity + # of 80% homekit will give you the options 20%-100% instead + # of 0-80% + properties={PROP_MIN_VALUE: min_humidity}, + ) + self.char_current_humidity = serv_thermostat.configure_char( + CHAR_CURRENT_HUMIDITY, value=50 + ) + + self._update_state(state) + + serv_thermostat.setter_callback = self._set_chars + + def _temperature_to_homekit(self, temp): + return temperature_to_homekit(temp, self._unit) + + def _temperature_to_states(self, temp): + return temperature_to_states(temp, self._unit) + + def _set_chars(self, char_values): + _LOGGER.debug("Thermostat _set_chars: %s", char_values) + events = [] + params = {} + service = None + state = self.hass.states.get(self.entity_id) + features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + + hvac_mode = self.hass.states.get(self.entity_id).state + homekit_hvac_mode = HC_HASS_TO_HOMEKIT[hvac_mode] + + if CHAR_TARGET_HEATING_COOLING in char_values: + # Homekit will reset the mode when VIEWING the temp + # Ignore it if its the same mode + if char_values[CHAR_TARGET_HEATING_COOLING] != homekit_hvac_mode: + service = SERVICE_SET_HVAC_MODE_THERMOSTAT + hass_value = self.hc_homekit_to_hass[ + char_values[CHAR_TARGET_HEATING_COOLING] + ] + params = {ATTR_HVAC_MODE: hass_value} + events.append( + f"{CHAR_TARGET_HEATING_COOLING} to {char_values[CHAR_TARGET_HEATING_COOLING]}" + ) + + if CHAR_TARGET_TEMPERATURE in char_values: + hc_target_temp = char_values[CHAR_TARGET_TEMPERATURE] + if features & SUPPORT_TARGET_TEMPERATURE: + service = SERVICE_SET_TEMPERATURE_THERMOSTAT + temperature = self._temperature_to_states(hc_target_temp) + events.append( + f"{CHAR_TARGET_TEMPERATURE} to {char_values[CHAR_TARGET_TEMPERATURE]}°C" + ) + params[ATTR_TEMPERATURE] = temperature + elif features & SUPPORT_TARGET_TEMPERATURE_RANGE: + # Homekit will send us a target temperature + # even if the device does not support it + _LOGGER.debug( + "Homekit requested target temp: %s and the device does not support", + hc_target_temp, + ) + if ( + homekit_hvac_mode == HC_HEAT_COOL_HEAT + and CHAR_HEATING_THRESHOLD_TEMPERATURE not in char_values + ): + char_values[CHAR_HEATING_THRESHOLD_TEMPERATURE] = hc_target_temp + if ( + homekit_hvac_mode == HC_HEAT_COOL_COOL + and CHAR_COOLING_THRESHOLD_TEMPERATURE not in char_values + ): + char_values[CHAR_COOLING_THRESHOLD_TEMPERATURE] = hc_target_temp + + if ( + CHAR_HEATING_THRESHOLD_TEMPERATURE in char_values + or CHAR_COOLING_THRESHOLD_TEMPERATURE in char_values + ): + service = SERVICE_SET_TEMPERATURE_THERMOSTAT + high = self.char_cooling_thresh_temp.value + low = self.char_heating_thresh_temp.value + min_temp, max_temp = self.get_temperature_range() + if CHAR_COOLING_THRESHOLD_TEMPERATURE in char_values: + events.append( + f"{CHAR_COOLING_THRESHOLD_TEMPERATURE} to {char_values[CHAR_COOLING_THRESHOLD_TEMPERATURE]}°C" + ) + high = char_values[CHAR_COOLING_THRESHOLD_TEMPERATURE] + # If the device doesn't support TARGET_TEMPATURE + # this can happen + if high < low: + low = high - HEAT_COOL_DEADBAND + if CHAR_HEATING_THRESHOLD_TEMPERATURE in char_values: + events.append( + f"{CHAR_HEATING_THRESHOLD_TEMPERATURE} to {char_values[CHAR_HEATING_THRESHOLD_TEMPERATURE]}°C" + ) + low = char_values[CHAR_HEATING_THRESHOLD_TEMPERATURE] + # If the device doesn't support TARGET_TEMPATURE + # this can happen + if low > high: + high = low + HEAT_COOL_DEADBAND + + high = min(high, max_temp) + low = max(low, min_temp) + + params.update( + { + ATTR_TARGET_TEMP_HIGH: self._temperature_to_states(high), + ATTR_TARGET_TEMP_LOW: self._temperature_to_states(low), + } + ) + + if service: + params[ATTR_ENTITY_ID] = self.entity_id + self.call_service( + DOMAIN_CLIMATE, service, params, ", ".join(events), + ) + + if CHAR_TARGET_HUMIDITY in char_values: + self.set_target_humidity(char_values[CHAR_TARGET_HUMIDITY]) + + def _configure_hvac_modes(self, state): + """Configure target mode characteristics.""" + hc_modes = state.attributes.get(ATTR_HVAC_MODES) + if not hc_modes: + # This cannot be none OR an empty list + _LOGGER.error( + "%s: HVAC modes not yet available. Please disable auto start for homekit.", + self.entity_id, + ) + hc_modes = ( + HVAC_MODE_HEAT, + HVAC_MODE_COOL, + HVAC_MODE_HEAT_COOL, + HVAC_MODE_OFF, + ) + + # Determine available modes for this entity, + # Prefer HEAT_COOL over AUTO and COOL over FAN_ONLY, DRY + # + # HEAT_COOL is preferred over auto because HomeKit Accessory Protocol describes + # heating or cooling comes on to maintain a target temp which is closest to + # the Home Assistant spec + # + # HVAC_MODE_HEAT_COOL: The device supports heating/cooling to a range + self.hc_homekit_to_hass = { + c: s + for s, c in HC_HASS_TO_HOMEKIT.items() + if ( + s in hc_modes + and not ( + (s == HVAC_MODE_AUTO and HVAC_MODE_HEAT_COOL in hc_modes) + or ( + s in (HVAC_MODE_DRY, HVAC_MODE_FAN_ONLY) + and HVAC_MODE_COOL in hc_modes + ) + ) + ) + } + self.hc_hass_to_homekit = {k: v for v, k in self.hc_homekit_to_hass.items()} 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 = self.hass.states.get(self.entity_id).attributes.get(ATTR_MAX_TEMP) + max_temp = ( + self._temperature_to_homekit(max_temp) 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 = self.hass.states.get(self.entity_id).attributes.get(ATTR_MIN_TEMP) + min_temp = ( + self._temperature_to_homekit(min_temp) 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} + def set_target_humidity(self, value): + """Set target humidity to value if call came from HomeKit.""" + _LOGGER.debug("%s: Set target humidity to %d", self.entity_id, value) + params = {ATTR_ENTITY_ID: self.entity_id, ATTR_HUMIDITY: value} 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)) + DOMAIN_CLIMATE, SERVICE_SET_HUMIDITY, params, f"{value}{UNIT_PERCENTAGE}" + ) def update_state(self, new_state): """Update thermostat state after state changed.""" + if self._state_updates < 3: + # When we get the first state updates + # we recheck valid hvac modes as the entity + # may not have been fully setup when we saw it the + # first time + original_hc_hass_to_homekit = self.hc_hass_to_homekit + self._configure_hvac_modes(new_state) + if self.hc_hass_to_homekit != original_hc_hass_to_homekit: + if self.char_target_heat_cool.value not in self.hc_homekit_to_hass: + # We must make sure the char value is + # in the new valid values before + # setting the new valid values or + # changing them with throw + self.char_target_heat_cool.set_value( + list(self.hc_homekit_to_hass)[0], should_notify=False + ) + self.char_target_heat_cool.override_properties( + valid_values=self.hc_hass_to_homekit + ) + self._state_updates += 1 + + self._update_state(new_state) + + def _update_state(self, new_state): + """Update state without rechecking the device features.""" + features = new_state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + + # Update target operation mode FIRST + hvac_mode = new_state.state + if hvac_mode and hvac_mode in HC_HASS_TO_HOMEKIT: + homekit_hvac_mode = HC_HASS_TO_HOMEKIT[hvac_mode] + if homekit_hvac_mode in self.hc_homekit_to_hass: + if self.char_target_heat_cool.value != homekit_hvac_mode: + self.char_target_heat_cool.set_value(homekit_hvac_mode) + else: + _LOGGER.error( + "Cannot map hvac target mode: %s to homekit as only %s modes are supported", + hvac_mode, + self.hc_homekit_to_hass, + ) + + # Set current operation mode for supported thermostats + hvac_action = new_state.attributes.get(ATTR_HVAC_ACTION) + if hvac_action: + homekit_hvac_action = HC_HASS_TO_HOMEKIT_ACTION[hvac_action] + if self.char_current_heat_cool.value != homekit_hvac_action: + self.char_current_heat_cool.set_value(homekit_hvac_action) + # 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 + current_temp = self._temperature_to_homekit(current_temp) + if self.char_current_temp.value != current_temp: + self.char_current_temp.set_value(current_temp) + + # Update current humidity + if CHAR_CURRENT_HUMIDITY in self.chars: + current_humdity = new_state.attributes.get(ATTR_CURRENT_HUMIDITY) + if isinstance(current_humdity, (int, float)): + if self.char_current_humidity.value != current_humdity: + self.char_current_humidity.set_value(current_humdity) + + # Update target humidity + if CHAR_TARGET_HUMIDITY in self.chars: + target_humdity = new_state.attributes.get(ATTR_HUMIDITY) + if isinstance(target_humdity, (int, float)): + if self.char_target_humidity.value != target_humdity: + self.char_target_humidity.set_value(target_humdity) # 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: + cooling_thresh = self._temperature_to_homekit(cooling_thresh) + if self.char_heating_thresh_temp.value != cooling_thresh: 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: + heating_thresh = self._temperature_to_homekit(heating_thresh) + if self.char_heating_thresh_temp.value != heating_thresh: self.char_heating_thresh_temp.set_value(heating_thresh) - self._flag_heatingthresh = False + + # Update target temperature + target_temp = new_state.attributes.get(ATTR_TEMPERATURE) + if isinstance(target_temp, (int, float)): + target_temp = self._temperature_to_homekit(target_temp) + elif features & SUPPORT_TARGET_TEMPERATURE_RANGE: + # Homekit expects a target temperature + # even if the device does not support it + hc_hvac_mode = self.char_target_heat_cool.value + if hc_hvac_mode == HC_HEAT_COOL_HEAT: + temp_low = new_state.attributes.get(ATTR_TARGET_TEMP_LOW) + if isinstance(temp_low, (int, float)): + target_temp = self._temperature_to_homekit(temp_low) + elif hc_hvac_mode == HC_HEAT_COOL_COOL: + temp_high = new_state.attributes.get(ATTR_TARGET_TEMP_HIGH) + if isinstance(temp_high, (int, float)): + target_temp = self._temperature_to_homekit(temp_high) + if target_temp and self.char_target_temp.value != target_temp: + self.char_target_temp.set_value(target_temp) # 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]) + unit = UNIT_HASS_TO_HOMEKIT[self._unit] + if self.char_display_units.value != unit: + self.char_display_units.set_value(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') + +@TYPES.register("WaterHeater") class WaterHeater(HomeAccessory): """Generate a WaterHeater accessory for a water_heater.""" @@ -289,67 +512,79 @@ 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) + 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) + CHAR_TARGET_HEATING_COOLING, + value=1, + setter_callback=self.set_heat_cool, + valid_values=HC_HOMEKIT_VALID_MODES_WATER_HEATER, + ) self.char_current_temp = serv_thermostat.configure_char( - CHAR_CURRENT_TEMPERATURE, value=50.0) + 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) + CHAR_TARGET_TEMPERATURE, + value=50.0, + # We do not set PROP_MIN_STEP here and instead use the HomeKit + # default of 0.1 in order to have enough precision to convert + # temperature units and avoid setting to 73F will result in 74F + properties={PROP_MIN_VALUE: min_temp, PROP_MAX_VALUE: max_temp}, + setter_callback=self.set_target_temperature, + ) self.char_display_units = serv_thermostat.configure_char( - CHAR_TEMP_DISPLAY_UNITS, value=0) + CHAR_TEMP_DISPLAY_UNITS, value=0 + ) + + state = self.hass.states.get(self.entity_id) + self.update_state(state) 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 \ + 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 \ + 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 + _LOGGER.debug("%s: Set heat-cool to %d", self.entity_id, value) hass_value = HC_HOMEKIT_TO_HASS[value] - if hass_value != STATE_HEAT: - self.char_target_heat_cool.set_value(1) # Heat + if hass_value != HVAC_MODE_HEAT: + if self.char_target_heat_cool.value != 1: + 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 + _LOGGER.debug("%s: Set target temperature to %.1f°C", self.entity_id, value) temperature = temperature_to_states(value, self._unit) - params = { - ATTR_ENTITY_ID: self.entity_id, - ATTR_TEMPERATURE: temperature} + 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)) + DOMAIN_WATER_HEATER, + SERVICE_SET_TEMPERATURE_WATER_HEATER, + params, + f"{temperature}{self._unit}", + ) def update_state(self, new_state): """Update water_heater state after state change.""" @@ -357,17 +592,16 @@ def update_state(self, new_state): 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: + if temperature != self.char_current_temp.value: 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]) + unit = UNIT_HASS_TO_HOMEKIT[self._unit] + if self.char_display_units.value != unit: + self.char_display_units.set_value(unit) # Update target operation mode - operation_mode = new_state.attributes.get(ATTR_OPERATION_MODE) - if operation_mode and not self._flag_heat_cool: + operation_mode = new_state.state + if operation_mode and self.char_target_heat_cool.value != 1: 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 index b3c90ae6cbe3a..3ccf73d39250b 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -1,59 +1,110 @@ """Collection of useful functions for the HomeKit component.""" from collections import OrderedDict, namedtuple +import io import logging +import os +import secrets +import socket +import pyqrcode 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 + ATTR_CODE, + ATTR_SUPPORTED_FEATURES, + CONF_NAME, + CONF_TYPE, + TEMP_CELSIUS, +) +from homeassistant.core import HomeAssistant, split_entity_id import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.storage import STORAGE_DIR 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) + CONF_FEATURE, + CONF_FEATURE_LIST, + CONF_LINKED_BATTERY_SENSOR, + CONF_LOW_BATTERY_THRESHOLD, + DEFAULT_LOW_BATTERY_THRESHOLD, + DOMAIN, + FEATURE_ON_OFF, + FEATURE_PLAY_PAUSE, + FEATURE_PLAY_STOP, + FEATURE_TOGGLE_MUTE, + HOMEKIT_FILE, + HOMEKIT_PAIRING_QR, + HOMEKIT_PAIRING_QR_SECRET, + 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))), -}) +MAX_PORT = 65535 + +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') + raise vol.Invalid("expected a dictionary") entities = {} for entity_id, config in values.items(): @@ -61,10 +112,9 @@ def validate_entity_config(values): domain, _ = split_entity_id(entity) if not isinstance(config, dict): - raise vol.Invalid('The configuration for {} must be ' - ' a dictionary.'.format(entity)) + raise vol.Invalid(f"The configuration for {entity} must be a dictionary.") - if domain in ('alarm_control_panel', 'lock'): + if domain in ("alarm_control_panel", "lock"): config = CODE_SCHEMA(config) elif domain == media_player.const.DOMAIN: @@ -74,12 +124,11 @@ def validate_entity_config(values): 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)) + raise vol.Invalid(f"A feature can be added only once for {entity}") feature_list[key] = params config[CONF_FEATURE_LIST] = feature_list - elif domain == 'switch': + elif domain == "switch": config = SWITCH_TYPE_SCHEMA(config) else: @@ -94,14 +143,13 @@ def validate_media_player_features(state, feature_list): features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) supported_modes = [] - if features & (media_player.const.SUPPORT_TURN_ON | - media_player.const.SUPPORT_TURN_OFF): + 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): + 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): + 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) @@ -112,13 +160,12 @@ def validate_media_player_features(state, feature_list): error_list.append(feature) if error_list: - _LOGGER.error('%s does not support features: %s', - state.entity_id, 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 = namedtuple("SpeedRange", ("start", "target")) SpeedRange.__doc__ += """ Maps Home Assistant speed \ values to percentage based HomeKit speeds. start: Start of the range (inclusive). @@ -133,10 +180,14 @@ class HomeKitSpeedMapping: 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]) + _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): @@ -154,7 +205,7 @@ def speed_to_homekit(self, speed): if speed is None: return None speed_range = self.speed_ranges[speed] - return speed_range.target + return round(speed_range.target) def speed_to_states(self, speed): """Map HomeKit speed to Home Assistant speed state.""" @@ -164,19 +215,33 @@ def speed_to_states(self, speed): return list(self.speed_ranges.keys())[0] -def show_setup_message(hass, pincode): +def show_setup_message(hass, entry_id, bridge_name, pincode, uri): """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) + _LOGGER.info("Pincode: %s", pin) + + buffer = io.BytesIO() + url = pyqrcode.create(uri) + url.svg(buffer, scale=5) + pairing_secret = secrets.token_hex(32) + + hass.data[DOMAIN][entry_id][HOMEKIT_PAIRING_QR] = buffer.getvalue() + hass.data[DOMAIN][entry_id][HOMEKIT_PAIRING_QR_SECRET] = pairing_secret + + message = ( + f"To set up {bridge_name} in the Home App, " + f"scan the QR code or enter the following code:\n" + f"### {pin}\n" + f"![image](/api/homekit/pairingqr?{entry_id}-{pairing_secret})" + ) hass.components.persistent_notification.create( - message, 'HomeKit Setup', HOMEKIT_NOTIFY_ID) + message, "HomeKit Bridge Setup", entry_id + ) -def dismiss_setup_message(hass): +def dismiss_setup_message(hass, entry_id): """Dismiss persistent notification and remove QR code.""" - hass.components.persistent_notification.dismiss(HOMEKIT_NOTIFY_ID) + hass.components.persistent_notification.dismiss(entry_id) def convert_to_float(state): @@ -189,7 +254,7 @@ def convert_to_float(state): def temperature_to_homekit(temperature, unit): """Convert temperature to Celsius for HomeKit.""" - return round(temp_util.convert(temperature, unit, TEMP_CELSIUS) * 2) / 2 + return round(temp_util.convert(temperature, unit, TEMP_CELSIUS), 1) def temperature_to_states(temperature, unit): @@ -208,3 +273,85 @@ def density_to_air_quality(density): if density <= 150: return 4 return 5 + + +def get_persist_filename_for_entry_id(entry_id: str): + """Determine the filename of the homekit state file.""" + return f"{DOMAIN}.{entry_id}.state" + + +def get_aid_storage_filename_for_entry_id(entry_id: str): + """Determine the ilename of homekit aid storage file.""" + return f"{DOMAIN}.{entry_id}.aids" + + +def get_persist_fullpath_for_entry_id(hass: HomeAssistant, entry_id: str): + """Determine the path to the homekit state file.""" + return hass.config.path(STORAGE_DIR, get_persist_filename_for_entry_id(entry_id)) + + +def get_aid_storage_fullpath_for_entry_id(hass: HomeAssistant, entry_id: str): + """Determine the path to the homekit aid storage file.""" + return hass.config.path( + STORAGE_DIR, get_aid_storage_filename_for_entry_id(entry_id) + ) + + +def migrate_filesystem_state_data_for_primary_imported_entry_id( + hass: HomeAssistant, entry_id: str +): + """Migrate the old paths to the storage directory.""" + legacy_persist_file_path = hass.config.path(HOMEKIT_FILE) + if os.path.exists(legacy_persist_file_path): + os.rename( + legacy_persist_file_path, get_persist_fullpath_for_entry_id(hass, entry_id) + ) + + legacy_aid_storage_path = hass.config.path(STORAGE_DIR, "homekit.aids") + if os.path.exists(legacy_aid_storage_path): + os.rename( + legacy_aid_storage_path, + get_aid_storage_fullpath_for_entry_id(hass, entry_id), + ) + + +def remove_state_files_for_entry_id(hass: HomeAssistant, entry_id: str): + """Remove the state files from disk.""" + persist_file_path = get_persist_fullpath_for_entry_id(hass, entry_id) + aid_storage_path = get_aid_storage_fullpath_for_entry_id(hass, entry_id) + os.unlink(persist_file_path) + if os.path.exists(aid_storage_path): + os.unlink(aid_storage_path) + return True + + +def _get_test_socket(): + """Create a socket to test binding ports.""" + test_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + test_socket.setblocking(False) + test_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + return test_socket + + +def port_is_available(port: int): + """Check to see if a port is available.""" + test_socket = _get_test_socket() + try: + test_socket.bind(("", port)) + except OSError: + return False + + return True + + +def find_next_available_port(start_port: int): + """Find the next available port starting with the given port.""" + test_socket = _get_test_socket() + for port in range(start_port, MAX_PORT): + try: + test_socket.bind(("", port)) + return port + except OSError: + if port == MAX_PORT: + raise + continue diff --git a/homeassistant/components/homekit_controller/.translations/bg.json b/homeassistant/components/homekit_controller/.translations/bg.json deleted file mode 100644 index be7d5d323aca3..0000000000000 --- a/homeassistant/components/homekit_controller/.translations/bg.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "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 deleted file mode 100644 index 8765a859418f5..0000000000000 --- a/homeassistant/components/homekit_controller/.translations/ca.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "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_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/cy.json b/homeassistant/components/homekit_controller/.translations/cy.json deleted file mode 100644 index 59e402080f3f4..0000000000000 --- a/homeassistant/components/homekit_controller/.translations/cy.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "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 deleted file mode 100644 index 3451053eb072a..0000000000000 --- a/homeassistant/components/homekit_controller/.translations/da.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "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 deleted file mode 100644 index d13d2bb7e2a76..0000000000000 --- a/homeassistant/components/homekit_controller/.translations/de.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "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_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 deleted file mode 100644 index 059f0f7afe793..0000000000000 --- a/homeassistant/components/homekit_controller/.translations/en.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "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_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 deleted file mode 100644 index b058e94e25ad2..0000000000000 --- a/homeassistant/components/homekit_controller/.translations/es-419.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "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 deleted file mode 100644 index f22b41586984f..0000000000000 --- a/homeassistant/components/homekit_controller/.translations/es.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "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, 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.", - "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." - }, - "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 deleted file mode 100644 index 73cbbdf046ad2..0000000000000 --- a/homeassistant/components/homekit_controller/.translations/fr.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "L'accessoire est d\u00e9j\u00e0 configur\u00e9 avec ce contr\u00f4leur.", - "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.", - "unable_to_pair": "Impossible d'appairer, veuillez r\u00e9essayer.", - "unknown_error": "L'appareil a signal\u00e9 une erreur inconnue. L'appairage a \u00e9chou\u00e9." - }, - "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/it.json b/homeassistant/components/homekit_controller/.translations/it.json deleted file mode 100644 index 6ec1c28344845..0000000000000 --- a/homeassistant/components/homekit_controller/.translations/it.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "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 deleted file mode 100644 index c780f07e96ea5..0000000000000 --- a/homeassistant/components/homekit_controller/.translations/ko.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "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_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 \uc7a5\uce58 \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 deleted file mode 100644 index 882a1d3bc3af4..0000000000000 --- a/homeassistant/components/homekit_controller/.translations/lb.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "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 deleted file mode 100644 index 30380344d9b3f..0000000000000 --- a/homeassistant/components/homekit_controller/.translations/nl.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "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.", - "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 deleted file mode 100644 index 995d67792389d..0000000000000 --- a/homeassistant/components/homekit_controller/.translations/nn.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "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 deleted file mode 100644 index 555faef1061d8..0000000000000 --- a/homeassistant/components/homekit_controller/.translations/no.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Tilbeh\u00f8r er allerede konfigurert med denne kontrolleren.", - "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.", - "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 deleted file mode 100644 index acbc6ee81f740..0000000000000 --- a/homeassistant/components/homekit_controller/.translations/pl.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Akcesorium jest ju\u017c skonfigurowane z tym kontrolerem.", - "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.", - "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.json b/homeassistant/components/homekit_controller/.translations/pt.json deleted file mode 100644 index 37f68408ce44f..0000000000000 --- a/homeassistant/components/homekit_controller/.translations/pt.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "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 deleted file mode 100644 index 44b4faf455f94..0000000000000 --- a/homeassistant/components/homekit_controller/.translations/ru.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "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_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 deleted file mode 100644 index afee189216d36..0000000000000 --- a/homeassistant/components/homekit_controller/.translations/sl.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "config": { - "abort": { - "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.", - "unable_to_pair": "Ni mogo\u010de seznaniti. Poskusite znova.", - "unknown_error": "Naprava je sporo\u010dila neznano napako. Seznanjanje ni uspelo." - }, - "step": { - "pair": { - "data": { - "pairing_code": "Koda za seznanjanje" - }, - "description": "Vnesi HomeKit kodo, \u010de \u017eeli\u0161 uporabiti to dodatno opremo", - "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 deleted file mode 100644 index 32372840031b8..0000000000000 --- a/homeassistant/components/homekit_controller/.translations/sv.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "config": { - "abort": { - "already_paired": "Det h\u00e4r tillbeh\u00f6ret \u00e4r redan kopplat till en annan enhet. \u00c5terst\u00e4ll tillbeh\u00f6ret och f\u00f6rs\u00f6k igen.", - "no_devices": "Inga oparade enheter kunde hittas" - }, - "error": { - "authentication_error": "Felaktig HomeKit-kod. V\u00e4nligen kontrollera och f\u00f6rs\u00f6k igen.", - "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 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 deleted file mode 100644 index c0311b0f19894..0000000000000 --- a/homeassistant/components/homekit_controller/.translations/th.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "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 deleted file mode 100644 index cc16ebc70c455..0000000000000 --- a/homeassistant/components/homekit_controller/.translations/vi.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "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 deleted file mode 100644 index d8c7ba8c4da06..0000000000000 --- a/homeassistant/components/homekit_controller/.translations/zh-Hans.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "config": { - "abort": { - "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", - "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" - }, - "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 deleted file mode 100644 index 25ca625d7df19..0000000000000 --- a/homeassistant/components/homekit_controller/.translations/zh-Hant.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "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_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 index 1b1c7b96b5835..47f3cf2057103 100644 --- a/homeassistant/components/homekit_controller/__init__.py +++ b/homeassistant/components/homekit_controller/__init__.py @@ -1,30 +1,31 @@ """Support for Homekit device discovery.""" import logging +from typing import Any, Dict + +import aiohomekit +from aiohomekit.model import Accessory +from aiohomekit.model.characteristics import ( + Characteristic, + CharacteristicPermissions, + CharacteristicsTypes, +) +from aiohomekit.model.services import Service, ServicesTypes -from homeassistant.components.discovery import SERVICE_HOMEKIT -from homeassistant.helpers import discovery +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity import Entity -from .config_flow import load_old_pairings -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 .config_flow import normalize_hkid +from .connection import HKDevice +from .const import CONTROLLER, DOMAIN, ENTITY_MAP, KNOWN_DEVICES from .storage import EntityMapStorage -HOMEKIT_IGNORE = [ - 'BSB002', - 'Home Assistant Bridge', - 'TRADFRI gateway', -] - _LOGGER = logging.getLogger(__name__) def escape_characteristic_name(char_name): """Escape any dash or dots in a characteristics name.""" - return char_name.replace('-', '_').replace('.', '_') + return char_name.replace("-", "_").replace(".", "_") class HomeKitEntity(Entity): @@ -32,183 +33,203 @@ class HomeKitEntity(Entity): 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._aid = devinfo["aid"] + self._iid = devinfo["iid"] self._features = 0 - self._chars = {} self.setup() + self._signals = [] + + @property + def accessory(self) -> Accessory: + """Return an Accessory model that this entity is attached to.""" + return self._accessory.entity_map.aid(self._aid) + + @property + def accessory_info(self) -> Service: + """Information about the make and model of an accessory.""" + return self.accessory.services.first( + service_type=ServicesTypes.ACCESSORY_INFORMATION + ) + + @property + def service(self) -> Service: + """Return a Service model that this entity is attached to.""" + return self.accessory.services.iid(self._iid) + + async def async_added_to_hass(self): + """Entity added to hass.""" + self._signals.append( + self.hass.helpers.dispatcher.async_dispatcher_connect( + self._accessory.signal_state_updated, self.async_write_ha_state + ) + ) + + self._accessory.add_pollable_characteristics(self.pollable_characteristics) + self._accessory.add_watchable_characteristics(self.watchable_characteristics) + + async def async_will_remove_from_hass(self): + """Prepare to be removed from hass.""" + self._accessory.remove_pollable_characteristics(self._aid) + self._accessory.remove_watchable_characteristics(self._aid) + + for signal_remove in self._signals: + signal_remove() + self._signals.clear() + + async def async_put_characteristics(self, characteristics: Dict[str, Any]): + """ + Write characteristics to the device. + + A characteristic type is unique within a service, but in order to write + to a named characteristic on a bridge we need to turn its type into + an aid and iid, and send it as a list of tuples, which is what this + helper does. + + E.g. you can do: + + await entity.async_put_characteristics({ + CharacteristicsTypes.ON: True + }) + """ + payload = self.service.build_update(characteristics) + return await self._accessory.put_characteristics(payload) + + @property + def should_poll(self) -> bool: + """Return False. + + Data update is triggered from HKDevice. + """ + return False + 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 + """Configure an entity baed on its HomeKit characteristics metadata.""" + self.pollable_characteristics = [] + self.watchable_characteristics = [] + + char_types = self.get_characteristic_types() + + # Setup events and/or polling for characteristics directly attached to this entity + for char in self.service.characteristics.filter(char_types=char_types): + self._setup_characteristic(char) + # Setup events and/or polling for characteristics attached to sub-services of this + # entity (like an INPUT_SOURCE). + for service in self.accessory.services.filter(parent_service=self.service): + for char in service.characteristics.filter(char_types=char_types): + self._setup_characteristic(char) + + def _setup_characteristic(self, char: Characteristic): + """Configure an entity based on a HomeKit characteristics metadata.""" # 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) - - 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: - # 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']) + if CharacteristicPermissions.paired_read in char.perms: + self.pollable_characteristics.append((self._aid, char.iid)) + + # Build up a list of (aid, iid) tuples to subscribe to + if CharacteristicPermissions.events in char.perms: + self.watchable_characteristics.append((self._aid, char.iid)) @property - def unique_id(self): + def unique_id(self) -> str: """Return the ID of this device.""" - serial = self._accessory_info['serial-number'] - return "homekit-{}-{}".format(serial, self._iid) + serial = self.accessory_info.value(CharacteristicsTypes.SERIAL_NUMBER) + return f"homekit-{serial}-{self._iid}" @property - def name(self): + def name(self) -> str: """Return the name of the device if any.""" - return self._accessory_info.get('name') + return self.accessory_info.value(CharacteristicsTypes.NAME) @property def available(self) -> bool: """Return True if entity is available.""" - return self._available + return self._accessory.available + + @property + def device_info(self): + """Return the device info.""" + info = self.accessory_info + accessory_serial = info.value(CharacteristicsTypes.SERIAL_NUMBER) + + device_info = { + "identifiers": {(DOMAIN, "serial-number", accessory_serial)}, + "name": info.value(CharacteristicsTypes.NAME), + "manufacturer": info.value(CharacteristicsTypes.MANUFACTURER, ""), + "model": info.value(CharacteristicsTypes.MODEL, ""), + "sw_version": info.value(CharacteristicsTypes.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 + + # For backwards compat + if entry.unique_id is None: + hass.config_entries.async_update_entry( + entry, unique_id=normalize_hkid(conn.unique_id) + ) + + 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 - from homekit.controller.ip_implementation import IpPairing - map_storage = hass.data[ENTITY_MAP] = EntityMapStorage(hass) await map_storage.async_initialize() - hass.data[CONTROLLER] = controller = homekit.Controller() - - old_pairings = await hass.async_add_executor_job( - load_old_pairings, - hass - ) - for hkid, pairing_data in old_pairings.items(): - controller.pairings[hkid] = IpPairing(pairing_data) - - def discovery_dispatch(service, discovery_info): - """Dispatcher for Homekit discovery events.""" - # model, id - host = discovery_info['host'] - port = discovery_info['port'] - - # Fold property keys to lower case, making them effectively - # case-insensitive. Some HomeKit devices capitalize them. - properties = { - key.lower(): value - for (key, value) in discovery_info['properties'].items() - } - - model = properties['md'] - hkid = properties['id'] - config_num = int(properties['c#']) - - if model in HOMEKIT_IGNORE: - return + hass.data[CONTROLLER] = aiohomekit.Controller() + hass.data[KNOWN_DEVICES] = {} - # Only register a device once, but rescan if the config has changed - if hkid in hass.data[KNOWN_DEVICES]: - device = hass.data[KNOWN_DEVICES][hkid] - if config_num > device.config_num and \ - device.pairing is not None: - device.refresh_entity_map(config_num) - return + return True - _LOGGER.debug('Discovered unique device %s', hkid) - device = HKDevice(hass, host, port, model, hkid, config_num, config) - device.setup() - hass.data[KNOWN_DEVICES] = {} +async def async_unload_entry(hass, entry): + """Disconnect from HomeKit devices before unloading entry.""" + hkid = entry.data["AccessoryPairingID"] - await hass.async_add_executor_job( - discovery.listen, hass, SERVICE_HOMEKIT, discovery_dispatch) + if hkid in hass.data[KNOWN_DEVICES]: + connection = hass.data[KNOWN_DEVICES][hkid] + await connection.async_unload() return True async def async_remove_entry(hass, entry): """Cleanup caches before removing config entry.""" - hkid = entry.data['AccessoryPairingID'] + hkid = entry.data["AccessoryPairingID"] hass.data[ENTITY_MAP].async_delete_map(hkid) diff --git a/homeassistant/components/homekit_controller/air_quality.py b/homeassistant/components/homekit_controller/air_quality.py new file mode 100644 index 0000000000000..999980ad60ccc --- /dev/null +++ b/homeassistant/components/homekit_controller/air_quality.py @@ -0,0 +1,95 @@ +"""Support for HomeKit Controller air quality sensors.""" +from aiohomekit.model.characteristics import CharacteristicsTypes + +from homeassistant.components.air_quality import AirQualityEntity +from homeassistant.core import callback + +from . import KNOWN_DEVICES, HomeKitEntity + +AIR_QUALITY_TEXT = { + 0: "unknown", + 1: "excellent", + 2: "good", + 3: "fair", + 4: "inferior", + 5: "poor", +} + + +class HomeAirQualitySensor(HomeKitEntity, AirQualityEntity): + """Representation of a HomeKit Controller Air Quality sensor.""" + + def get_characteristic_types(self): + """Define the homekit characteristics the entity cares about.""" + return [ + CharacteristicsTypes.AIR_QUALITY, + CharacteristicsTypes.DENSITY_PM25, + CharacteristicsTypes.DENSITY_PM10, + CharacteristicsTypes.DENSITY_OZONE, + CharacteristicsTypes.DENSITY_NO2, + CharacteristicsTypes.DENSITY_SO2, + CharacteristicsTypes.DENSITY_VOC, + ] + + @property + def particulate_matter_2_5(self): + """Return the particulate matter 2.5 level.""" + return self.service.value(CharacteristicsTypes.DENSITY_PM25) + + @property + def particulate_matter_10(self): + """Return the particulate matter 10 level.""" + return self.service.value(CharacteristicsTypes.DENSITY_PM10) + + @property + def ozone(self): + """Return the O3 (ozone) level.""" + return self.service.value(CharacteristicsTypes.DENSITY_OZONE) + + @property + def sulphur_dioxide(self): + """Return the SO2 (sulphur dioxide) level.""" + return self.service.value(CharacteristicsTypes.DENSITY_SO2) + + @property + def nitrogen_dioxide(self): + """Return the NO2 (nitrogen dioxide) level.""" + return self.service.value(CharacteristicsTypes.DENSITY_NO2) + + @property + def air_quality_text(self): + """Return the Air Quality Index (AQI).""" + air_quality = self.service.value(CharacteristicsTypes.AIR_QUALITY) + return AIR_QUALITY_TEXT.get(air_quality, "unknown") + + @property + def volatile_organic_compounds(self): + """Return the volatile organic compounds (VOC) level.""" + return self.service.value(CharacteristicsTypes.DENSITY_VOC) + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + data = {"air_quality_text": self.air_quality_text} + + voc = self.volatile_organic_compounds + if voc: + data["volatile_organic_compounds"] = voc + + return data + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Homekit air quality sensor.""" + hkid = config_entry.data["AccessoryPairingID"] + conn = hass.data[KNOWN_DEVICES][hkid] + + @callback + def async_add_service(aid, service): + if service["stype"] != "air-quality": + return False + info = {"aid": aid, "iid": service["iid"]} + async_add_entities([HomeAirQualitySensor(conn, info)], True) + return True + + conn.add_listener(async_add_service) diff --git a/homeassistant/components/homekit_controller/alarm_control_panel.py b/homeassistant/components/homekit_controller/alarm_control_panel.py index fe15cfe2eab9a..0b8f0b3b2f84f 100644 --- a/homeassistant/components/homekit_controller/alarm_control_panel.py +++ b/homeassistant/components/homekit_controller/alarm_control_panel.py @@ -1,14 +1,27 @@ """Support for Homekit Alarm Control Panel.""" import logging -from homeassistant.components.alarm_control_panel import AlarmControlPanel +from aiohomekit.model.characteristics import CharacteristicsTypes + +from homeassistant.components.alarm_control_panel import AlarmControlPanelEntity +from homeassistant.components.alarm_control_panel.const import ( + SUPPORT_ALARM_ARM_AWAY, + SUPPORT_ALARM_ARM_HOME, + SUPPORT_ALARM_ARM_NIGHT, +) 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) + ATTR_BATTERY_LEVEL, + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_HOME, + STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_DISARMED, + STATE_ALARM_TRIGGERED, +) +from homeassistant.core import callback from . import KNOWN_DEVICES, HomeKitEntity -ICON = 'mdi:security' +ICON = "mdi:security" _LOGGER = logging.getLogger(__name__) @@ -28,40 +41,33 @@ } -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up Homekit Alarm Control Panel support.""" - if discovery_info is None: - return - accessory = hass.data[KNOWN_DEVICES][discovery_info['serial']] - add_entities([HomeKitAlarmControlPanel(accessory, discovery_info)], - True) +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] + @callback + def async_add_service(aid, service): + if service["stype"] != "security-system": + return False + info = {"aid": aid, "iid": service["iid"]} + async_add_entities([HomeKitAlarmControlPanelEntity(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 +class HomeKitAlarmControlPanelEntity(HomeKitEntity, AlarmControlPanelEntity): + """Representation of a Homekit Alarm Control Panel.""" 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.""" @@ -70,7 +76,14 @@ def icon(self): @property def state(self): """Return the state of the device.""" - return self._state + return CURRENT_STATE_MAP[ + self.service.value(CharacteristicsTypes.SECURITY_SYSTEM_STATE_CURRENT) + ] + + @property + def supported_features(self) -> int: + """Return the list of supported features.""" + return SUPPORT_ALARM_ARM_HOME | SUPPORT_ALARM_ARM_AWAY | SUPPORT_ALARM_ARM_NIGHT async def async_alarm_disarm(self, code=None): """Send disarm command.""" @@ -90,17 +103,17 @@ async def async_alarm_arm_night(self, code=None): 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) + await self.async_put_characteristics( + {CharacteristicsTypes.SECURITY_SYSTEM_STATE_TARGET: TARGET_STATE_MAP[state]} + ) @property def device_state_attributes(self): """Return the optional state attributes.""" - if self._battery_level is None: - return None + attributes = {} + + battery_level = self.service.value(CharacteristicsTypes.BATTERY_LEVEL) + if battery_level: + attributes[ATTR_BATTERY_LEVEL] = battery_level - return { - ATTR_BATTERY_LEVEL: self._battery_level, - } + return attributes diff --git a/homeassistant/components/homekit_controller/binary_sensor.py b/homeassistant/components/homekit_controller/binary_sensor.py index a5b7008200236..939c6055e10f1 100644 --- a/homeassistant/components/homekit_controller/binary_sensor.py +++ b/homeassistant/components/homekit_controller/binary_sensor.py @@ -1,46 +1,134 @@ """Support for Homekit motion sensors.""" import logging -from homeassistant.components.binary_sensor import BinarySensorDevice +from aiohomekit.model.characteristics import CharacteristicsTypes + +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_MOISTURE, + DEVICE_CLASS_MOTION, + DEVICE_CLASS_OCCUPANCY, + DEVICE_CLASS_OPENING, + DEVICE_CLASS_SMOKE, + BinarySensorEntity, +) +from homeassistant.core import callback from . import KNOWN_DEVICES, HomeKitEntity _LOGGER = logging.getLogger(__name__) -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up Homekit motion sensor support.""" - if discovery_info is not None: - accessory = hass.data[KNOWN_DEVICES][discovery_info['serial']] - add_entities([HomeKitMotionSensor(accessory, discovery_info)], True) +class HomeKitMotionSensor(HomeKitEntity, BinarySensorEntity): + """Representation of a Homekit motion sensor.""" + + def get_characteristic_types(self): + """Define the homekit characteristics the entity is tracking.""" + return [CharacteristicsTypes.MOTION_DETECTED] + + @property + def device_class(self): + """Define this binary_sensor as a motion sensor.""" + return DEVICE_CLASS_MOTION + + @property + def is_on(self): + """Has motion been detected.""" + return self.service.value(CharacteristicsTypes.MOTION_DETECTED) + + +class HomeKitContactSensor(HomeKitEntity, BinarySensorEntity): + """Representation of a Homekit contact sensor.""" + + def get_characteristic_types(self): + """Define the homekit characteristics the entity is tracking.""" + return [CharacteristicsTypes.CONTACT_STATE] + + @property + def device_class(self): + """Define this binary_sensor as a opening sensor.""" + return DEVICE_CLASS_OPENING + + @property + def is_on(self): + """Return true if the binary sensor is on/open.""" + return self.service.value(CharacteristicsTypes.CONTACT_STATE) == 1 + + +class HomeKitSmokeSensor(HomeKitEntity, BinarySensorEntity): + """Representation of a Homekit smoke sensor.""" + + @property + def device_class(self) -> str: + """Return the class of this sensor.""" + return DEVICE_CLASS_SMOKE + + def get_characteristic_types(self): + """Define the homekit characteristics the entity is tracking.""" + return [CharacteristicsTypes.SMOKE_DETECTED] + + @property + def is_on(self): + """Return true if smoke is currently detected.""" + return self.service.value(CharacteristicsTypes.SMOKE_DETECTED) == 1 -class HomeKitMotionSensor(HomeKitEntity, BinarySensorDevice): - """Representation of a Homekit sensor.""" +class HomeKitOccupancySensor(HomeKitEntity, BinarySensorEntity): + """Representation of a Homekit occupancy sensor.""" - def __init__(self, *args): - """Initialise the entity.""" - super().__init__(*args) - self._on = False + @property + def device_class(self) -> str: + """Return the class of this sensor.""" + return DEVICE_CLASS_OCCUPANCY 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.OCCUPANCY_DETECTED] + + @property + def is_on(self): + """Return true if occupancy is currently detected.""" + return self.service.value(CharacteristicsTypes.OCCUPANCY_DETECTED) == 1 + - return [ - CharacteristicsTypes.MOTION_DETECTED, - ] +class HomeKitLeakSensor(HomeKitEntity, BinarySensorEntity): + """Representation of a Homekit leak sensor.""" - def _update_motion_detected(self, value): - self._on = value + def get_characteristic_types(self): + """Define the homekit characteristics the entity is tracking.""" + return [CharacteristicsTypes.LEAK_DETECTED] @property def device_class(self): - """Define this binary_sensor as a motion sensor.""" - return 'motion' + """Define this binary_sensor as a leak sensor.""" + return DEVICE_CLASS_MOISTURE @property def is_on(self): - """Has motion been detected.""" - return self._on + """Return true if a leak is detected from the binary sensor.""" + return self.service.value(CharacteristicsTypes.LEAK_DETECTED) == 1 + + +ENTITY_TYPES = { + "motion": HomeKitMotionSensor, + "contact": HomeKitContactSensor, + "smoke": HomeKitSmokeSensor, + "occupancy": HomeKitOccupancySensor, + "leak": HomeKitLeakSensor, +} + + +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] + + @callback + def async_add_service(aid, service): + entity_class = ENTITY_TYPES.get(service["stype"]) + if not entity_class: + return False + info = {"aid": aid, "iid": service["iid"]} + async_add_entities([entity_class(conn, info)], True) + return True + + conn.add_listener(async_add_service) diff --git a/homeassistant/components/homekit_controller/climate.py b/homeassistant/components/homekit_controller/climate.py index 4c299d1c7d0ee..f06063c5fd246 100644 --- a/homeassistant/components/homekit_controller/climate.py +++ b/homeassistant/components/homekit_controller/climate.py @@ -1,12 +1,31 @@ """Support for Homekit climate devices.""" import logging -from homeassistant.components.climate import ClimateDevice +from aiohomekit.model.characteristics import ( + CharacteristicsTypes, + HeatingCoolingCurrentValues, + HeatingCoolingTargetValues, +) +from aiohomekit.utils import clamp_enum_to_char + +from homeassistant.components.climate import ( + DEFAULT_MAX_HUMIDITY, + DEFAULT_MIN_HUMIDITY, + ClimateEntity, +) 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 + CURRENT_HVAC_COOL, + CURRENT_HVAC_HEAT, + CURRENT_HVAC_IDLE, + HVAC_MODE_COOL, + HVAC_MODE_HEAT, + HVAC_MODE_HEAT_COOL, + HVAC_MODE_OFF, + SUPPORT_TARGET_HUMIDITY, + SUPPORT_TARGET_TEMPERATURE, +) +from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS +from homeassistant.core import callback from . import KNOWN_DEVICES, HomeKitEntity @@ -14,47 +33,43 @@ # Map of Homekit operation modes to hass modes MODE_HOMEKIT_TO_HASS = { - 0: STATE_OFF, - 1: STATE_HEAT, - 2: STATE_COOL, - 3: STATE_AUTO, + HeatingCoolingTargetValues.OFF: HVAC_MODE_OFF, + HeatingCoolingTargetValues.HEAT: HVAC_MODE_HEAT, + HeatingCoolingTargetValues.COOL: HVAC_MODE_COOL, + HeatingCoolingTargetValues.AUTO: HVAC_MODE_HEAT_COOL, } # 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) +CURRENT_MODE_HOMEKIT_TO_HASS = { + HeatingCoolingCurrentValues.IDLE: CURRENT_HVAC_IDLE, + HeatingCoolingCurrentValues.HEATING: CURRENT_HVAC_HEAT, + HeatingCoolingCurrentValues.COOLING: CURRENT_HVAC_COOL, +} -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_entry(hass, config_entry, async_add_entities): """Set up Homekit climate.""" - if discovery_info is not None: - accessory = hass.data[KNOWN_DEVICES][discovery_info['serial']] - add_entities([HomeKitClimateDevice(accessory, discovery_info)], True) + hkid = config_entry.data["AccessoryPairingID"] + conn = hass.data[KNOWN_DEVICES][hkid] + @callback + def async_add_service(aid, service): + if service["stype"] != "thermostat": + return False + info = {"aid": aid, "iid": service["iid"]} + async_add_entities([HomeKitClimateEntity(conn, info)], True) + return True -class HomeKitClimateDevice(HomeKitEntity, ClimateDevice): - """Representation of a Homekit climate device.""" + conn.add_listener(async_add_service) - 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) + +class HomeKitClimateEntity(HomeKitEntity, ClimateEntity): + """Representation of a Homekit climate device.""" 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, @@ -64,162 +79,118 @@ def get_characteristic_types(self): 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) + await self.async_put_characteristics( + {CharacteristicsTypes.TEMPERATURE_TARGET: temp} + ) 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) + await self.async_put_characteristics( + {CharacteristicsTypes.RELATIVE_HUMIDITY_TARGET: humidity} + ) - async def async_set_operation_mode(self, operation_mode): + async def async_set_hvac_mode(self, hvac_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 + await self.async_put_characteristics( + { + CharacteristicsTypes.HEATING_COOLING_TARGET: MODE_HASS_TO_HOMEKIT[ + hvac_mode + ], + } + ) @property def current_temperature(self): """Return the current temperature.""" - return self._current_temp + return self.service.value(CharacteristicsTypes.TEMPERATURE_CURRENT) @property def target_temperature(self): """Return the temperature we try to reach.""" - return self._target_temp + return self.service.value(CharacteristicsTypes.TEMPERATURE_TARGET) @property def min_temp(self): """Return the minimum target temp.""" - if self._max_target_temp: - return self._min_target_temp + if self.service.has(CharacteristicsTypes.TEMPERATURE_TARGET): + char = self.service[CharacteristicsTypes.TEMPERATURE_TARGET] + return char.minValue return super().min_temp @property def max_temp(self): """Return the maximum target temp.""" - if self._max_target_temp: - return self._max_target_temp + if self.service.has(CharacteristicsTypes.TEMPERATURE_TARGET): + char = self.service[CharacteristicsTypes.TEMPERATURE_TARGET] + return char.maxValue return super().max_temp @property def current_humidity(self): """Return the current humidity.""" - return self._current_humidity + return self.service.value(CharacteristicsTypes.RELATIVE_HUMIDITY_CURRENT) @property def target_humidity(self): """Return the humidity we try to reach.""" - return self._target_humidity + return self.service.value(CharacteristicsTypes.RELATIVE_HUMIDITY_TARGET) @property def min_humidity(self): """Return the minimum humidity.""" - return self._min_target_humidity + char = self.service[CharacteristicsTypes.RELATIVE_HUMIDITY_TARGET] + return char.minValue or DEFAULT_MIN_HUMIDITY @property def max_humidity(self): """Return the maximum humidity.""" - return self._max_target_humidity + char = self.service[CharacteristicsTypes.RELATIVE_HUMIDITY_TARGET] + return char.maxValue or DEFAULT_MAX_HUMIDITY + + @property + def hvac_action(self): + """Return the current running hvac operation.""" + # This characteristic describes the current mode of a device, + # e.g. a thermostat is "heating" a room to 75 degrees Fahrenheit. + # Can be 0 - 2 (Off, Heat, Cool) + value = self.service.value(CharacteristicsTypes.HEATING_COOLING_CURRENT) + return CURRENT_MODE_HOMEKIT_TO_HASS.get(value) @property - def current_operation(self): - """Return current operation ie. heat, cool, idle.""" - return self._current_mode + def hvac_mode(self): + """Return hvac operation ie. heat, cool mode.""" + # This characteristic describes the target mode + # E.g. should the device start heating a room if the temperature + # falls below the target temperature. + # Can be 0 - 3 (Off, Heat, Cool, Auto) + value = self.service.value(CharacteristicsTypes.HEATING_COOLING_TARGET) + return MODE_HOMEKIT_TO_HASS.get(value) @property - def operation_list(self): - """Return the list of available operation modes.""" - return self._valid_modes + def hvac_modes(self): + """Return the list of available hvac operation modes.""" + valid_values = clamp_enum_to_char( + HeatingCoolingTargetValues, + self.service[CharacteristicsTypes.HEATING_COOLING_TARGET], + ) + return [MODE_HOMEKIT_TO_HASS[mode] for mode in valid_values] @property def supported_features(self): """Return the list of supported features.""" - return self._features + features = 0 + + if self.service.has(CharacteristicsTypes.TEMPERATURE_TARGET): + features |= SUPPORT_TARGET_TEMPERATURE + + if self.service.has(CharacteristicsTypes.RELATIVE_HUMIDITY_TARGET): + features |= SUPPORT_TARGET_HUMIDITY + + return features @property def temperature_unit(self): diff --git a/homeassistant/components/homekit_controller/config_flow.py b/homeassistant/components/homekit_controller/config_flow.py index 197d15116b18c..812d10eb8c484 100644 --- a/homeassistant/components/homekit_controller/config_flow.py +++ b/homeassistant/components/homekit_controller/config_flow.py @@ -1,115 +1,135 @@ """Config flow to configure homekit_controller.""" -import os -import json import logging +import re +import aiohomekit import voluptuous as vol from homeassistant import config_entries from homeassistant.core import callback +from .connection import get_accessory_name, get_bridge_information 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" -HOMEKIT_IGNORE = [ - 'BSB002', - 'Home Assistant Bridge', - 'TRADFRI gateway', -] -HOMEKIT_DIR = '.homekit' -PAIRING_FILE = 'pairing.json' +PIN_FORMAT = re.compile(r"^(\d{3})-{0,1}(\d{2})-{0,1}(\d{3})$") _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 +def normalize_hkid(hkid): + """Normalize a hkid so that it is safe to compare with other normalized hkids.""" + return hkid.lower() @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: + if entry.data.get("AccessoryPairingID") == serial: return entry +def ensure_pin_format(pin): + """ + Ensure a pin code is correctly formatted. + + Ensures a pin code is in the format 111-11-111. Handles codes with and without dashes. + + If incorrect code is entered, an exception is raised. + """ + match = PIN_FORMAT.search(pin) + if not match: + raise aiohomekit.exceptions.MalformedPinError(f"Invalid PIN code f{pin}") + return "-".join(match.groups()) + + @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 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH def __init__(self): """Initialize the homekit_controller flow.""" self.model = None self.hkid = None self.devices = {} + self.controller = aiohomekit.Controller() + self.finish_pairing = None async def async_step_user(self, user_input=None): """Handle a flow start.""" - import homekit - errors = {} if user_input is not None: - key = user_input['device'] - props = self.devices[key]['properties'] - self.hkid = props['id'] - self.model = props['md'] + key = user_input["device"] + self.hkid = self.devices[key].device_id + self.model = self.devices[key].info["md"] + await self.async_set_unique_id( + normalize_hkid(self.hkid), raise_on_progress=False + ) return await self.async_step_pair() - controller = homekit.Controller() - all_hosts = await self.hass.async_add_executor_job( - controller.discover, 5 - ) + all_hosts = await self.controller.discover_ip() self.devices = {} for host in all_hosts: - status_flags = int(host['properties']['sf']) + status_flags = int(host.info["sf"]) paired = not status_flags & 0x01 if paired: continue - self.devices[host['properties']['id']] = host + self.devices[host.info["name"]] = host if not self.devices: - return self.async_abort( - reason='no_devices' - ) + return self.async_abort(reason="no_devices") return self.async_show_form( - step_id='user', + step_id="user", errors=errors, - data_schema=vol.Schema({ - vol.Required('device'): vol.In(self.devices.keys()), - }) + data_schema=vol.Schema( + {vol.Required("device"): vol.In(self.devices.keys())} + ), ) - async def async_step_discovery(self, discovery_info): + async def async_step_unignore(self, user_input): + """Rediscover a previously ignored discover.""" + unique_id = user_input["unique_id"] + await self.async_set_unique_id(unique_id) + + devices = await self.controller.discover_ip(5) + for device in devices: + if normalize_hkid(device.device_id) != unique_id: + continue + record = device.info + return await self.async_step_zeroconf( + { + "host": record["address"], + "port": record["port"], + "hostname": record["name"], + "type": "_hap._tcp.local.", + "name": record["name"], + "properties": { + "md": record["md"], + "pv": record["pv"], + "id": unique_id, + "c#": record["c#"], + "s#": record["s#"], + "ff": record["ff"], + "ci": record["ci"], + "sf": record["sf"], + "sh": "", + }, + } + ) + + return self.async_abort(reason="no_devices") + + async def async_step_zeroconf(self, discovery_info): """Handle a discovered HomeKit accessory. This flow is triggered by the discovery component. @@ -118,158 +138,177 @@ async def async_step_discovery(self, discovery_info): # 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() + 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'] - - status_flags = int(properties['sf']) + hkid = properties["id"] + model = properties["md"] + name = discovery_info["name"].replace("._hap._tcp.local.", "") + status_flags = int(properties["sf"]) paired = not status_flags & 0x01 - # pylint: disable=unsupported-assignment-operation - self.context['title_placeholders'] = { - 'name': discovery_info['name'], - } - # 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#']) + 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 + "HomeKit device %s: c# not exposed, in violation of spec", hkid ) + config_num = None - if hkid in old_pairings: - return await self.async_import_legacy_pairing( - properties, - old_pairings[hkid] + # If the device is already paired and known to us we should monitor c# + # (config_num) for changes. If it changes, we check for new entities + if paired and hkid in self.hass.data.get(KNOWN_DEVICES, {}): + 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") - # 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') + _LOGGER.debug("Discovered device %s (%s - %s)", name, model, hkid) # 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: + if not paired and existing: await self.hass.config_entries.async_remove(existing.entry_id) - self.model = model - self.hkid = hkid - return await self.async_step_pair() + # Set unique-id and error out if it's already configured + await self.async_set_unique_id(normalize_hkid(hkid)) + self._abort_if_unique_id_configured() - 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 + # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 + self.context["hkid"] = hkid + self.context["title_placeholders"] = {"name": name} - 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') + if paired: + # 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") - _LOGGER.info( - ("Legacy configuration %s for homekit" - "accessory migrated to config entries"), hkid) + # 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") - pairing = IpPairing(pairing_data) + self.model = model + self.hkid = hkid - return await self._entry_from_accessory(pairing) + # 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_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'] - controller = homekit.Controller() + code = pair_info["pairing_code"] try: - await self.hass.async_add_executor_job( - controller.perform_pairing, self.hkid, self.hkid, code - ) - - pairing = controller.pairings.get(self.hkid) - if pairing: - return await self._entry_from_accessory( - pairing) - - errors['pairing_code'] = 'unable_to_pair' - except homekit.AuthenticationError: - errors['pairing_code'] = 'authentication_error' - except homekit.UnknownError: - errors['pairing_code'] = 'unknown_error' - except homekit.MaxTriesError: - errors['pairing_code'] = 'max_tries_error' - except homekit.BusyError: - errors['pairing_code'] = 'busy_error' - except homekit.MaxPeersError: - errors['pairing_code'] = 'max_peers_error' - except homekit.AccessoryNotFoundError: - return self.async_abort(reason='accessory_not_found_error') - except homekit.UnavailableError: - return self.async_abort(reason='already_paired') + code = ensure_pin_format(code) + pairing = await self.finish_pairing(code) + return await self._entry_from_accessory(pairing) + except aiohomekit.exceptions.MalformedPinError: + # Library claimed pin was invalid before even making an API call + errors["pairing_code"] = "authentication_error" + except aiohomekit.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 aiohomekit.UnknownError: + # An error occurred on the device whilst performing this + # operation. + errors["pairing_code"] = "unknown_error" + except aiohomekit.MaxPeersError: + # The device can't pair with any more accessories. + errors["pairing_code"] = "max_peers_error" + except aiohomekit.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' + _LOGGER.exception("Pairing attempt failed with an unhandled exception") + errors["pairing_code"] = "pairing_failed" + + discovery = await self.controller.find_ip_by_device_id(self.hkid) + try: + self.finish_pairing = await discovery.start_pairing(self.hkid) + + except aiohomekit.BusyError: + # Already performing a pair setup operation with a different + # controller + errors["pairing_code"] = "busy_error" + except aiohomekit.MaxTriesError: + # The accessory has received more than 100 unsuccessful auth + # attempts. + errors["pairing_code"] = "max_tries_error" + except aiohomekit.UnavailableError: + # The accessory is already paired - cannot try to pair again. + return self.async_abort(reason="already_paired") + except aiohomekit.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) + + @callback + def _async_step_pair_show_form(self, errors=None): return self.async_show_form( - step_id='pair', - errors=errors, - data_schema=vol.Schema({ - vol.Required('pairing_code'): vol.All(str, vol.Strip), - }) + 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.""" - accessories = await self.hass.async_add_executor_job( - pairing.list_accessories_and_characteristics - ) + # 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 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.pairing_data, - ) + 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 index af438c6816416..605253e6235d0 100644 --- a/homeassistant/components/homekit_controller/connection.py +++ b/homeassistant/components/homekit_controller/connection.py @@ -1,16 +1,23 @@ """Helpers for managing a pairing with a HomeKit accessory or bridge.""" import asyncio +import datetime import logging -import os -from homeassistant.helpers import discovery - -from .const import ( - CONTROLLER, DOMAIN, HOMEKIT_ACCESSORY_DISPATCH, KNOWN_DEVICES, - PAIRING_FILE, HOMEKIT_DIR, ENTITY_MAP +from aiohomekit.exceptions import ( + AccessoryDisconnectedError, + AccessoryNotFoundError, + EncryptionError, ) +from aiohomekit.model import Accessories +from aiohomekit.model.characteristics import CharacteristicsTypes +from aiohomekit.model.services import ServicesTypes + +from homeassistant.core import callback +from homeassistant.helpers.event import async_track_time_interval +from .const import CONTROLLER, DOMAIN, ENTITY_MAP, HOMEKIT_ACCESSORY_DISPATCH +DEFAULT_SCAN_INTERVAL = datetime.timedelta(seconds=60) RETRY_INTERVAL = 60 # seconds _LOGGER = logging.getLogger(__name__) @@ -18,241 +25,341 @@ 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': + 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'] + 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: + 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'): + for field in ("name", "model", "manufacturer"): if field in accessory_info: return accessory_info[field] return None -class HKDevice(): +class HKDevice: """HomeKit device.""" - def __init__(self, hass, host, port, model, hkid, config_num, config): + def __init__(self, hass, config_entry, pairing_data): """Initialise a generic HomeKit device.""" - _LOGGER.info("Setting up Homekit device %s", model) + self.hass = hass - self.controller = hass.data[CONTROLLER] + self.config_entry = config_entry - self.host = host - self.port = port - self.model = model - self.hkid = hkid - self.config_num = config_num - self.config = config - self.configurator = hass.components.configurator - self.accessories = {} + # 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 = hass.data[CONTROLLER].load_pairing( + self.pairing_data["AccessoryPairingID"], self.pairing_data + ) + + self.accessories = None + self.config_num = 0 + + self.entity_map = Accessories() + + # 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 don't 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 = [] - self.pairing_lock = asyncio.Lock(loop=hass.loop) + # There are multiple entities sharing a single connection - only + # allow one entity to use pairing at once. + self.pairing_lock = asyncio.Lock() - self.pairing = self.controller.pairings.get(hkid) + self.available = True - hass.data[KNOWN_DEVICES][hkid] = self + self.signal_state_updated = "_".join((DOMAIN, self.unique_id, "state_updated")) - def setup(self): - """Prepare to use a paired HomeKit device in homeassistant.""" - if self.pairing is None: - self.configure() - return + # Current values of all characteristics homekit_controller is tracking. + # Key is a (accessory_id, characteristic_id) tuple. + self.current_state = {} + + self.pollable_characteristics = [] + + # If this is set polling is active and can be disabled by calling + # this method. + self._polling_interval_remover = None + + # Never allow concurrent polling of the same accessory or bridge + self._polling_lock = asyncio.Lock() + self._polling_lock_warned = False + + self.watchable_characteristics = [] + + self.pairing.dispatcher_connect(self.process_new_events) + + def add_pollable_characteristics(self, characteristics): + """Add (aid, iid) pairs that we need to poll.""" + self.pollable_characteristics.extend(characteristics) + + def remove_pollable_characteristics(self, accessory_id): + """Remove all pollable characteristics by accessory id.""" + self.pollable_characteristics = [ + char for char in self.pollable_characteristics if char[0] != accessory_id + ] + + def add_watchable_characteristics(self, characteristics): + """Add (aid, iid) pairs that we need to poll.""" + self.watchable_characteristics.extend(characteristics) + self.hass.async_create_task(self.pairing.subscribe(characteristics)) - self.pairing.pairing_data['AccessoryIP'] = self.host - self.pairing.pairing_data['AccessoryPort'] = self.port + def remove_watchable_characteristics(self, accessory_id): + """Remove all pollable characteristics by accessory id.""" + self.watchable_characteristics = [ + char for char in self.watchable_characteristics if char[0] != accessory_id + ] + @callback + def async_set_unavailable(self): + """Mark state of all entities on this connection as unavailable.""" + self.available = False + self.hass.helpers.dispatcher.async_dispatcher_send(self.signal_state_updated) + + async def async_setup(self): + """Prepare to use a paired HomeKit device in Home Assistant.""" cache = self.hass.data[ENTITY_MAP].get_map(self.unique_id) - if not cache or cache['config_num'] < self.config_num: - return self.refresh_entity_map(self.config_num) + if not cache: + if await self.async_refresh_entity_map(self.config_num): + self._polling_interval_remover = async_track_time_interval( + self.hass, self.async_update, DEFAULT_SCAN_INTERVAL + ) + return True + return False - self.accessories = cache['accessories'] + 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.entity_map = Accessories.from_list(self.accessories) - self.add_entities() + self._polling_interval_remover = async_track_time_interval( + self.hass, self.async_update, DEFAULT_SCAN_INTERVAL + ) + + self.hass.async_create_task(self.async_process_entity_map()) return True - def refresh_entity_map(self, config_num): + async def async_process_entity_map(self): """ - Handle setup of a HomeKit accessory. + Process the entity map and load any platforms or entities that need adding. - The sync version will be removed when homekit_controller migrates to - config flow. + This is idempotent and will be called at startup and when we detect metadata changes + via the c# counter on the zeroconf record. """ - self.hass.add_job( - self.async_refresh_entity_map, - config_num, - ) + # Ensure the Pairing object has access to the latest version of the entity map. This + # is especially important for BLE, as 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 + + await self.async_load_platforms() + + self.add_entities() + + if self.watchable_characteristics: + await self.pairing.subscribe(self.watchable_characteristics) + + await self.async_update() + + return True + + async def async_unload(self): + """Stop interacting with device and prepare for removal from hass.""" + if self._polling_interval_remover: + self._polling_interval_remover() + + await self.pairing.unsubscribe(self.watchable_characteristics) + + unloads = [] + for platform in self.platforms: + unloads.append( + self.hass.config_entries.async_forward_entry_unload( + self.config_entry, platform + ) + ) + + results = await asyncio.gather(*unloads) + + return False not in results 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: - self.accessories = await self.hass.async_add_executor_job( - self.pairing.list_accessories_and_characteristics, - ) + self.accessories = await 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 + return False + + self.entity_map = Accessories.from_list(self.accessories) self.hass.data[ENTITY_MAP].async_create_or_update_map( - self.unique_id, - config_num, - self.accessories, + 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 - - # Register add new entities that are available - await self.hass.async_add_executor_job(self.add_entities) + self.hass.async_create_task(self.async_process_entity_map()) 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.""" - # pylint: disable=import-error - from homekit.model.services import ServicesTypes + self._add_new_entities(self.listeners) + def _add_new_entities(self, callbacks): for accessory in self.accessories: - aid = accessory['aid'] - for service in accessory['services']: - iid = service['iid'] + 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 - devtype = ServicesTypes.get_short(service['type']) - _LOGGER.debug("Found %s", devtype) - service_info = {'serial': self.hkid, - 'aid': aid, - 'iid': service['iid'], - 'model': self.model, - 'device-type': devtype} - component = HOMEKIT_ACCESSORY_DISPATCH.get(devtype, None) - if component is not None: - discovery.load_platform(self.hass, component, DOMAIN, - service_info, self.config) - self.entities.append((aid, iid)) - - def device_config_callback(self, callback_data): - """Handle initial pairing.""" - import homekit # pylint: disable=import-error - code = callback_data.get('code').strip() - try: - self.controller.perform_pairing(self.hkid, self.hkid, code) - except homekit.UnavailableError: - error_msg = "This accessory is already paired to another device. \ - Please reset the accessory and try again." - _configurator = self.hass.data[DOMAIN+self.hkid] - self.configurator.notify_errors(_configurator, error_msg) + for listener in callbacks: + if listener(aid, service): + self.entities.append((aid, iid)) + break + + async def async_load_platforms(self): + """Load any platforms needed by this HomeKit device.""" + 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.platforms.add(platform) + try: + await self.hass.config_entries.async_forward_entry_setup( + self.config_entry, platform + ) + except Exception: + self.platforms.remove(platform) + raise + + async def async_update(self, now=None): + """Poll state of all entities attached to this bridge/accessory.""" + if not self.pollable_characteristics: + _LOGGER.debug("HomeKit connection not polling any characteristics.") return - except homekit.AuthenticationError: - error_msg = "Incorrect HomeKit code for {}. Please check it and \ - try again.".format(self.model) - _configurator = self.hass.data[DOMAIN+self.hkid] - self.configurator.notify_errors(_configurator, error_msg) + + if self._polling_lock.locked(): + if not self._polling_lock_warned: + _LOGGER.warning( + "HomeKit controller update skipped as previous poll still in flight" + ) + self._polling_lock_warned = True return - except homekit.UnknownError: - error_msg = "Received an unknown error. Please file a bug." - _configurator = self.hass.data[DOMAIN+self.hkid] - self.configurator.notify_errors(_configurator, error_msg) - raise - - self.pairing = self.controller.pairings.get(self.hkid) - if self.pairing is not None: - pairing_dir = os.path.join( - self.hass.config.path(), - HOMEKIT_DIR, - ) - if not os.path.exists(pairing_dir): - os.makedirs(pairing_dir) - pairing_file = os.path.join( - pairing_dir, - PAIRING_FILE, + + if self._polling_lock_warned: + _LOGGER.info( + "HomeKit controller no longer detecting back pressure - not skipping poll" ) - self.controller.save_data(pairing_file) - _configurator = self.hass.data[DOMAIN+self.hkid] - self.configurator.request_done(_configurator) - self.setup() - else: - error_msg = "Unable to pair, please try again" - _configurator = self.hass.data[DOMAIN+self.hkid] - self.configurator.notify_errors(_configurator, error_msg) - - def configure(self): - """Obtain the pairing code for a HomeKit device.""" - description = "Please enter the HomeKit code for your {}".format( - self.model) - self.hass.data[DOMAIN+self.hkid] = \ - self.configurator.request_config(self.model, - self.device_config_callback, - description=description, - submit_caption="submit", - fields=[{'id': 'code', - 'name': 'HomeKit code', - 'type': 'string'}]) + self._polling_lock_warned = False + + async with self._polling_lock: + _LOGGER.debug("Starting HomeKit controller update") + + try: + new_values_dict = await self.get_characteristics( + self.pollable_characteristics + ) + except AccessoryNotFoundError: + # Not only did the connection fail, but also the accessory is not + # visible on the network. + self.async_set_unavailable() + return + except (AccessoryDisconnectedError, EncryptionError): + # Temporary connection failure. Device is still available but our + # connection was dropped. + return + + self.process_new_events(new_values_dict) + + _LOGGER.debug("Finished HomeKit controller update") + + def process_new_events(self, new_values_dict): + """Process events from accessory into HA state.""" + self.available = True + + for (aid, cid), value in new_values_dict.items(): + accessory = self.current_state.setdefault(aid, {}) + accessory[cid] = value + + # self.current_state will be replaced by entity_map in a future PR + # For now we update both + self.entity_map.process_changes(new_values_dict) + + self.hass.helpers.dispatcher.async_dispatcher_send(self.signal_state_updated) 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 + return await self.pairing.get_characteristics(*args, **kwargs) 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 - ) + results = await self.pairing.put_characteristics(characteristics) + + # Feed characteristics back into HA and update the current state + # results will only contain failures, so anythin in characteristics + # but not in results was applied successfully - we can just have HA + # reflect the change immediately. + + new_entity_state = {} + for aid, iid, value in characteristics: + key = (aid, iid) + + # If the key was returned by put_characteristics() then the + # change didn't work + if key in results: + continue + + # Otherwise it was accepted and we can apply the change to + # our state + new_entity_state[key] = {"value": value} + + self.process_new_events(new_entity_state) @property def unique_id(self): @@ -261,4 +368,14 @@ def unique_id(self): This id is random and will change if a device undergoes a hard reset. """ - return self.hkid + 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 index f112737ca2414..7b40863141cc8 100644 --- a/homeassistant/components/homekit_controller/const.py +++ b/homeassistant/components/homekit_controller/const.py @@ -1,26 +1,37 @@ """Constants for the homekit_controller component.""" -DOMAIN = 'homekit_controller' +DOMAIN = "homekit_controller" -KNOWN_DEVICES = "{}-devices".format(DOMAIN) -CONTROLLER = "{}-controller".format(DOMAIN) -ENTITY_MAP = '{}-entity-map'.format(DOMAIN) +KNOWN_DEVICES = f"{DOMAIN}-devices" +CONTROLLER = f"{DOMAIN}-controller" +ENTITY_MAP = f"{DOMAIN}-entity-map" -HOMEKIT_DIR = '.homekit' -PAIRING_FILE = 'pairing.json' +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' + "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", + "contact": "binary_sensor", + "motion": "binary_sensor", + "carbon-dioxide": "sensor", + "humidity": "sensor", + "light": "sensor", + "temperature": "sensor", + "battery": "sensor", + "smoke": "binary_sensor", + "leak": "binary_sensor", + "fan": "fan", + "fanv2": "fan", + "air-quality": "air_quality", + "occupancy": "binary_sensor", + "television": "media_player", + "valve": "switch", } diff --git a/homeassistant/components/homekit_controller/cover.py b/homeassistant/components/homekit_controller/cover.py index bd466d074d0cd..086f780b8162b 100644 --- a/homeassistant/components/homekit_controller/cover.py +++ b/homeassistant/components/homekit_controller/cover.py @@ -1,16 +1,26 @@ """Support for Homekit covers.""" import logging +from aiohomekit.model.characteristics import CharacteristicsTypes + 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) + ATTR_POSITION, + ATTR_TILT_POSITION, + SUPPORT_CLOSE, + SUPPORT_CLOSE_TILT, + SUPPORT_OPEN, + SUPPORT_OPEN_TILT, + SUPPORT_SET_POSITION, + SUPPORT_SET_TILT_POSITION, + SUPPORT_STOP, + CoverEntity, +) +from homeassistant.const import STATE_CLOSED, STATE_CLOSING, STATE_OPEN, STATE_OPENING +from homeassistant.core import callback from . import KNOWN_DEVICES, HomeKitEntity -STATE_STOPPED = 'stopped' +STATE_STOPPED = "stopped" _LOGGER = logging.getLogger(__name__) @@ -19,86 +29,76 @@ 1: STATE_CLOSED, 2: STATE_OPENING, 3: STATE_CLOSING, - 4: STATE_STOPPED + 4: STATE_STOPPED, } -TARGET_GARAGE_STATE_MAP = { - STATE_OPEN: 0, - STATE_CLOSED: 1, - STATE_STOPPED: 2 -} +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 -} +CURRENT_WINDOW_STATE_MAP = {0: STATE_CLOSING, 1: STATE_OPENING, 2: STATE_STOPPED} -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up HomeKit Cover support.""" - if discovery_info is None: - return - accessory = hass.data[KNOWN_DEVICES][discovery_info['serial']] +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] - if discovery_info['device-type'] == 'garage-door-opener': - add_entities([HomeKitGarageDoorCover(accessory, discovery_info)], - True) - else: - add_entities([HomeKitWindowCover(accessory, discovery_info)], - True) + @callback + 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 -class HomeKitGarageDoorCover(HomeKitEntity, CoverDevice): - """Representation of a HomeKit Garage Door.""" + return False + + conn.add_listener(async_add_service) - 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 + +class HomeKitGarageDoorCover(HomeKitEntity, CoverEntity): + """Representation of a HomeKit Garage Door.""" @property def device_class(self): """Define this cover as a garage door.""" - return 'garage' + 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 state(self): + """Return the current state of the garage door.""" + value = self.service.value(CharacteristicsTypes.DOOR_STATE_CURRENT) + return CURRENT_GARAGE_STATE_MAP[value] + @property def is_closed(self): """Return true if cover is closed, else False.""" - return self._state == STATE_CLOSED + return self.state == STATE_CLOSED @property def is_closing(self): """Return if the cover is closing or not.""" - return self._state == STATE_CLOSING + return self.state == STATE_CLOSING @property def is_opening(self): """Return if the cover is opening or not.""" - return self._state == STATE_OPENING + return self.state == STATE_OPENING async def async_open_cover(self, **kwargs): """Send open command.""" @@ -110,40 +110,29 @@ async def async_close_cover(self, **kwargs): 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) + await self.async_put_characteristics( + {CharacteristicsTypes.DOOR_STATE_TARGET: TARGET_GARAGE_STATE_MAP[state]} + ) @property def device_state_attributes(self): """Return the optional state attributes.""" - if self._obstruction_detected is None: - return None + attributes = {} - return { - 'obstruction-detected': self._obstruction_detected, - } + obstruction_detected = self.service.value( + CharacteristicsTypes.OBSTRUCTION_DETECTED + ) + if obstruction_detected: + attributes["obstruction-detected"] = obstruction_detected + return attributes -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) +class HomeKitWindowCover(HomeKitEntity, CoverEntity): + """Representation of a HomeKit Window or Window Covering.""" 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, @@ -156,65 +145,79 @@ def get_characteristic_types(self): 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 + @property + def supported_features(self): + """Flag supported features.""" + features = SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_SET_POSITION - def _update_vertical_tilt_current(self, value): - self._tilt_position = value + if self.service.has(CharacteristicsTypes.POSITION_HOLD): + features |= SUPPORT_STOP - def _update_horizontal_tilt_current(self, value): - self._tilt_position = value + supports_tilt = any( + ( + self.service.has(CharacteristicsTypes.VERTICAL_TILT_CURRENT), + self.service.has(CharacteristicsTypes.HORIZONTAL_TILT_CURRENT), + ) + ) - def _update_obstruction_detected(self, value): - self._obstruction_detected = value + if supports_tilt: + features |= ( + SUPPORT_OPEN_TILT | SUPPORT_CLOSE_TILT | SUPPORT_SET_TILT_POSITION + ) - @property - def supported_features(self): - """Flag supported features.""" - return self._features + return features @property def current_cover_position(self): """Return the current position of cover.""" - return self._position + return self.service.value(CharacteristicsTypes.POSITION_CURRENT) @property def is_closed(self): """Return true if cover is closed, else False.""" - return self._position == 0 + return self.current_cover_position == 0 @property def is_closing(self): """Return if the cover is closing or not.""" - return self._state == STATE_CLOSING + value = self.service.value(CharacteristicsTypes.POSITION_STATE) + state = CURRENT_WINDOW_STATE_MAP[value] + return state == STATE_CLOSING @property def is_opening(self): """Return if the cover is opening or not.""" - return self._state == STATE_OPENING + value = self.service.value(CharacteristicsTypes.POSITION_STATE) + state = CURRENT_WINDOW_STATE_MAP[value] + return state == STATE_OPENING + + @property + def is_horizontal_tilt(self): + """Return True if the service has a horizontal tilt characteristic.""" + return ( + self.service.value(CharacteristicsTypes.HORIZONTAL_TILT_CURRENT) is not None + ) + + @property + def is_vertical_tilt(self): + """Return True if the service has a vertical tilt characteristic.""" + return ( + self.service.value(CharacteristicsTypes.VERTICAL_TILT_CURRENT) is not None + ) + + @property + def current_cover_tilt_position(self): + """Return current position of cover tilt.""" + tilt_position = self.service.value(CharacteristicsTypes.VERTICAL_TILT_CURRENT) + if not tilt_position: + tilt_position = self.service.value( + CharacteristicsTypes.HORIZONTAL_TILT_CURRENT + ) + return tilt_position 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) + await self.async_put_characteristics({CharacteristicsTypes.POSITION_HOLD: 1}) async def async_open_cover(self, **kwargs): """Send open command.""" @@ -227,37 +230,31 @@ async def async_close_cover(self, **kwargs): 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 + await self.async_put_characteristics( + {CharacteristicsTypes.POSITION_TARGET: 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) + if self.is_vertical_tilt: + await self.async_put_characteristics( + {CharacteristicsTypes.VERTICAL_TILT_TARGET: tilt_position} + ) + elif self.is_horizontal_tilt: + await self.async_put_characteristics( + {CharacteristicsTypes.HORIZONTAL_TILT_TARGET: tilt_position} + ) @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 + attributes = {} + + obstruction_detected = self.service.value( + CharacteristicsTypes.OBSTRUCTION_DETECTED + ) + if obstruction_detected: + attributes["obstruction-detected"] = obstruction_detected - return state_attributes + return attributes diff --git a/homeassistant/components/homekit_controller/fan.py b/homeassistant/components/homekit_controller/fan.py new file mode 100644 index 0000000000000..e3b392ea1075c --- /dev/null +++ b/homeassistant/components/homekit_controller/fan.py @@ -0,0 +1,187 @@ +"""Support for Homekit fans.""" +import logging + +from aiohomekit.model.characteristics import CharacteristicsTypes + +from homeassistant.components.fan import ( + DIRECTION_FORWARD, + DIRECTION_REVERSE, + SPEED_HIGH, + SPEED_LOW, + SPEED_MEDIUM, + SPEED_OFF, + SUPPORT_DIRECTION, + SUPPORT_OSCILLATE, + SUPPORT_SET_SPEED, + FanEntity, +) +from homeassistant.core import callback + +from . import KNOWN_DEVICES, HomeKitEntity + +_LOGGER = logging.getLogger(__name__) + +# 0 is clockwise, 1 is counter-clockwise. The match to forward and reverse is so that +# its consistent with homeassistant.components.homekit. +DIRECTION_TO_HK = { + DIRECTION_REVERSE: 1, + DIRECTION_FORWARD: 0, +} +HK_DIRECTION_TO_HA = {v: k for (k, v) in DIRECTION_TO_HK.items()} + +SPEED_TO_PCNT = { + SPEED_HIGH: 100, + SPEED_MEDIUM: 50, + SPEED_LOW: 25, + SPEED_OFF: 0, +} + + +class BaseHomeKitFan(HomeKitEntity, FanEntity): + """Representation of a Homekit fan.""" + + # This must be set in subclasses to the name of a boolean characteristic + # that controls whether the fan is on or off. + on_characteristic = None + + def get_characteristic_types(self): + """Define the homekit characteristics the entity cares about.""" + return [ + CharacteristicsTypes.SWING_MODE, + CharacteristicsTypes.ROTATION_DIRECTION, + CharacteristicsTypes.ROTATION_SPEED, + self.on_characteristic, + ] + + @property + def is_on(self): + """Return true if device is on.""" + return self.service.value(self.on_characteristic) == 1 + + @property + def speed(self): + """Return the current speed.""" + if not self.is_on: + return SPEED_OFF + + rotation_speed = self.service.value(CharacteristicsTypes.ROTATION_SPEED) + + if rotation_speed > SPEED_TO_PCNT[SPEED_MEDIUM]: + return SPEED_HIGH + + if rotation_speed > SPEED_TO_PCNT[SPEED_LOW]: + return SPEED_MEDIUM + + if rotation_speed > SPEED_TO_PCNT[SPEED_OFF]: + return SPEED_LOW + + return SPEED_OFF + + @property + def speed_list(self): + """Get the list of available speeds.""" + if self.supported_features & SUPPORT_SET_SPEED: + return [SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH] + return [] + + @property + def current_direction(self): + """Return the current direction of the fan.""" + direction = self.service.value(CharacteristicsTypes.ROTATION_DIRECTION) + return HK_DIRECTION_TO_HA[direction] + + @property + def oscillating(self): + """Return whether or not the fan is currently oscillating.""" + oscillating = self.service.value(CharacteristicsTypes.SWING_MODE) + return oscillating == 1 + + @property + def supported_features(self): + """Flag supported features.""" + features = 0 + + if self.service.has(CharacteristicsTypes.ROTATION_DIRECTION): + features |= SUPPORT_DIRECTION + + if self.service.has(CharacteristicsTypes.ROTATION_SPEED): + features |= SUPPORT_SET_SPEED + + if self.service.has(CharacteristicsTypes.SWING_MODE): + features |= SUPPORT_OSCILLATE + + return features + + async def async_set_direction(self, direction): + """Set the direction of the fan.""" + await self.async_put_characteristics( + {CharacteristicsTypes.ROTATION_DIRECTION: DIRECTION_TO_HK[direction]} + ) + + async def async_set_speed(self, speed): + """Set the speed of the fan.""" + if speed == SPEED_OFF: + return await self.async_turn_off() + + await self.async_put_characteristics( + {CharacteristicsTypes.ROTATION_SPEED: SPEED_TO_PCNT[speed]} + ) + + async def async_oscillate(self, oscillating: bool): + """Oscillate the fan.""" + await self.async_put_characteristics( + {CharacteristicsTypes.SWING_MODE: 1 if oscillating else 0} + ) + + async def async_turn_on(self, speed=None, **kwargs): + """Turn the specified fan on.""" + + characteristics = {} + + if not self.is_on: + characteristics[self.on_characteristic] = True + + if self.supported_features & SUPPORT_SET_SPEED and speed: + characteristics[CharacteristicsTypes.ROTATION_SPEED] = SPEED_TO_PCNT[speed] + + if characteristics: + await self.async_put_characteristics(characteristics) + + async def async_turn_off(self, **kwargs): + """Turn the specified fan off.""" + await self.async_put_characteristics({self.on_characteristic: False}) + + +class HomeKitFanV1(BaseHomeKitFan): + """Implement fan support for public.hap.service.fan.""" + + on_characteristic = CharacteristicsTypes.ON + + +class HomeKitFanV2(BaseHomeKitFan): + """Implement fan support for public.hap.service.fanv2.""" + + on_characteristic = CharacteristicsTypes.ACTIVE + + +ENTITY_TYPES = { + "fan": HomeKitFanV1, + "fanv2": HomeKitFanV2, +} + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Homekit fans.""" + hkid = config_entry.data["AccessoryPairingID"] + conn = hass.data[KNOWN_DEVICES][hkid] + + @callback + def async_add_service(aid, service): + entity_class = ENTITY_TYPES.get(service["stype"]) + if not entity_class: + return False + info = {"aid": aid, "iid": service["iid"]} + async_add_entities([entity_class(conn, info)], True) + return True + + conn.add_listener(async_add_service) diff --git a/homeassistant/components/homekit_controller/light.py b/homeassistant/components/homekit_controller/light.py index a139b1f29328f..b024efe61212a 100644 --- a/homeassistant/components/homekit_controller/light.py +++ b/homeassistant/components/homekit_controller/light.py @@ -1,38 +1,45 @@ """Support for Homekit lights.""" import logging +from aiohomekit.model.characteristics import CharacteristicsTypes + from homeassistant.components.light import ( - ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_HS_COLOR, SUPPORT_BRIGHTNESS, - SUPPORT_COLOR, SUPPORT_COLOR_TEMP, Light) + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP, + ATTR_HS_COLOR, + SUPPORT_BRIGHTNESS, + SUPPORT_COLOR, + SUPPORT_COLOR_TEMP, + LightEntity, +) +from homeassistant.core import callback from . import KNOWN_DEVICES, HomeKitEntity _LOGGER = logging.getLogger(__name__) -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up Homekit lighting.""" - if discovery_info is not None: - accessory = hass.data[KNOWN_DEVICES][discovery_info['serial']] - add_entities([HomeKitLight(accessory, discovery_info)], True) +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] + @callback + 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 +class HomeKitLight(HomeKitEntity, LightEntity): + """Representation of a Homekit light.""" 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, @@ -41,57 +48,47 @@ def get_characteristic_types(self): 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 + return self.service.value(CharacteristicsTypes.ON) @property def brightness(self): """Return the brightness of this light between 0..255.""" - return self._brightness * 255 / 100 + return self.service.value(CharacteristicsTypes.BRIGHTNESS) * 255 / 100 @property def hs_color(self): """Return the color property.""" - return (self._hue, self._saturation) + return ( + self.service.value(CharacteristicsTypes.HUE), + self.service.value(CharacteristicsTypes.SATURATION), + ) @property def color_temp(self): """Return the color temperature.""" - return self._color_temperature + return self.service.value(CharacteristicsTypes.COLOR_TEMPERATURE) @property def supported_features(self): """Flag supported features.""" - return self._features + features = 0 + + if self.service.has(CharacteristicsTypes.BRIGHTNESS): + features |= SUPPORT_BRIGHTNESS + + if self.service.has(CharacteristicsTypes.COLOR_TEMPERATURE): + features |= SUPPORT_COLOR_TEMP + + if self.service.has(CharacteristicsTypes.HUE): + features |= SUPPORT_COLOR + + if self.service.has(CharacteristicsTypes.SATURATION): + features |= SUPPORT_COLOR + + return features async def async_turn_on(self, **kwargs): """Turn the specified light on.""" @@ -99,31 +96,28 @@ async def async_turn_on(self, **kwargs): temperature = kwargs.get(ATTR_COLOR_TEMP) brightness = kwargs.get(ATTR_BRIGHTNESS) - characteristics = [] + 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]}) + characteristics.update( + { + CharacteristicsTypes.HUE: hs_color[0], + CharacteristicsTypes.SATURATION: hs_color[1], + } + ) + if brightness is not None: - characteristics.append({'aid': self._aid, - 'iid': self._chars['brightness'], - 'value': int(brightness * 100 / 255)}) + characteristics[CharacteristicsTypes.BRIGHTNESS] = 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) + characteristics[CharacteristicsTypes.COLOR_TEMPERATURE] = int(temperature) + + characteristics[CharacteristicsTypes.ON] = True + + await self.async_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) + await self.async_put_characteristics({CharacteristicsTypes.ON: False}) diff --git a/homeassistant/components/homekit_controller/lock.py b/homeassistant/components/homekit_controller/lock.py index 67de2bfaf3f6b..93bb4f1568fca 100644 --- a/homeassistant/components/homekit_controller/lock.py +++ b/homeassistant/components/homekit_controller/lock.py @@ -1,66 +1,55 @@ """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 aiohomekit.model.characteristics import CharacteristicsTypes + +from homeassistant.components.lock import LockEntity +from homeassistant.const import ATTR_BATTERY_LEVEL, STATE_LOCKED, STATE_UNLOCKED +from homeassistant.core import callback from . import KNOWN_DEVICES, HomeKitEntity _LOGGER = logging.getLogger(__name__) -STATE_JAMMED = 'jammed' +STATE_JAMMED = "jammed" -CURRENT_STATE_MAP = { - 0: STATE_UNLOCKED, - 1: STATE_LOCKED, - 2: STATE_JAMMED, - 3: None, -} +CURRENT_STATE_MAP = {0: STATE_UNLOCKED, 1: STATE_LOCKED, 2: STATE_JAMMED, 3: None} -TARGET_STATE_MAP = { - STATE_UNLOCKED: 0, - STATE_LOCKED: 1, -} +TARGET_STATE_MAP = {STATE_UNLOCKED: 0, STATE_LOCKED: 1} -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up Homekit Lock support.""" - if discovery_info is None: - return - accessory = hass.data[KNOWN_DEVICES][discovery_info['serial']] - add_entities([HomeKitLock(accessory, discovery_info)], True) +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] + @callback + 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 -class HomeKitLock(HomeKitEntity, LockDevice): - """Representation of a HomeKit Controller Lock.""" + conn.add_listener(async_add_service) - def __init__(self, accessory, discovery_info): - """Initialise the Lock.""" - super().__init__(accessory, discovery_info) - self._state = None - self._battery_level = None + +class HomeKitLock(HomeKitEntity, LockEntity): + """Representation of a HomeKit Controller Lock.""" 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 + value = self.service.value(CharacteristicsTypes.LOCK_MECHANISM_CURRENT_STATE) + return CURRENT_STATE_MAP[value] == STATE_LOCKED async def async_lock(self, **kwargs): """Lock the device.""" @@ -72,17 +61,17 @@ async def async_unlock(self, **kwargs): 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) + await self.async_put_characteristics( + {CharacteristicsTypes.LOCK_MECHANISM_TARGET_STATE: TARGET_STATE_MAP[state]} + ) @property def device_state_attributes(self): """Return the optional state attributes.""" - if self._battery_level is None: - return None + attributes = {} + + battery_level = self.service.value(CharacteristicsTypes.BATTERY_LEVEL) + if battery_level: + attributes[ATTR_BATTERY_LEVEL] = battery_level - return { - ATTR_BATTERY_LEVEL: self._battery_level, - } + return attributes diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index c1b923a56773a..07736f61c8e13 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -1,12 +1,9 @@ { "domain": "homekit_controller", - "name": "Homekit controller", - "documentation": "https://www.home-assistant.io/components/homekit_controller", - "requirements": [ - "homekit[IP]==0.14.0" - ], - "dependencies": ["configurator"], - "codeowners": [ - "@Jc2k" - ] + "name": "HomeKit Controller", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/homekit_controller", + "requirements": ["aiohomekit[IP]==0.2.37"], + "zeroconf": ["_hap._tcp.local."], + "codeowners": ["@Jc2k"] } diff --git a/homeassistant/components/homekit_controller/media_player.py b/homeassistant/components/homekit_controller/media_player.py new file mode 100644 index 0000000000000..249b8c9c3e088 --- /dev/null +++ b/homeassistant/components/homekit_controller/media_player.py @@ -0,0 +1,230 @@ +"""Support for HomeKit Controller Televisions.""" +import logging + +from aiohomekit.model.characteristics import ( + CharacteristicsTypes, + CurrentMediaStateValues, + RemoteKeyValues, + TargetMediaStateValues, +) +from aiohomekit.model.services import ServicesTypes +from aiohomekit.utils import clamp_enum_to_char + +from homeassistant.components.media_player import DEVICE_CLASS_TV, MediaPlayerEntity +from homeassistant.components.media_player.const import ( + SUPPORT_PAUSE, + SUPPORT_PLAY, + SUPPORT_SELECT_SOURCE, + SUPPORT_STOP, +) +from homeassistant.const import ( + STATE_IDLE, + STATE_OK, + STATE_PAUSED, + STATE_PLAYING, + STATE_PROBLEM, +) +from homeassistant.core import callback + +from . import KNOWN_DEVICES, HomeKitEntity + +_LOGGER = logging.getLogger(__name__) + + +HK_TO_HA_STATE = { + CurrentMediaStateValues.PLAYING: STATE_PLAYING, + CurrentMediaStateValues.PAUSED: STATE_PAUSED, + CurrentMediaStateValues.STOPPED: STATE_IDLE, +} + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Homekit television.""" + hkid = config_entry.data["AccessoryPairingID"] + conn = hass.data[KNOWN_DEVICES][hkid] + + @callback + def async_add_service(aid, service): + if service["stype"] != "television": + return False + info = {"aid": aid, "iid": service["iid"]} + async_add_entities([HomeKitTelevision(conn, info)], True) + return True + + conn.add_listener(async_add_service) + + +class HomeKitTelevision(HomeKitEntity, MediaPlayerEntity): + """Representation of a HomeKit Controller Television.""" + + def get_characteristic_types(self): + """Define the homekit characteristics the entity cares about.""" + return [ + CharacteristicsTypes.ACTIVE, + CharacteristicsTypes.CURRENT_MEDIA_STATE, + CharacteristicsTypes.TARGET_MEDIA_STATE, + CharacteristicsTypes.REMOTE_KEY, + CharacteristicsTypes.ACTIVE_IDENTIFIER, + # Characterics that are on the linked INPUT_SOURCE services + CharacteristicsTypes.CONFIGURED_NAME, + CharacteristicsTypes.IDENTIFIER, + ] + + @property + def device_class(self): + """Define the device class for a HomeKit enabled TV.""" + return DEVICE_CLASS_TV + + @property + def supported_features(self): + """Flag media player features that are supported.""" + features = 0 + + if self.service.has(CharacteristicsTypes.ACTIVE_IDENTIFIER): + features |= SUPPORT_SELECT_SOURCE + + if self.service.has(CharacteristicsTypes.TARGET_MEDIA_STATE): + if TargetMediaStateValues.PAUSE in self.supported_media_states: + features |= SUPPORT_PAUSE + + if TargetMediaStateValues.PLAY in self.supported_media_states: + features |= SUPPORT_PLAY + + if TargetMediaStateValues.STOP in self.supported_media_states: + features |= SUPPORT_STOP + + if self.service.has(CharacteristicsTypes.REMOTE_KEY): + if RemoteKeyValues.PLAY_PAUSE in self.supported_remote_keys: + features |= SUPPORT_PAUSE | SUPPORT_PLAY + + return features + + @property + def supported_media_states(self): + """Mediate state flags that are supported.""" + if not self.service.has(CharacteristicsTypes.TARGET_MEDIA_STATE): + return frozenset() + + return clamp_enum_to_char( + TargetMediaStateValues, + self.service[CharacteristicsTypes.TARGET_MEDIA_STATE], + ) + + @property + def supported_remote_keys(self): + """Remote key buttons that are supported.""" + if not self.service.has(CharacteristicsTypes.REMOTE_KEY): + return frozenset() + + return clamp_enum_to_char( + RemoteKeyValues, self.service[CharacteristicsTypes.REMOTE_KEY] + ) + + @property + def source_list(self): + """List of all input sources for this television.""" + sources = [] + + this_accessory = self._accessory.entity_map.aid(self._aid) + this_tv = this_accessory.services.iid(self._iid) + + input_sources = this_accessory.services.filter( + service_type=ServicesTypes.INPUT_SOURCE, parent_service=this_tv, + ) + + for input_source in input_sources: + char = input_source[CharacteristicsTypes.CONFIGURED_NAME] + sources.append(char.value) + return sources + + @property + def source(self): + """Name of the current input source.""" + active_identifier = self.service.value(CharacteristicsTypes.ACTIVE_IDENTIFIER) + if not active_identifier: + return None + + this_accessory = self._accessory.entity_map.aid(self._aid) + this_tv = this_accessory.services.iid(self._iid) + + input_source = this_accessory.services.first( + service_type=ServicesTypes.INPUT_SOURCE, + characteristics={CharacteristicsTypes.IDENTIFIER: active_identifier}, + parent_service=this_tv, + ) + char = input_source[CharacteristicsTypes.CONFIGURED_NAME] + return char.value + + @property + def state(self): + """State of the tv.""" + active = self.service.value(CharacteristicsTypes.ACTIVE) + if not active: + return STATE_PROBLEM + + homekit_state = self.service.value(CharacteristicsTypes.CURRENT_MEDIA_STATE) + if homekit_state is not None: + return HK_TO_HA_STATE.get(homekit_state, STATE_OK) + + return STATE_OK + + async def async_media_play(self): + """Send play command.""" + if self.state == STATE_PLAYING: + _LOGGER.debug("Cannot play while already playing") + return + + if TargetMediaStateValues.PLAY in self.supported_media_states: + await self.async_put_characteristics( + {CharacteristicsTypes.TARGET_MEDIA_STATE: TargetMediaStateValues.PLAY} + ) + elif RemoteKeyValues.PLAY_PAUSE in self.supported_remote_keys: + await self.async_put_characteristics( + {CharacteristicsTypes.REMOTE_KEY: RemoteKeyValues.PLAY_PAUSE} + ) + + async def async_media_pause(self): + """Send pause command.""" + if self.state == STATE_PAUSED: + _LOGGER.debug("Cannot pause while already paused") + return + + if TargetMediaStateValues.PAUSE in self.supported_media_states: + await self.async_put_characteristics( + {CharacteristicsTypes.TARGET_MEDIA_STATE: TargetMediaStateValues.PAUSE} + ) + elif RemoteKeyValues.PLAY_PAUSE in self.supported_remote_keys: + await self.async_put_characteristics( + {CharacteristicsTypes.REMOTE_KEY: RemoteKeyValues.PLAY_PAUSE} + ) + + async def async_media_stop(self): + """Send stop command.""" + if self.state == STATE_IDLE: + _LOGGER.debug("Cannot stop when already idle") + return + + if TargetMediaStateValues.STOP in self.supported_media_states: + await self.async_put_characteristics( + {CharacteristicsTypes.TARGET_MEDIA_STATE: TargetMediaStateValues.STOP} + ) + + async def async_select_source(self, source): + """Switch to a different media source.""" + this_accessory = self._accessory.entity_map.aid(self._aid) + this_tv = this_accessory.services.iid(self._iid) + + input_source = this_accessory.services.first( + service_type=ServicesTypes.INPUT_SOURCE, + characteristics={CharacteristicsTypes.CONFIGURED_NAME: source}, + parent_service=this_tv, + ) + + if not input_source: + raise ValueError(f"Could not find source {source}") + + identifier = input_source[CharacteristicsTypes.IDENTIFIER] + + await self.async_put_characteristics( + {CharacteristicsTypes.ACTIVE_IDENTIFIER: identifier.value} + ) diff --git a/homeassistant/components/homekit_controller/sensor.py b/homeassistant/components/homekit_controller/sensor.py index b377da80142cf..87f47e720234a 100644 --- a/homeassistant/components/homekit_controller/sensor.py +++ b/homeassistant/components/homekit_controller/sensor.py @@ -1,54 +1,43 @@ """Support for Homekit sensors.""" -from homeassistant.const import TEMP_CELSIUS +from aiohomekit.model.characteristics import CharacteristicsTypes + +from homeassistant.const import ( + CONCENTRATION_PARTS_PER_MILLION, + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_ILLUMINANCE, + DEVICE_CLASS_TEMPERATURE, + TEMP_CELSIUS, + UNIT_PERCENTAGE, +) +from homeassistant.core import callback from . import KNOWN_DEVICES, HomeKitEntity -HUMIDITY_ICON = 'mdi-water-percent' -TEMP_C_ICON = "mdi-temperature-celsius" -BRIGHTNESS_ICON = "mdi-brightness-6" +HUMIDITY_ICON = "mdi:water-percent" +TEMP_C_ICON = "mdi:thermometer" +BRIGHTNESS_ICON = "mdi:brightness-6" +CO2_ICON = "mdi:periodic-table-co2" -UNIT_PERCENT = "%" UNIT_LUX = "lux" -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up Homekit sensor support.""" - if discovery_info is not None: - accessory = hass.data[KNOWN_DEVICES][discovery_info['serial']] - devtype = discovery_info['device-type'] - - if devtype == 'humidity': - add_entities( - [HomeKitHumiditySensor(accessory, discovery_info)], True) - elif devtype == 'temperature': - add_entities( - [HomeKitTemperatureSensor(accessory, discovery_info)], True) - elif devtype == 'light': - add_entities( - [HomeKitLightSensor(accessory, discovery_info)], True) - - 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] - return [ - CharacteristicsTypes.RELATIVE_HUMIDITY_CURRENT - ] + @property + def device_class(self) -> str: + """Return the device class of the sensor.""" + return DEVICE_CLASS_HUMIDITY @property def name(self): """Return the name of the device.""" - return "{} {}".format(super().name, "Humidity") + return f"{super().name} Humidity" @property def icon(self): @@ -58,38 +47,30 @@ def icon(self): @property def unit_of_measurement(self): """Return units for the sensor.""" - return UNIT_PERCENT - - def _update_relative_humidity_current(self, value): - self._state = value + return UNIT_PERCENTAGE @property def state(self): """Return the current humidity.""" - return self._state + return self.service.value(CharacteristicsTypes.RELATIVE_HUMIDITY_CURRENT) 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] - return [ - CharacteristicsTypes.TEMPERATURE_CURRENT - ] + @property + def device_class(self) -> str: + """Return the device class of the sensor.""" + return DEVICE_CLASS_TEMPERATURE @property def name(self): """Return the name of the device.""" - return "{} {}".format(super().name, "Temperature") + return f"{super().name} Temperature" @property def icon(self): @@ -101,51 +82,161 @@ 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 + return self.service.value(CharacteristicsTypes.TEMPERATURE_CURRENT) 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.""" + return [CharacteristicsTypes.LIGHT_LEVEL_CURRENT] + + @property + def device_class(self) -> str: + """Return the device class of the sensor.""" + return DEVICE_CLASS_ILLUMINANCE + + @property + def name(self): + """Return the name of the device.""" + return f"{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 + + @property + def state(self): + """Return the current light level in lux.""" + return self.service.value(CharacteristicsTypes.LIGHT_LEVEL_CURRENT) + + +class HomeKitCarbonDioxideSensor(HomeKitEntity): + """Representation of a Homekit Carbon Dioxide sensor.""" 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.CARBON_DIOXIDE_LEVEL] + + @property + def name(self): + """Return the name of the device.""" + return f"{super().name} CO2" + + @property + def icon(self): + """Return the sensor icon.""" + return CO2_ICON + + @property + def unit_of_measurement(self): + """Return units for the sensor.""" + return CONCENTRATION_PARTS_PER_MILLION + + @property + def state(self): + """Return the current CO2 level in ppm.""" + return self.service.value(CharacteristicsTypes.CARBON_DIOXIDE_LEVEL) + +class HomeKitBatterySensor(HomeKitEntity): + """Representation of a Homekit battery sensor.""" + + def get_characteristic_types(self): + """Define the homekit characteristics the entity is tracking.""" return [ - CharacteristicsTypes.LIGHT_LEVEL_CURRENT + CharacteristicsTypes.BATTERY_LEVEL, + CharacteristicsTypes.STATUS_LO_BATT, + CharacteristicsTypes.CHARGING_STATE, ] + @property + def device_class(self) -> str: + """Return the device class of the sensor.""" + return DEVICE_CLASS_BATTERY + @property def name(self): """Return the name of the device.""" - return "{} {}".format(super().name, "Light Level") + return f"{super().name} Battery" @property def icon(self): """Return the sensor icon.""" - return BRIGHTNESS_ICON + if not self.available or self.state is None: + return "mdi:battery-unknown" + + # This is similar to the logic in helpers.icon, but we have delegated the + # decision about what mdi:battery-alert is to the device. + icon = "mdi:battery" + if self.is_charging and self.state > 10: + percentage = int(round(self.state / 20 - 0.01)) * 20 + icon += f"-charging-{percentage}" + elif self.is_charging: + icon += "-outline" + elif self.is_low_battery: + icon += "-alert" + elif self.state < 95: + percentage = max(int(round(self.state / 10 - 0.01)) * 10, 10) + icon += f"-{percentage}" + + return icon @property def unit_of_measurement(self): """Return units for the sensor.""" - return UNIT_LUX + return UNIT_PERCENTAGE - def _update_light_level_current(self, value): - self._state = value + @property + def is_low_battery(self): + """Return true if battery level is low.""" + return self.service.value(CharacteristicsTypes.STATUS_LO_BATT) == 1 + + @property + def is_charging(self): + """Return true if currently charing.""" + # 0 = not charging + # 1 = charging + # 2 = not chargeable + return self.service.value(CharacteristicsTypes.CHARGING_STATE) == 1 @property def state(self): - """Return the current light level in lux.""" - return self._state + """Return the current battery level percentage.""" + return self.service.value(CharacteristicsTypes.BATTERY_LEVEL) + + +ENTITY_TYPES = { + "humidity": HomeKitHumiditySensor, + "temperature": HomeKitTemperatureSensor, + "light": HomeKitLightSensor, + "carbon-dioxide": HomeKitCarbonDioxideSensor, + "battery": HomeKitBatterySensor, +} + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Homekit sensors.""" + hkid = config_entry.data["AccessoryPairingID"] + conn = hass.data[KNOWN_DEVICES][hkid] + + @callback + def async_add_service(aid, service): + entity_class = ENTITY_TYPES.get(service["stype"]) + if not entity_class: + return False + info = {"aid": aid, "iid": service["iid"]} + async_add_entities([entity_class(conn, info)], True) + return True + + conn.add_listener(async_add_service) diff --git a/homeassistant/components/homekit_controller/storage.py b/homeassistant/components/homekit_controller/storage.py index 4a7c0a8057bc6..ffc5bdc23818b 100644 --- a/homeassistant/components/homekit_controller/storage.py +++ b/homeassistant/components/homekit_controller/storage.py @@ -1,11 +1,11 @@ """Helpers for HomeKit data stored in HA storage.""" -from homeassistant.helpers.storage import Store from homeassistant.core import callback +from homeassistant.helpers.storage import Store from .const import DOMAIN -ENTITY_MAP_STORAGE_KEY = '{}-entity-map'.format(DOMAIN) +ENTITY_MAP_STORAGE_KEY = f"{DOMAIN}-entity-map" ENTITY_MAP_STORAGE_VERSION = 1 ENTITY_MAP_SAVE_DELAY = 10 @@ -29,11 +29,7 @@ class EntityMapStorage: 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.store = Store(hass, ENTITY_MAP_STORAGE_VERSION, ENTITY_MAP_STORAGE_KEY) self.storage_data = {} async def async_initialize(self): @@ -43,22 +39,21 @@ async def async_initialize(self): # There is no cached data about HomeKit devices yet return - self.storage_data = raw_storage.get('pairings', {}) + 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) + @callback def async_create_or_update_map(self, homekit_id, config_num, accessories): """Create a new pairing cache.""" - data = { - 'config_num': config_num, - 'accessories': accessories, - } + data = {"config_num": config_num, "accessories": accessories} self.storage_data[homekit_id] = data self._async_schedule_save() return data + @callback def async_delete_map(self, homekit_id): """Delete pairing cache.""" if homekit_id not in self.storage_data: @@ -75,6 +70,4 @@ def _async_schedule_save(self): @callback def _data_to_save(self): """Return data of entity map to store in a file.""" - return { - 'pairings': self.storage_data, - } + return {"pairings": self.storage_data} diff --git a/homeassistant/components/homekit_controller/strings.json b/homeassistant/components/homekit_controller/strings.json index eceaa624b0fc6..118c3bf7f8a9f 100644 --- a/homeassistant/components/homekit_controller/strings.json +++ b/homeassistant/components/homekit_controller/strings.json @@ -1,39 +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." + "title": "HomeKit Controller", + "config": { + "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 occurred 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 configuration 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 index c09502373a608..32c7fd8515ec8 100644 --- a/homeassistant/components/homekit_controller/switch.py +++ b/homeassistant/components/homekit_controller/switch.py @@ -1,7 +1,14 @@ """Support for Homekit switches.""" import logging -from homeassistant.components.switch import SwitchDevice +from aiohomekit.model.characteristics import ( + CharacteristicsTypes, + InUseValues, + IsConfiguredValues, +) + +from homeassistant.components.switch import SwitchEntity +from homeassistant.core import callback from . import KNOWN_DEVICES, HomeKitEntity @@ -9,62 +16,108 @@ _LOGGER = logging.getLogger(__name__) - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up Homekit switch support.""" - if discovery_info is not None: - accessory = hass.data[KNOWN_DEVICES][discovery_info['serial']] - add_entities([HomeKitSwitch(accessory, discovery_info)], True) +ATTR_IN_USE = "in_use" +ATTR_IS_CONFIGURED = "is_configured" +ATTR_REMAINING_DURATION = "remaining_duration" -class HomeKitSwitch(HomeKitEntity, SwitchDevice): +class HomeKitSwitch(HomeKitEntity, SwitchEntity): """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 + return [CharacteristicsTypes.ON, CharacteristicsTypes.OUTLET_IN_USE] @property def is_on(self): """Return true if device is on.""" - return self._on + return self.service.value(CharacteristicsTypes.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) + await self.async_put_characteristics({CharacteristicsTypes.ON: True}) 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) + await self.async_put_characteristics({CharacteristicsTypes.ON: False}) + + @property + def device_state_attributes(self): + """Return the optional state attributes.""" + outlet_in_use = self.service.value(CharacteristicsTypes.OUTLET_IN_USE) + if outlet_in_use is not None: + return {OUTLET_IN_USE: outlet_in_use} + + +class HomeKitValve(HomeKitEntity, SwitchEntity): + """Represents a valve in an irrigation system.""" + + def get_characteristic_types(self): + """Define the homekit characteristics the entity cares about.""" + return [ + CharacteristicsTypes.ACTIVE, + CharacteristicsTypes.IN_USE, + CharacteristicsTypes.IS_CONFIGURED, + CharacteristicsTypes.REMAINING_DURATION, + ] + + async def async_turn_on(self, **kwargs): + """Turn the specified valve on.""" + await self.async_put_characteristics({CharacteristicsTypes.ACTIVE: True}) + + async def async_turn_off(self, **kwargs): + """Turn the specified valve off.""" + await self.async_put_characteristics({CharacteristicsTypes.ACTIVE: False}) + + @property + def icon(self) -> str: + """Return the icon.""" + return "mdi:water" + + @property + def is_on(self): + """Return true if device is on.""" + return self.service.value(CharacteristicsTypes.ACTIVE) @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, - } + attrs = {} + + in_use = self.service.value(CharacteristicsTypes.IN_USE) + if in_use is not None: + attrs[ATTR_IN_USE] = in_use == InUseValues.IN_USE + + is_configured = self.service.value(CharacteristicsTypes.IS_CONFIGURED) + if is_configured is not None: + attrs[ATTR_IS_CONFIGURED] = is_configured == IsConfiguredValues.CONFIGURED + + remaining = self.service.value(CharacteristicsTypes.REMAINING_DURATION) + if remaining is not None: + attrs[ATTR_REMAINING_DURATION] = remaining + + return attrs + + +ENTITY_TYPES = { + "switch": HomeKitSwitch, + "outlet": HomeKitSwitch, + "valve": HomeKitValve, +} + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Homekit switches.""" + hkid = config_entry.data["AccessoryPairingID"] + conn = hass.data[KNOWN_DEVICES][hkid] + + @callback + def async_add_service(aid, service): + entity_class = ENTITY_TYPES.get(service["stype"]) + if not entity_class: + return False + info = {"aid": aid, "iid": service["iid"]} + async_add_entities([entity_class(conn, info)], True) + return True + + conn.add_listener(async_add_service) diff --git a/homeassistant/components/homekit_controller/translations/bg.json b/homeassistant/components/homekit_controller/translations/bg.json new file mode 100644 index 0000000000000..bca0eeec380ec --- /dev/null +++ b/homeassistant/components/homekit_controller/translations/bg.json @@ -0,0 +1,40 @@ +{ + "config": { + "abort": { + "accessory_not_found_error": "\u0421\u0434\u0432\u043e\u044f\u0432\u0430\u043d\u0435\u0442\u043e \u043d\u0435 \u043c\u043e\u0436\u0435 \u0434\u0430 \u0431\u044a\u0434\u0435 \u0434\u043e\u0431\u0430\u0432\u0435\u043d\u043e, \u0442\u044a\u0439 \u043a\u0430\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u043d\u0435 \u043c\u043e\u0436\u0435 \u0434\u0430 \u0431\u044a\u0434\u0435 \u043d\u0430\u043c\u0435\u0440\u0435\u043d\u043e.", + "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_in_progress": "\u0412 \u043c\u043e\u043c\u0435\u043d\u0442\u0430 \u0442\u0435\u0447\u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u0435 \u043d\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e.", + "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.", + "busy_error": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u043e\u0442\u043a\u0430\u0437\u0432\u0430 \u0434\u0430 \u0434\u043e\u0431\u0430\u0432\u0438 \u0441\u0434\u0432\u043e\u044f\u0432\u0430\u043d\u0435, \u0442\u044a\u0439 \u043a\u0430\u0442\u043e \u0432\u0435\u0447\u0435 \u0441\u0435 \u0441\u0434\u0432\u043e\u044f \u0441 \u0434\u0440\u0443\u0433 \u043a\u043e\u043d\u0442\u0440\u043e\u043b\u0435\u0440.", + "max_peers_error": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u043e\u0442\u043a\u0430\u0437\u0430 \u0434\u0430 \u0434\u043e\u0431\u0430\u0432\u0438 \u0441\u0434\u0432\u043e\u044f\u0432\u0430\u043d\u0435, \u0442\u044a\u0439 \u043a\u0430\u0442\u043e \u043d\u044f\u043c\u0430 \u0441\u0432\u043e\u0431\u043e\u0434\u043d\u043e \u043f\u0440\u043e\u0441\u0442\u0440\u0430\u043d\u0441\u0442\u0432\u043e \u0437\u0430 \u0441\u0434\u0432\u043e\u044f\u0432\u0430\u043d\u0435.", + "max_tries_error": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u043e\u0442\u043a\u0430\u0437\u0432\u0430 \u0434\u0430 \u0434\u043e\u0431\u0430\u0432\u0438 \u0441\u0434\u0432\u043e\u044f\u0432\u0430\u043d\u0435, \u0442\u044a\u0439 \u043a\u0430\u0442\u043e \u0435 \u043f\u043e\u043b\u0443\u0447\u0438\u043b\u043e \u043f\u043e\u0432\u0435\u0447\u0435 \u043e\u0442 100 \u043d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u0438 \u043e\u043f\u0438\u0442\u0430 \u0437\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u043a\u0430\u0446\u0438\u044f.", + "pairing_failed": "\u0412\u044a\u0437\u043d\u0438\u043a\u043d\u0430 \u043d\u0435\u043e\u0431\u0440\u0430\u0431\u043e\u0442\u0435\u043d\u043e \u0433\u0440\u0435\u0448\u043a\u0430 \u043f\u0440\u0438 \u043e\u043f\u0438\u0442 \u0437\u0430 \u0441\u0434\u0432\u043e\u044f\u0432\u0430\u043d\u0435 \u0441 \u0442\u043e\u0432\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e. \u0422\u043e\u0432\u0430 \u043c\u043e\u0436\u0435 \u0434\u0430 \u0435 \u0432\u0440\u0435\u043c\u0435\u043d\u0435\u043d \u043f\u0440\u043e\u0431\u043b\u0435\u043c \u0438\u043b\u0438 \u0432\u0430\u0448\u0435\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043c\u043e\u0436\u0435 \u0434\u0430 \u043d\u0435 \u0441\u0435 \u043f\u043e\u0434\u0434\u044a\u0440\u0436\u0430 \u0432 \u043c\u043e\u043c\u0435\u043d\u0442\u0430.", + "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." + }, + "flow_title": "HomeKit \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e: {name}", + "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 (\u0432\u044a\u0432 \u0444\u043e\u0440\u043c\u0430\u0442 XXX-XX-XXX) \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..3407d93c63fef --- /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 de configuraci\u00f3 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'autenticaci\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 similarity index 100% rename from homeassistant/components/homekit_controller/.translations/cs.json rename to homeassistant/components/homekit_controller/translations/cs.json diff --git a/homeassistant/components/homekit_controller/translations/cy.json b/homeassistant/components/homekit_controller/translations/cy.json new file mode 100644 index 0000000000000..eb4a68c902af7 --- /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..4794b5acc448a --- /dev/null +++ b/homeassistant/components/homekit_controller/translations/da.json @@ -0,0 +1,40 @@ +{ + "config": { + "abort": { + "accessory_not_found_error": "Parring kan ikke tilf\u00f8jes da enheden ikke l\u00e6ngere findes.", + "already_configured": "Tilbeh\u00f8ret er allerede konfigureret med denne controller.", + "already_in_progress": "Enhedskonfiguration er allerede i gang.", + "already_paired": "Dette tilbeh\u00f8r er allerede parret med en anden enhed. Nulstil tilbeh\u00f8ret og pr\u00f8v igen.", + "ignored_model": "HomeKit-underst\u00f8ttelse af denne model er blokeret, da en mere funktionskomplet indbygget integration er tilg\u00e6ngelig.", + "invalid_config_entry": "Denne enhed vises som klar til parring, men der er allerede en modstridende konfigurationspost for den i Home Assistant, som f\u00f8rst skal fjernes.", + "no_devices": "Der blev ikke fundet nogen uparrede enheder" + }, + "error": { + "authentication_error": "Forkert HomeKit-kode. Kontroller den og pr\u00f8v igen.", + "busy_error": "Enheden n\u00e6gtede at parre da den allerede er parret med en anden controller.", + "max_peers_error": "Enheden n\u00e6gtede at parre da den ikke har nok frit parringslagerplads.", + "max_tries_error": "Enheden n\u00e6gtede at parre da den har modtaget mere end 100 mislykkede godkendelsesfors\u00f8g.", + "pairing_failed": "En uh\u00e5ndteret fejl opstod under fors\u00f8g p\u00e5 at parre med denne enhed. Dette kan v\u00e6re en midlertidig fejl eller din enhed muligvis ikke underst\u00f8ttes i \u00f8jeblikket.", + "unable_to_pair": "Kunne ikke parre, pr\u00f8v venligst igen.", + "unknown_error": "Enhed rapporterede en ukendt fejl. Parring mislykkedes." + }, + "flow_title": "HomeKit-tilbeh\u00f8r: {name}", + "step": { + "pair": { + "data": { + "pairing_code": "Parringskode" + }, + "description": "Indtast din HomeKit-parringskode (i formatet XXX-XX-XXX) for at bruge dette tilbeh\u00f8r", + "title": "Par med HomeKit-tilbeh\u00f8r" + }, + "user": { + "data": { + "device": "Enhed" + }, + "description": "V\u00e6lg den enhed du vil parre med", + "title": "Par med HomeKit-tilbeh\u00f8r" + } + } + }, + "title": "HomeKit-tilbeh\u00f8r" +} \ 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..b10fb6efe45fc --- /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. Setze das Zubeh\u00f6r zur\u00fcck und versuche 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": "Gib deinen 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-Controller" +} \ 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..69ea4c3c351a7 --- /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 configuration 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 occurred 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 Controller" +} \ 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..f3a084e7545e7 --- /dev/null +++ b/homeassistant/components/homekit_controller/translations/es-419.json @@ -0,0 +1,31 @@ +{ + "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." + }, + "error": { + "pairing_failed": "Se produjo un error no controlado al intentar vincularse con este dispositivo. Esto puede ser una falla temporal o su dispositivo puede no ser compatible actualmente.", + "unable_to_pair": "No se puede vincular, por favor intente nuevamente.", + "unknown_error": "El dispositivo inform\u00f3 un error desconocido. Vinculaci\u00f3n fallida." + }, + "flow_title": "Accesorio HomeKit: {name}", + "step": { + "pair": { + "data": { + "pairing_code": "C\u00f3digo de emparejamiento" + }, + "description": "Ingrese su c\u00f3digo de emparejamiento de HomeKit (en el formato XXX-XX-XXX) para usar este accesorio", + "title": "Vincular con el accesorio HomeKit" + }, + "user": { + "data": { + "device": "Dispositivo" + }, + "description": "Seleccione el dispositivo con el que desea vincular", + "title": "Vincular con el accesorio HomeKit" + } + } + }, + "title": "Accesorio HomeKit" +} \ 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..b7639223f51aa --- /dev/null +++ b/homeassistant/components/homekit_controller/translations/es.json @@ -0,0 +1,40 @@ +{ + "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_in_progress": "El flujo de configuraci\u00f3n del dispositivo ya est\u00e1 en curso.", + "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 (en este formato XXX-XX-XXX) 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..a21ee3a53b302 --- /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 (au format XXX-XX-XXX) 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..c6d81d985bbd5 --- /dev/null +++ b/homeassistant/components/homekit_controller/translations/hu.json @@ -0,0 +1,40 @@ +{ + "config": { + "abort": { + "accessory_not_found_error": "Nem adhat\u00f3 hozz\u00e1 p\u00e1ros\u00edt\u00e1s, mert az eszk\u00f6z m\u00e1r nem tal\u00e1lhat\u00f3.", + "already_configured": "A tartoz\u00e9k m\u00e1r konfigur\u00e1lva van ezzel a vez\u00e9rl\u0151vel.", + "already_in_progress": "Az eszk\u00f6z konfigur\u00e1ci\u00f3ja m\u00e1r folyamatban van.", + "already_paired": "Ez a tartoz\u00e9k m\u00e1r p\u00e1ros\u00edtva van egy m\u00e1sik eszk\u00f6zzel. \u00c1ll\u00edtsa alaphelyzetbe a tartoz\u00e9kot, majd pr\u00f3b\u00e1lkozzon \u00fajra.", + "ignored_model": "A HomeKit t\u00e1mogat\u00e1sa e modelln\u00e9l blokkolva van, mivel a szolg\u00e1ltat\u00e1shoz teljes nat\u00edv integr\u00e1ci\u00f3 \u00e9rhet\u0151 el.", + "invalid_config_entry": "Ez az eszk\u00f6z k\u00e9szen \u00e1ll a p\u00e1ros\u00edt\u00e1sra, de m\u00e1r van egy \u00fctk\u00f6z\u0151 konfigur\u00e1ci\u00f3s bejegyz\u00e9s a Home Assistant-ben, amelyet el\u0151sz\u00f6r el kell t\u00e1vol\u00edtani.", + "no_devices": "Nem tal\u00e1lhat\u00f3 nem p\u00e1ros\u00edtott eszk\u00f6z" + }, + "error": { + "authentication_error": "Helytelen HomeKit k\u00f3d. K\u00e9rj\u00fck, ellen\u0151rizze, \u00e9s pr\u00f3b\u00e1lja \u00fajra.", + "busy_error": "Az eszk\u00f6z megtagadta a p\u00e1ros\u00edt\u00e1s hozz\u00e1ad\u00e1s\u00e1t, mivel m\u00e1r p\u00e1ros\u00edtva van egy m\u00e1sik vez\u00e9rl\u0151vel.", + "max_peers_error": "Az eszk\u00f6z megtagadta a p\u00e1ros\u00edt\u00e1s hozz\u00e1ad\u00e1s\u00e1t, mivel nincs ingyenes p\u00e1ros\u00edt\u00e1si t\u00e1rhely.", + "max_tries_error": "Az eszk\u00f6z megtagadta a p\u00e1ros\u00edt\u00e1s hozz\u00e1ad\u00e1s\u00e1t, mivel t\u00f6bb mint 100 sikertelen hiteles\u00edt\u00e9si k\u00eds\u00e9rletet kapott.", + "pairing_failed": "Nem kezelt hiba t\u00f6rt\u00e9nt az eszk\u00f6zzel val\u00f3 p\u00e1ros\u00edt\u00e1s sor\u00e1n. Lehet, hogy ez \u00e1tmeneti hiba, vagy az eszk\u00f6z jelenleg m\u00e9g nem t\u00e1mogatott.", + "unable_to_pair": "Nem siker\u00fclt p\u00e1ros\u00edtani, pr\u00f3b\u00e1ld \u00fajra.", + "unknown_error": "Az eszk\u00f6z ismeretlen hib\u00e1t jelentett. A p\u00e1ros\u00edt\u00e1s sikertelen." + }, + "flow_title": "HomeKit tartoz\u00e9k: {name}", + "step": { + "pair": { + "data": { + "pairing_code": "P\u00e1ros\u00edt\u00e1si k\u00f3d" + }, + "description": "\u00cdrja be a HomeKit p\u00e1ros\u00edt\u00e1si k\u00f3dj\u00e1t (XXX-XX-XXX form\u00e1tumban) a kieg\u00e9sz\u00edt\u0151 haszn\u00e1lat\u00e1hoz", + "title": "HomeKit tartoz\u00e9k p\u00e1ros\u00edt\u00e1sa" + }, + "user": { + "data": { + "device": "Eszk\u00f6z" + }, + "description": "V\u00e1lassza ki azt az eszk\u00f6zt, amelyet p\u00e1ros\u00edtani szeretne", + "title": "HomeKit tartoz\u00e9k p\u00e1ros\u00edt\u00e1sa" + } + } + }, + "title": "HomeKit Vez\u00e9rl\u0151" +} \ 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..033e6f937daa1 --- /dev/null +++ b/homeassistant/components/homekit_controller/translations/it.json @@ -0,0 +1,40 @@ +{ + "config": { + "abort": { + "accessory_not_found_error": "Impossibile aggiungere l'abbinamento in quanto non \u00e8 pi\u00f9 possibile trovare il dispositivo.", + "already_configured": "L'accessorio \u00e8 gi\u00e0 configurato con questo controller.", + "already_in_progress": "Il flusso di configurazione per il dispositivo \u00e8 gi\u00e0 in corso.", + "already_paired": "Questo accessorio \u00e8 gi\u00e0 associato a un altro dispositivo. Si prega di resettare l'accessorio e riprovare.", + "ignored_model": "Il supporto di HomeKit per questo modello \u00e8 bloccato poich\u00e9 \u00e8 disponibile un'integrazione nativa con pi\u00f9 funzionalit\u00e0.", + "invalid_config_entry": "Questo dispositivo viene visualizzato come pronto per l'associazione, ma c'\u00e8 gi\u00e0 una voce di configurazione in conflitto in Home Assistant che prima deve essere rimossa.", + "no_devices": "Non \u00e8 stato possibile trovare dispositivi non associati" + }, + "error": { + "authentication_error": "Codice HomeKit errato. Per favore, controllate e riprovate.", + "busy_error": "Il dispositivo ha rifiutato di aggiungere l'abbinamento in quanto \u00e8 gi\u00e0 associato a un altro controller.", + "max_peers_error": "Il dispositivo ha rifiutato di aggiungere l'abbinamento in quanto non dispone di una memoria libera per esso.", + "max_tries_error": "Il dispositivo ha rifiutato di aggiungere l'abbinamento poich\u00e9 ha ricevuto pi\u00f9 di 100 tentativi di autenticazione non riusciti.", + "pairing_failed": "Si \u00e8 verificato un errore non gestito durante il tentativo di abbinamento con questo dispositivo. Potrebbe trattarsi di un errore temporaneo o il dispositivo potrebbe non essere attualmente supportato.", + "unable_to_pair": "Impossibile abbinare, per favore riprova.", + "unknown_error": "Il dispositivo ha riportato un errore sconosciuto. L'abbinamento non \u00e8 riuscito." + }, + "flow_title": "Accessorio HomeKit: {name}", + "step": { + "pair": { + "data": { + "pairing_code": "Codice di abbinamento" + }, + "description": "Immettere il codice di abbinamento HomeKit (nel formato XXX-XX-XXX) per utilizzare 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": "Controller 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..6cd6c20aad0f5 --- /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\uc774 \ud544\uc694\ud55c \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\ud558\uae30" + }, + "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\ud558\uae30" + } + } + }, + "title": "HomeKit \ucee8\ud2b8\ub864\ub7ec" +} \ 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..c840cb4e9fcf7 --- /dev/null +++ b/homeassistant/components/homekit_controller/translations/lb.json @@ -0,0 +1,40 @@ +{ + "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_in_progress": "Konfiguratioun fir d\u00ebsen Apparat ass schonn am gaang.", + "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 D\u00e4in 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 (am Format XXX-XX-XXX) 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 Kontroller" +} \ 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..20013168c8150 --- /dev/null +++ b/homeassistant/components/homekit_controller/translations/nl.json @@ -0,0 +1,40 @@ +{ + "config": { + "abort": { + "accessory_not_found_error": "Kan geen koppeling toevoegen omdat het apparaat niet langer kan worden gevonden.", + "already_configured": "Accessoire is al geconfigureerd met deze controller.", + "already_in_progress": "De configuratiestroom voor het apparaat is al in volle gang.", + "already_paired": "Dit accessoire is al gekoppeld aan een ander apparaat. Reset het accessoire en probeer het opnieuw.", + "ignored_model": "HomeKit-ondersteuning voor dit model is geblokkeerd omdat er een meer functie volledige native integratie beschikbaar is.", + "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.", + "busy_error": "Het apparaat weigerde om koppelingen toe te voegen, omdat het al gekoppeld is met een andere controller.", + "max_peers_error": "Apparaat heeft geweigerd om koppelingen toe te voegen omdat het geen vrije koppelingsopslag heeft.", + "max_tries_error": "Apparaat weigerde pairing toe te voegen omdat het meer dan 100 niet-succesvolle authenticatiepogingen heeft ontvangen.", + "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." + }, + "flow_title": "HomeKit-accessoire: {name}", + "step": { + "pair": { + "data": { + "pairing_code": "Koppelingscode" + }, + "description": "Voer uw HomeKit pairing code (in het formaat XXX-XX-XXX) om dit accessoire te gebruiken", + "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..a00780eb54f8c --- /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..f3d93fd9e9280 --- /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 sammenkobling, men det er allerede en motstridende konfigurasjonsoppf\u00f8ring for den i Hjelpeassistenten som f\u00f8rst m\u00e5 fjernes.", + "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 din HomeKit-sammenkoblingskode (i formatet XXX-XX-XXX) 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-kontroller" +} \ 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..498d927ffb87d --- /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": "Konfiguracja 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 dodania 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 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..ff0dadf8c5724 --- /dev/null +++ b/homeassistant/components/homekit_controller/translations/pt-BR.json @@ -0,0 +1,40 @@ +{ + "config": { + "abort": { + "accessory_not_found_error": "N\u00e3o \u00e9 poss\u00edvel adicionar o emparelhamento, pois o dispositivo n\u00e3o pode mais ser encontrado.", + "already_configured": "O acess\u00f3rio j\u00e1 est\u00e1 configurado com este controlador.", + "already_in_progress": "O fluxo de configura\u00e7\u00e3o para o dispositivo j\u00e1 est\u00e1 em andamento.", + "already_paired": "Este acess\u00f3rio j\u00e1 est\u00e1 pareado com outro dispositivo. Por favor, redefina o acess\u00f3rio e tente novamente.", + "ignored_model": "O suporte do HomeKit para este modelo est\u00e1 bloqueado, j\u00e1 que uma integra\u00e7\u00e3o nativa mais completa est\u00e1 dispon\u00edvel.", + "invalid_config_entry": "Este dispositivo est\u00e1 mostrando como pronto para parear, mas existe um conflito na configura\u00e7\u00e3o de entrada para ele no Home Assistant que deve ser removida primeiro.", + "no_devices": "N\u00e3o foi poss\u00edvel encontrar dispositivos n\u00e3o pareados" + }, + "error": { + "authentication_error": "C\u00f3digo HomeKit incorreto. Por favor verifique e tente novamente.", + "busy_error": "O dispositivo recusou-se a adicionar o emparelhamento, uma vez que j\u00e1 est\u00e1 emparelhando com outro controlador.", + "max_peers_error": "O dispositivo recusou-se a adicionar o emparelhamento, pois n\u00e3o tem armazenamento de emparelhamento gratuito.", + "max_tries_error": "O dispositivo recusou-se a adicionar o emparelhamento, uma vez que recebeu mais de 100 tentativas de autentica\u00e7\u00e3o malsucedidas.", + "pairing_failed": "Ocorreu um erro sem tratamento ao tentar emparelhar com este dispositivo. Isso pode ser uma falha tempor\u00e1ria ou o dispositivo pode n\u00e3o ser suportado no momento.", + "unable_to_pair": "N\u00e3o \u00e9 poss\u00edvel parear, tente novamente.", + "unknown_error": "O dispositivo relatou um erro desconhecido. O pareamento falhou." + }, + "flow_title": "Acess\u00f3rio HomeKit: {name}", + "step": { + "pair": { + "data": { + "pairing_code": "C\u00f3digo de pareamento" + }, + "description": "Digite seu c\u00f3digo de pareamento do HomeKit (no formato XXX-XX-XXX) para usar este acess\u00f3rio", + "title": "Parear com o acess\u00f3rio HomeKit" + }, + "user": { + "data": { + "device": "Dispositivo" + }, + "description": "Selecione o dispositivo com o qual voc\u00ea deseja parear", + "title": "Parear com o acess\u00f3rio HomeKit" + } + } + }, + "title": "Acess\u00f3rio HomeKit" +} \ 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..f4ffbf157bd34 --- /dev/null +++ b/homeassistant/components/homekit_controller/translations/pt.json @@ -0,0 +1,40 @@ +{ + "config": { + "abort": { + "accessory_not_found_error": "N\u00e3o \u00e9 poss\u00edvel adicionar o emparelhamento. O dispositivo n\u00e3o foi encontrado.", + "already_configured": "O acess\u00f3rio j\u00e1 est\u00e1 configurado com este controlador.", + "already_in_progress": "O fluxo de configura\u00e7\u00e3o do dispositivo j\u00e1 est\u00e1 em andamento.", + "already_paired": "Este acess\u00f3rio j\u00e1 est\u00e1 emparelhado com outro dispositivo. Por favor restaure as defini\u00e7\u00f5es de f\u00e1brica do acess\u00f3rio e tente novamente.", + "ignored_model": "O suporte do HomeKit para este modelo est\u00e1 bloqueado, uma vez que est\u00e1 dispon\u00edvel uma integra\u00e7\u00e3o nativa mais completa.", + "invalid_config_entry": "Este dispositivo est\u00e1 pronto a emparelhar, mas j\u00e1 existe uma configura\u00e7\u00e3o no Home Assistant que precisa ser removida primeiro.", + "no_devices": "N\u00e3o foram encontrados dispositivos por emparelhar" + }, + "error": { + "authentication_error": "C\u00f3digo incorreto do HomeKit. Por favor, verifique e tente novamente.", + "busy_error": "O dispositivo recusou-se a adicionar emparelhamento, pois j\u00e1 est\u00e1 emparelhado com outro controlador.", + "max_peers_error": "O dispositivo recusou-se a adicionar o emparelhamento, pois n\u00e3o possui espa\u00e7o livre para o emparelhamento.", + "max_tries_error": "O dispositivo recusou-se a emparelhar por ter recebido mais de 100 tentativas de autentica\u00e7\u00e3o mal sucedidas.", + "pairing_failed": "Ocorreu um erro n\u00e3o tratado ao tentar emparelhar com este dispositivo. Poder\u00e1 ser uma falha tempor\u00e1ria ou o dispositivo pode n\u00e3o ser suportado agora.", + "unable_to_pair": "N\u00e3o foi poss\u00edvel emparelhar, por favor, tente novamente.", + "unknown_error": "O dispositivo reportou um erro desconhecido. O emparelhamento falhou." + }, + "flow_title": "Acess\u00f3rio HomeKit: {name}", + "step": { + "pair": { + "data": { + "pairing_code": "C\u00f3digo de emparelhamento" + }, + "description": "Introduza o c\u00f3digo de emparelhamento do seu HomeKit (no formato XXX-XX-XXX) para utilizar este acess\u00f3rio", + "title": "Emparelhar com o acess\u00f3rio HomeKit" + }, + "user": { + "data": { + "device": "Dispositivo" + }, + "description": "Selecione o dispositivo com o qual deseja emparelhar", + "title": "Emparelhar com o acess\u00f3rio HomeKit" + } + } + }, + "title": "Acess\u00f3rio HomeKit" +} \ 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..95f8a4d6efe90 --- /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 \u044d\u0442\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u044f\u0435\u0442\u0441\u044f.", + "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": "HomeKit Controller" +} \ 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..4dcd1d485c33a --- /dev/null +++ b/homeassistant/components/homekit_controller/translations/sl.json @@ -0,0 +1,40 @@ +{ + "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_in_progress": "Konfiguracijski tok za to napravo je \u017ee v teku.", + "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 je prikazana kot pripravljena za seznanjanje, vendar je v programu Home Assistant zanj \u017ee vpisan konfliktni vnos konfiguracije, 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": "Med poskusom seznanitev s to napravo je pri\u0161lo do napake. To je lahko za\u010dasna napaka ali pa va\u0161a 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..0c57e09b09b70 --- /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: {name}", + "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..938d6f2aad83c --- /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..39283a733e8c5 --- /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..4b58e4f9fb6fe --- /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: {name}", + "step": { + "pair": { + "data": { + "pairing_code": "\u914d\u5bf9\u4ee3\u7801" + }, + "description": "\u8f93\u5165\u60a8\u7684HomeKit\u914d\u5bf9\u4ee3\u7801\uff08\u683c\u5f0f\u4e3aXXX-XX-XXX\uff09\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..27ff273d117a2 --- /dev/null +++ b/homeassistant/components/homekit_controller/translations/zh-Hant.json @@ -0,0 +1,40 @@ +{ + "config": { + "abort": { + "accessory_not_found_error": "\u627e\u4e0d\u5230\u8a2d\u5099\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": "\u8a2d\u5099\u8a2d\u5b9a\u5df2\u7d93\u9032\u884c\u4e2d\u3002", + "already_paired": "\u914d\u4ef6\u5df2\u7d93\u8207\u5176\u4ed6\u8a2d\u5099\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": "\u6b64\u8a2d\u5099\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\u8a2d\u5099" + }, + "error": { + "authentication_error": "Homekit \u4ee3\u78bc\u932f\u8aa4\uff0c\u8acb\u78ba\u5b9a\u5f8c\u518d\u8a66\u4e00\u6b21\u3002", + "busy_error": "\u8a2d\u5099\u5df2\u7d93\u8207\u5176\u4ed6\u63a7\u5236\u5668\u914d\u5c0d\uff0c\u62d2\u7d55\u9032\u884c\u914d\u5c0d\u3002", + "max_peers_error": "\u8a2d\u5099\u5df2\u7121\u5269\u9918\u914d\u5c0d\u7a7a\u9593\uff0c\u62d2\u7d55\u9032\u884c\u914d\u5c0d\u3002", + "max_tries_error": "\u8a2d\u5099\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\u8a2d\u5099\u914d\u5c0d\u6642\u767c\u751f\u7121\u6cd5\u8655\u7406\u932f\u8aa4\uff0c\u53ef\u80fd\u50c5\u70ba\u66ab\u6642\u5931\u6548\u3001\u6216\u8005\u8a2d\u5099\u76ee\u524d\u4e0d\u652f\u63f4\u3002", + "unable_to_pair": "\u7121\u6cd5\u914d\u5c0d\uff0c\u8acb\u518d\u8a66\u4e00\u6b21\u3002", + "unknown_error": "\u8a2d\u5099\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\uff08\u683c\u5f0f\uff1aXXX-XX-XXX\uff09\u4ee5\u4f7f\u7528\u6b64\u914d\u4ef6", + "title": "HomeKit \u914d\u4ef6\u914d\u5c0d" + }, + "user": { + "data": { + "device": "\u8a2d\u5099" + }, + "description": "\u9078\u64c7\u6240\u8981\u65b0\u589e\u7684\u8a2d\u5099", + "title": "HomeKit \u914d\u4ef6\u914d\u5c0d" + } + } + }, + "title": "HomeKit \u63a7\u5236\u5668" +} \ No newline at end of file diff --git a/homeassistant/components/homematic/__init__.py b/homeassistant/components/homematic/__init__.py index 7f6f9a6d522b5..4dfc27650c895 100644 --- a/homeassistant/components/homematic/__init__.py +++ b/homeassistant/components/homematic/__init__.py @@ -1,263 +1,210 @@ """Support for HomeMatic devices.""" -from datetime import timedelta +from datetime import datetime from functools import partial import logging +from pyhomematic import HMConnection 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) + ATTR_ENTITY_ID, + ATTR_MODE, + ATTR_NAME, + CONF_HOST, + CONF_HOSTS, + CONF_PASSWORD, + CONF_PLATFORM, + CONF_SSL, + CONF_USERNAME, + CONF_VERIFY_SSL, + EVENT_HOMEASSISTANT_STOP, +) from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import Entity + +from .const import ( + ATTR_ADDRESS, + ATTR_CHANNEL, + ATTR_DISCOVER_DEVICES, + ATTR_DISCOVERY_TYPE, + ATTR_ERRORCODE, + ATTR_INTERFACE, + ATTR_LOW_BAT, + ATTR_LOWBAT, + ATTR_MESSAGE, + ATTR_PARAM, + ATTR_PARAMSET, + ATTR_PARAMSET_KEY, + ATTR_TIME, + ATTR_UNIQUE_ID, + ATTR_VALUE, + ATTR_VALUE_TYPE, + CONF_CALLBACK_IP, + CONF_CALLBACK_PORT, + CONF_INTERFACES, + CONF_JSONPORT, + CONF_LOCAL_IP, + CONF_LOCAL_PORT, + CONF_PATH, + CONF_PORT, + CONF_RESOLVENAMES, + CONF_RESOLVENAMES_OPTIONS, + DATA_CONF, + DATA_HOMEMATIC, + DATA_STORE, + DISCOVER_BATTERY, + DISCOVER_BINARY_SENSORS, + DISCOVER_CLIMATE, + DISCOVER_COVER, + DISCOVER_LIGHTS, + DISCOVER_LOCKS, + DISCOVER_SENSORS, + DISCOVER_SWITCHES, + DOMAIN, + EVENT_ERROR, + EVENT_IMPULSE, + EVENT_KEYPRESS, + HM_DEVICE_TYPES, + HM_IGNORE_DISCOVERY_NODE, + HM_IGNORE_DISCOVERY_NODE_EXCEPTIONS, + HM_IMPULSE_EVENTS, + HM_PRESS_EVENTS, + SERVICE_PUT_PARAMSET, + SERVICE_RECONNECT, + SERVICE_SET_DEVICE_VALUE, + SERVICE_SET_INSTALL_MODE, + SERVICE_SET_VARIABLE_VALUE, + SERVICE_VIRTUALKEY, +) +from .entity import HMHub _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_BUTTONS = 'homematic.binary_sensor' - -ATTR_DISCOVER_DEVICES = 'devices' -ATTR_BATTERY_DEVICES = 'battery_devices' -ATTR_PARAM = 'param' -ATTR_CHANNEL = 'channel' -ATTR_ADDRESS = 'address' -ATTR_VALUE = 'value' -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' - - -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'], - DISCOVER_BUTTONS: ['HmIP-WRC6', 'HmIP-RC8'] -} - -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_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_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_INTERFACE): cv.string, -}) +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_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_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, -}) +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 = {} @@ -266,27 +213,27 @@ def setup(hass, config): # 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, + "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[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, + "ip": sconfig.get(CONF_HOST), + "port": sconfig[CONF_PORT], + "username": sconfig.get(CONF_USERNAME), + "password": sconfig.get(CONF_PASSWORD), + "connect": False, } # Create server thread @@ -296,15 +243,14 @@ def setup(hass, config): localport=config[DOMAIN].get(CONF_LOCAL_PORT, DEFAULT_LOCAL_PORT), remotes=remotes, systemcallback=bound_system_callback, - interface_id='homeassistant' + 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) + # Stops server when Home Assistant is shutting down + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, hass.data[DATA_HOMEMATIC].stop) # Init homematic hubs entity_hubs = [] @@ -330,16 +276,18 @@ def _hm_service_virtualkey(service): # 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) + _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) + DOMAIN, + SERVICE_VIRTUALKEY, + _hm_service_virtualkey, + schema=SCHEMA_SERVICE_VIRTUALKEY, + ) def _service_handle_value(service): """Service to call setValue method for HomeMatic system variable.""" @@ -348,8 +296,9 @@ def _service_handle_value(service): value = service.data[ATTR_VALUE] if entity_ids: - entities = [entity for entity in entity_hubs if - entity.entity_id in entity_ids] + entities = [ + entity for entity in entity_hubs if entity.entity_id in entity_ids + ] else: entities = entity_hubs @@ -361,16 +310,22 @@ def _service_handle_value(service): hub.hm_set_variable(name, value) hass.services.register( - DOMAIN, SERVICE_SET_VARIABLE_VALUE, _service_handle_value, - schema=SCHEMA_SERVICE_SET_VARIABLE_VALUE) + 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) + DOMAIN, + SERVICE_RECONNECT, + _service_handle_reconnect, + schema=SCHEMA_SERVICE_RECONNECT, + ) def _service_handle_device(service): """Service to call setValue method for HomeMatic devices.""" @@ -378,6 +333,22 @@ def _service_handle_device(service): 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) @@ -388,8 +359,11 @@ def _service_handle_device(service): hmdevice.setValue(param, value, channel) hass.services.register( - DOMAIN, SERVICE_SET_DEVICE_VALUE, _service_handle_device, - schema=SCHEMA_SERVICE_SET_DEVICE_VALUE) + 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.""" @@ -401,8 +375,11 @@ def _service_handle_install_mode(service): 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) + 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.""" @@ -416,13 +393,19 @@ def _service_put_paramset(service): _LOGGER.debug( "Calling putParamset: %s, %s, %s, %s", - interface, address, paramset_key, paramset + 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) + DOMAIN, + SERVICE_PUT_PARAMSET, + _service_put_paramset, + schema=SCHEMA_SERVICE_PUT_PARAMSET, + ) return True @@ -430,17 +413,17 @@ def _service_put_paramset(service): def _system_callback_handler(hass, config, src, *args): """System callback handler.""" # New devices available at hub - if src == 'newDevices': + if src == "newDevices": (interface_id, dev_descriptions) = args - interface = interface_id.split('-')[-1] + interface = interface_id.split("-")[-1] # Device support active? - if not hass.data[DATA_CONF][interface]['connect']: + if not hass.data[DATA_CONF][interface]["connect"]: return addresses = [] for dev in dev_descriptions: - address = dev['ADDRESS'].split(':')[0] + address = dev["ADDRESS"].split(":")[0] if address not in hass.data[DATA_STORE]: hass.data[DATA_STORE].add(address) addresses.append(address) @@ -452,51 +435,42 @@ def _system_callback_handler(hass, config, src, *args): hmdevice = hass.data[DATA_HOMEMATIC].devices[interface].get(dev) if hmdevice.EVENTNODE: - hmdevice.setEventCallback( - callback=bound_event_callback, bequeath=True) + hmdevice.setEventCallback(callback=bound_event_callback, bequeath=True) - # Create HASS entities + # Create Home Assistant 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_SWITCHES)): + ("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) + 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 + # they are setup in Home Assistant and a discovery event is fired if found_devices: - discovery_info = {ATTR_DISCOVER_DEVICES: found_devices, - ATTR_BATTERY_DEVICES: False} - - # Switches are skipped as a component. They will only - # appear in hass as a battery device. - if not discovery_type == DISCOVER_SWITCHES: - discovery.load_platform(hass, component_name, DOMAIN, - discovery_info, config) - - # Pass all devices to binary sensor discovery, - # check whether they are battery operated and - # add them as a battery operated binary sensor device. - discovery_info[ATTR_BATTERY_DEVICES] = True - discovery.load_platform(hass, 'binary_sensor', DOMAIN, - discovery_info, config) + discovery.load_platform( + hass, + component_name, + DOMAIN, + { + ATTR_DISCOVER_DEVICES: found_devices, + ATTR_DISCOVERY_TYPE: discovery_type, + }, + config, + ) # Homegear error message - elif src == 'error': + elif src == "error": _LOGGER.error("Error: %s", args) (interface_id, errorcode, message) = args - hass.bus.fire(EVENT_ERROR, { - ATTR_ERRORCODE: errorcode, - ATTR_MESSAGE: message - }) + hass.bus.fire(EVENT_ERROR, {ATTR_ERRORCODE: errorcode, ATTR_MESSAGE: message}) def _get_devices(hass, discovery_type, keys, interface): @@ -509,7 +483,10 @@ def _get_devices(hass, discovery_type, keys, interface): metadata = {} # Class not supported by discovery type - if class_name not in HM_DEVICE_TYPES[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 @@ -517,26 +494,39 @@ def _get_devices(hass, discovery_type, keys, interface): 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, []): + if ( + param in HM_IGNORE_DISCOVERY_NODE + and class_name not in HM_IGNORE_DISCOVERY_NODE_EXCEPTIONS.get(param, []) + ): continue + if discovery_type == DISCOVER_SWITCHES and class_name == "IPKeySwitchLevel": + channels.remove(8) + channels.remove(12) + if discovery_type == DISCOVER_LIGHTS and class_name == "IPKeySwitchLevel": + channels.remove(4) # Add devices - _LOGGER.debug("%s: Handling %s: %s: %s", - discovery_type, key, param, channels) + _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) + name=device.NAME, channel=channel, param=param, count=len(channels) ) unique_id = _create_ha_id( - name=key, channel=channel, param=param, - count=len(channels) + name=key, channel=channel, param=param, count=len(channels) ) device_dict = { CONF_PLATFORM: "homematic", @@ -544,7 +534,7 @@ def _get_devices(hass, discovery_type, keys, interface): ATTR_INTERFACE: interface, ATTR_NAME: name, ATTR_CHANNEL: channel, - ATTR_UNIQUE_ID: unique_id + ATTR_UNIQUE_ID: unique_id, } if param is not None: device_dict[ATTR_PARAM] = param @@ -554,8 +544,7 @@ def _get_devices(hass, discovery_type, keys, interface): DEVICE_SCHEMA(device_dict) device_arr.append(device_dict) except vol.MultipleInvalid as err: - _LOGGER.error("Invalid device config: %s", - str(err)) + _LOGGER.error("Invalid device config: %s", str(err)) return device_arr @@ -567,15 +556,15 @@ def _create_ha_id(name, channel, param, count): # Has multiple elements/channels if count > 1 and param is None: - return "{} {}".format(name, channel) + return f"{name} {channel}" # With multiple parameters on first channel if count == 1 and param is not None: - return "{} {}".format(name, param) + return f"{name} {param}" # Multiple parameters with multiple channels if count > 1 and param is not None: - return "{} {} {}".format(name, channel, param) + return f"{name} {channel} {param}" def _hm_event_handler(hass, interface, device, caller, attribute, value): @@ -592,24 +581,19 @@ def _hm_event_handler(hass, interface, device, caller, attribute, value): if attribute not in hmdevice.EVENTNODE: return - _LOGGER.debug("Event %s for %s channel %i", attribute, - hmdevice.NAME, channel) + _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 - }) + 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 - }) + hass.bus.fire(EVENT_IMPULSE, {ATTR_NAME: hmdevice.NAME, ATTR_CHANNEL: channel}) return _LOGGER.warning("Event is unknown and not forwarded") @@ -619,8 +603,8 @@ 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 address == "BIDCOS-RF": + address = "BidCoS-RF" if interface: return hass.data[DATA_HOMEMATIC].devices[interface].get(address) @@ -628,278 +612,3 @@ def _device_from_servicecall(hass, service): 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 index 91960cd85702a..041f2f02643f8 100644 --- a/homeassistant/components/homematic/binary_sensor.py +++ b/homeassistant/components/homematic/binary_sensor.py @@ -1,30 +1,35 @@ """Support for HomeMatic binary sensors.""" import logging -from homeassistant.components.binary_sensor import BinarySensorDevice -from homeassistant.components.homematic import ATTR_BATTERY_DEVICES -from homeassistant.const import STATE_UNKNOWN, DEVICE_CLASS_BATTERY - -from . import ATTR_DISCOVER_DEVICES, HMDevice +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_MOTION, + DEVICE_CLASS_OPENING, + DEVICE_CLASS_PRESENCE, + DEVICE_CLASS_SMOKE, + BinarySensorEntity, +) + +from .const import ATTR_DISCOVER_DEVICES, ATTR_DISCOVERY_TYPE, DISCOVER_BATTERY +from .entity import HMDevice _LOGGER = logging.getLogger(__name__) -ATTR_LOW_BAT = 'LOW_BAT' -ATTR_LOWBAT = 'LOWBAT' - 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, + "IPShutterContact": DEVICE_CLASS_OPENING, + "IPShutterContactSabotage": DEVICE_CLASS_OPENING, + "MaxShutterContact": DEVICE_CLASS_OPENING, + "Motion": DEVICE_CLASS_MOTION, + "MotionV2": DEVICE_CLASS_MOTION, + "PresenceIP": DEVICE_CLASS_PRESENCE, + "Remote": None, + "RemoteMotion": None, + "ShutterContact": DEVICE_CLASS_OPENING, + "Smoke": DEVICE_CLASS_SMOKE, + "SmokeV2": DEVICE_CLASS_SMOKE, + "TiltSensor": None, + "WeatherSensor": None, + "IPContact": DEVICE_CLASS_OPENING, } @@ -34,21 +39,16 @@ def setup_platform(hass, config, add_entities, discovery_info=None): return devices = [] - battery_devices = discovery_info[ATTR_BATTERY_DEVICES] - for conf in discovery_info[ATTR_DISCOVER_DEVICES]: - if battery_devices: - battery_device = conf.get(ATTR_LOWBAT) or conf.get(ATTR_LOW_BAT) - if battery_device: - new_device = HMBatterySensor(conf) + if discovery_info[ATTR_DISCOVERY_TYPE] == DISCOVER_BATTERY: + devices.append(HMBatterySensor(conf)) else: - new_device = HMBinarySensor(conf) - devices.append(new_device) + devices.append(HMBinarySensor(conf)) - add_entities(devices) + add_entities(devices, True) -class HMBinarySensor(HMDevice, BinarySensorDevice): +class HMBinarySensor(HMDevice, BinarySensorEntity): """Representation of a binary HomeMatic device.""" @property @@ -62,18 +62,18 @@ def is_on(self): 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) + if self._state == "MOTION": + return DEVICE_CLASS_MOTION + return SENSOR_TYPES_CLASS.get(self._hmdevice.__class__.__name__) 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: STATE_UNKNOWN}) + self._data.update({self._state: None}) -class HMBatterySensor(HMDevice, BinarySensorDevice): +class HMBatterySensor(HMDevice, BinarySensorEntity): """Representation of an HomeMatic low battery sensor.""" @property @@ -84,13 +84,10 @@ def device_class(self): @property def is_on(self): """Return True if battery is low.""" - is_on = self._data.get(ATTR_LOW_BAT, False) or self._data.get( - ATTR_LOWBAT, False - ) - return is_on + 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: STATE_UNKNOWN}) + self._data.update({self._state: None}) diff --git a/homeassistant/components/homematic/climate.py b/homeassistant/components/homematic/climate.py index e10d486b727db..243e1782a370a 100644 --- a/homeassistant/components/homematic/climate.py +++ b/homeassistant/components/homematic/climate.py @@ -1,42 +1,38 @@ """Support for Homematic thermostats.""" import logging -from homeassistant.components.climate import ClimateDevice +from homeassistant.components.climate import ClimateEntity from homeassistant.components.climate.const import ( - STATE_AUTO, STATE_MANUAL, SUPPORT_OPERATION_MODE, - SUPPORT_TARGET_TEMPERATURE) + HVAC_MODE_AUTO, + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + PRESET_BOOST, + PRESET_COMFORT, + PRESET_ECO, + SUPPORT_PRESET_MODE, + SUPPORT_TARGET_TEMPERATURE, +) from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS -from . import ATTR_DISCOVER_DEVICES, HM_ATTRIBUTE_SUPPORT, HMDevice +from .const import ATTR_DISCOVER_DEVICES, HM_ATTRIBUTE_SUPPORT +from .entity import HMDevice _LOGGER = logging.getLogger(__name__) -STATE_BOOST = 'boost' -STATE_COMFORT = 'comfort' -STATE_LOWERING = 'lowering' +HM_TEMP_MAP = ["ACTUAL_TEMPERATURE", "TEMPERATURE"] -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_HUMI_MAP = [ - 'ACTUAL_HUMIDITY', - 'HUMIDITY', -] +HM_PRESET_MAP = { + "BOOST_MODE": PRESET_BOOST, + "COMFORT_MODE": PRESET_COMFORT, + "LOWERING_MODE": PRESET_ECO, +} -HM_CONTROL_MODE = 'CONTROL_MODE' -HMIP_CONTROL_MODE = 'SET_POINT_MODE' +HM_CONTROL_MODE = "CONTROL_MODE" +HMIP_CONTROL_MODE = "SET_POINT_MODE" -SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE +SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_PRESET_MODE def setup_platform(hass, config, add_entities, discovery_info=None): @@ -49,10 +45,10 @@ def setup_platform(hass, config, add_entities, discovery_info=None): new_device = HMThermostat(conf) devices.append(new_device) - add_entities(devices) + add_entities(devices, True) -class HMThermostat(HMDevice, ClimateDevice): +class HMThermostat(HMDevice, ClimateEntity): """Representation of a Homematic thermostat.""" @property @@ -66,40 +62,58 @@ def temperature_unit(self): 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 + def hvac_mode(self): + """Return hvac operation ie. heat, cool mode. + + Need to be one of HVAC_MODE_*. + """ + if self.target_temperature <= self._hmdevice.OFF_VALUE + 0.5: + return HVAC_MODE_OFF + if "MANU_MODE" in self._hmdevice.ACTIONNODE: + if self._hm_control_mode == self._hmdevice.MANU_MODE: + return HVAC_MODE_HEAT + return HVAC_MODE_AUTO + + # Simple devices + if self._data.get("BOOST_MODE"): + return HVAC_MODE_AUTO + return HVAC_MODE_HEAT - # 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'] + @property + def hvac_modes(self): + """Return the list of available hvac operation modes. - # get the name of the mode - name = HM_ATTRIBUTE_SUPPORT[HM_CONTROL_MODE][1][code] - return name.lower() + Need to be a subset of HVAC_MODES. + """ + if "AUTO_MODE" in self._hmdevice.ACTIONNODE: + return [HVAC_MODE_AUTO, HVAC_MODE_HEAT, HVAC_MODE_OFF] + return [HVAC_MODE_HEAT, HVAC_MODE_OFF] @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] + def preset_mode(self): + """Return the current preset mode, e.g., home, away, temp.""" + if self._data.get("BOOST_MODE", False): + return "boost" + + if not self._hm_control_mode: + return None - # HM - op_list = [] + mode = HM_ATTRIBUTE_SUPPORT[HM_CONTROL_MODE][1][self._hm_control_mode] + mode = mode.lower() + + # Filter HVAC states + if mode not in (HVAC_MODE_AUTO, HVAC_MODE_HEAT): + return None + return mode + + @property + def preset_modes(self): + """Return a list of available preset modes.""" + preset_modes = [] for mode in self._hmdevice.ACTIONNODE: - if mode in HM_STATE_MAP: - op_list.append(HM_STATE_MAP.get(mode)) - return op_list + if mode in HM_PRESET_MAP: + preset_modes.append(HM_PRESET_MAP[mode]) + return preset_modes @property def current_humidity(self): @@ -128,31 +142,57 @@ def set_temperature(self, **kwargs): 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 + def set_hvac_mode(self, hvac_mode): + """Set new target hvac mode.""" + if hvac_mode == HVAC_MODE_AUTO: + self._hmdevice.MODE = self._hmdevice.AUTO_MODE + elif hvac_mode == HVAC_MODE_HEAT: + self._hmdevice.MODE = self._hmdevice.MANU_MODE + elif hvac_mode == HVAC_MODE_OFF: + self._hmdevice.turnoff() + + def set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + if preset_mode == PRESET_BOOST: + self._hmdevice.MODE = self._hmdevice.BOOST_MODE + elif preset_mode == PRESET_COMFORT: + self._hmdevice.MODE = self._hmdevice.COMFORT_MODE + elif preset_mode == PRESET_ECO: + self._hmdevice.MODE = self._hmdevice.LOWERING_MODE @property def min_temp(self): - """Return the minimum temperature - 4.5 means off.""" + """Return the minimum temperature.""" return 4.5 @property def max_temp(self): - """Return the maximum temperature - 30.5 means on.""" + """Return the maximum temperature.""" return 30.5 + @property + def target_temperature_step(self): + """Return the supported step of target temperature.""" + return 0.5 + + @property + def _hm_control_mode(self): + """Return Control mode.""" + if HMIP_CONTROL_MODE in self._data: + return self._data[HMIP_CONTROL_MODE] + + # Homematic + return self._data.get("CONTROL_MODE") + 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: + 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(): diff --git a/homeassistant/components/homematic/const.py b/homeassistant/components/homematic/const.py new file mode 100644 index 0000000000000..188ec1e2445f1 --- /dev/null +++ b/homeassistant/components/homematic/const.py @@ -0,0 +1,213 @@ +"""Constants for the homematic component.""" + +DOMAIN = "homematic" + +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_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", + "IPKeySwitchLevel", + "IPMultiIO", + ], + DISCOVER_LIGHTS: [ + "Dimmer", + "KeyDimmer", + "IPKeyDimmer", + "IPDimmer", + "ColorEffectLight", + "IPKeySwitchLevel", + "ColdWarmDimmer", + ], + 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", + "IPContact", + ], + 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" diff --git a/homeassistant/components/homematic/cover.py b/homeassistant/components/homematic/cover.py index 387eb26f433d0..a520c08e4789f 100644 --- a/homeassistant/components/homematic/cover.py +++ b/homeassistant/components/homematic/cover.py @@ -2,10 +2,13 @@ import logging from homeassistant.components.cover import ( - ATTR_POSITION, ATTR_TILT_POSITION, CoverDevice) -from homeassistant.const import STATE_UNKNOWN + ATTR_POSITION, + ATTR_TILT_POSITION, + CoverEntity, +) -from . import ATTR_DISCOVER_DEVICES, HMDevice +from .const import ATTR_DISCOVER_DEVICES +from .entity import HMDevice _LOGGER = logging.getLogger(__name__) @@ -20,10 +23,10 @@ def setup_platform(hass, config, add_entities, discovery_info=None): new_device = HMCover(conf) devices.append(new_device) - add_entities(devices) + add_entities(devices, True) -class HMCover(HMDevice, CoverDevice): +class HMCover(HMDevice, CoverEntity): """Representation a HomeMatic Cover.""" @property @@ -48,6 +51,7 @@ def is_closed(self): """Return if the cover is closed.""" if self.current_cover_position is not None: return self.current_cover_position == 0 + return None def open_cover(self, **kwargs): """Open the cover.""" @@ -64,10 +68,9 @@ def stop_cover(self, **kwargs): def _init_data_struct(self): """Generate a data dictionary (self._data) from metadata.""" self._state = "LEVEL" - self._data.update({self._state: STATE_UNKNOWN}) + self._data.update({self._state: None}) if "LEVEL_2" in self._hmdevice.WRITENODE: - self._data.update( - {'LEVEL_2': STATE_UNKNOWN}) + self._data.update({"LEVEL_2": None}) @property def current_cover_tilt_position(self): @@ -75,10 +78,10 @@ def current_cover_tilt_position(self): None is unknown, 0 is closed, 100 is fully open. """ - if 'LEVEL_2' not in self._data: + if "LEVEL_2" not in self._data: return None - return int(self._data.get('LEVEL_2', 0) * 100) + return int(self._data.get("LEVEL_2", 0) * 100) def set_cover_tilt_position(self, **kwargs): """Move the cover tilt to a specific position.""" diff --git a/homeassistant/components/homematic/entity.py b/homeassistant/components/homematic/entity.py new file mode 100644 index 0000000000000..49d3ee1f1704d --- /dev/null +++ b/homeassistant/components/homematic/entity.py @@ -0,0 +1,284 @@ +"""Homematic base entity.""" +from abc import abstractmethod +from datetime import timedelta +import logging + +from homeassistant.const import ATTR_NAME +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity + +from .const import ( + ATTR_ADDRESS, + ATTR_CHANNEL, + ATTR_INTERFACE, + ATTR_PARAM, + ATTR_UNIQUE_ID, + DATA_HOMEMATIC, + DOMAIN, + HM_ATTRIBUTE_SUPPORT, +) + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL_HUB = timedelta(seconds=300) +SCAN_INTERVAL_VARIABLES = timedelta(seconds=30) + + +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 + self._channel_map = set() + + # 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._subscribe_homematic_events) + + @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 update(self): + """Connect to HomeMatic init values.""" + 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._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.""" + has_changed = False + + # Is data needed for this instance? + if f"{attribute}:{device.partition(':')[2]}" in self._channel_map: + 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 Home Assistant + if has_changed: + self.schedule_update_ha_state() + + def _subscribe_homematic_events(self): + """Subscribe all required events to handle job.""" + 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 + # Remember the channel for this attribute to ignore invalid events later + self._channel_map.add(f"{node}:{channel!s}") + + # Set callbacks + self._hmdevice.setEventCallback(callback=self._hm_event_callback, bequeath=True) + + 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: None}) + + # Initialize device specific data + self._init_data_struct() + + @abstractmethod + def _init_data_struct(self): + """Generate a data dictionary from the HomeMatic device metadata.""" + + +class HMHub(Entity): + """The HomeMatic hub. (CCU2/HomeGear).""" + + def __init__(self, hass, homematic, name): + """Initialize HomeMatic hub.""" + self.hass = hass + self.entity_id = f"{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() diff --git a/homeassistant/components/homematic/light.py b/homeassistant/components/homematic/light.py index f9bc785d3f44c..c7cfcc2ac8c4c 100644 --- a/homeassistant/components/homematic/light.py +++ b/homeassistant/components/homematic/light.py @@ -2,10 +2,20 @@ 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 + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP, + ATTR_EFFECT, + ATTR_HS_COLOR, + ATTR_TRANSITION, + SUPPORT_BRIGHTNESS, + SUPPORT_COLOR, + SUPPORT_COLOR_TEMP, + SUPPORT_EFFECT, + LightEntity, +) + +from .const import ATTR_DISCOVER_DEVICES +from .entity import HMDevice _LOGGER = logging.getLogger(__name__) @@ -22,17 +32,17 @@ def setup_platform(hass, config, add_entities, discovery_info=None): new_device = HMLight(conf) devices.append(new_device) - add_entities(devices) + add_entities(devices, True) -class HMLight(HMDevice, Light): +class HMLight(HMDevice, LightEntity): """Representation of a Homematic light.""" @property def brightness(self): """Return the brightness of this light between 0..255.""" # Is dimmer? - if self._state == 'LEVEL': + if self._state == "LEVEL": return int(self._hm_get_state() * 255) return None @@ -47,17 +57,30 @@ def is_on(self): @property def supported_features(self): """Flag supported features.""" - if 'COLOR' in self._hmdevice.WRITENODE: - return SUPPORT_BRIGHTNESS | SUPPORT_COLOR | SUPPORT_EFFECT - return SUPPORT_BRIGHTNESS + features = SUPPORT_BRIGHTNESS + if "COLOR" in self._hmdevice.WRITENODE: + features |= SUPPORT_COLOR + if "PROGRAM" in self._hmdevice.WRITENODE: + features |= SUPPORT_EFFECT + if hasattr(self._hmdevice, "get_color_temp"): + features |= SUPPORT_COLOR_TEMP + return features @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 + hue, sat = self._hmdevice.get_hs_color(self._channel) + return hue * 360.0, sat * 100.0 + + @property + def color_temp(self): + """Return the color temp in mireds [int].""" + if not self.supported_features & SUPPORT_COLOR_TEMP: + return None + hm_color_temp = self._hmdevice.get_color_temp(self._channel) + return self.max_mireds - (self.max_mireds - self.min_mireds) * hm_color_temp @property def effect_list(self): @@ -76,18 +99,29 @@ def effect(self): 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]) + 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: + elif ( + ATTR_HS_COLOR not in kwargs + and ATTR_COLOR_TEMP 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) + hue=kwargs[ATTR_HS_COLOR][0] / 360.0, + saturation=kwargs[ATTR_HS_COLOR][1] / 100.0, + channel=self._channel, + ) + if ATTR_COLOR_TEMP in kwargs: + hm_temp = (self.max_mireds - kwargs[ATTR_COLOR_TEMP]) / ( + self.max_mireds - self.min_mireds + ) + self._hmdevice.set_color_temp(hm_temp) if ATTR_EFFECT in kwargs: self._hmdevice.set_effect(kwargs[ATTR_EFFECT]) @@ -102,4 +136,6 @@ def _init_data_struct(self): self._data[self._state] = None if self.supported_features & SUPPORT_COLOR: - self._data.update({"COLOR": None, "PROGRAM": None}) + self._data.update({"COLOR": None}) + if self.supported_features & SUPPORT_EFFECT: + self._data.update({"PROGRAM": None}) diff --git a/homeassistant/components/homematic/lock.py b/homeassistant/components/homematic/lock.py index 7f796b32885cb..9a705627fa40f 100644 --- a/homeassistant/components/homematic/lock.py +++ b/homeassistant/components/homematic/lock.py @@ -1,10 +1,10 @@ """Support for Homematic locks.""" import logging -from homeassistant.components.lock import SUPPORT_OPEN, LockDevice -from homeassistant.const import STATE_UNKNOWN +from homeassistant.components.lock import SUPPORT_OPEN, LockEntity -from . import ATTR_DISCOVER_DEVICES, HMDevice +from .const import ATTR_DISCOVER_DEVICES +from .entity import HMDevice _LOGGER = logging.getLogger(__name__) @@ -18,10 +18,10 @@ def setup_platform(hass, config, add_entities, discovery_info=None): for conf in discovery_info[ATTR_DISCOVER_DEVICES]: devices.append(HMLock(conf)) - add_entities(devices) + add_entities(devices, True) -class HMLock(HMDevice, LockDevice): +class HMLock(HMDevice, LockEntity): """Representation of a Homematic lock aka KeyMatic.""" @property @@ -44,7 +44,7 @@ def open(self, **kwargs): def _init_data_struct(self): """Generate the data dictionary (self._data) from metadata.""" self._state = "STATE" - self._data.update({self._state: STATE_UNKNOWN}) + self._data.update({self._state: None}) @property def supported_features(self): diff --git a/homeassistant/components/homematic/manifest.json b/homeassistant/components/homematic/manifest.json index 7c80806cae585..31b26bbd511c5 100644 --- a/homeassistant/components/homematic/manifest.json +++ b/homeassistant/components/homematic/manifest.json @@ -1,13 +1,7 @@ { "domain": "homematic", "name": "Homematic", - "documentation": "https://www.home-assistant.io/components/homematic", - "requirements": [ - "pyhomematic==0.1.58" - ], - "dependencies": [], - "codeowners": [ - "@pvizeli", - "@danielperna84" - ] + "documentation": "https://www.home-assistant.io/integrations/homematic", + "requirements": ["pyhomematic==0.1.66"], + "codeowners": ["@pvizeli", "@danielperna84"] } diff --git a/homeassistant/components/homematic/notify.py b/homeassistant/components/homematic/notify.py index 74ea7095b41d3..3d48adc6df2b6 100644 --- a/homeassistant/components/homematic/notify.py +++ b/homeassistant/components/homematic/notify.py @@ -4,22 +4,34 @@ import voluptuous as vol from homeassistant.components.notify import ( - ATTR_DATA, PLATFORM_SCHEMA, BaseNotificationService) + 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) +from .const 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, -}) + +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): @@ -28,7 +40,7 @@ def get_service(hass, config, discovery_info=None): ATTR_ADDRESS: config[ATTR_ADDRESS], ATTR_CHANNEL: config[ATTR_CHANNEL], ATTR_PARAM: config[ATTR_PARAM], - ATTR_VALUE: config[ATTR_VALUE] + ATTR_VALUE: config[ATTR_VALUE], } if ATTR_INTERFACE in config: data[ATTR_INTERFACE] = config[ATTR_INTERFACE] diff --git a/homeassistant/components/homematic/sensor.py b/homeassistant/components/homematic/sensor.py index fca8c746a49cc..7c4486065b460 100644 --- a/homeassistant/components/homematic/sensor.py +++ b/homeassistant/components/homematic/sensor.py @@ -1,65 +1,83 @@ """Support for HomeMatic sensors.""" import logging -from homeassistant.const import ENERGY_WATT_HOUR, POWER_WATT, STATE_UNKNOWN - -from . import ATTR_DISCOVER_DEVICES, HMDevice +from homeassistant.const import ( + DEGREE, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_ILLUMINANCE, + DEVICE_CLASS_POWER, + DEVICE_CLASS_TEMPERATURE, + ENERGY_WATT_HOUR, + FREQUENCY_HERTZ, + POWER_WATT, + SPEED_KILOMETERS_PER_HOUR, + TEMP_CELSIUS, + UNIT_PERCENTAGE, + VOLT, + VOLUME_CUBIC_METERS, +) + +from .const import ATTR_DISCOVER_DEVICES +from .entity import 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'}, + "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': '#', + "HUMIDITY": UNIT_PERCENTAGE, + "TEMPERATURE": TEMP_CELSIUS, + "ACTUAL_TEMPERATURE": TEMP_CELSIUS, + "BRIGHTNESS": "#", + "POWER": POWER_WATT, + "CURRENT": "mA", + "VOLTAGE": VOLT, + "ENERGY_COUNTER": ENERGY_WATT_HOUR, + "GAS_POWER": VOLUME_CUBIC_METERS, + "GAS_ENERGY_COUNTER": VOLUME_CUBIC_METERS, + "LUX": "lx", + "ILLUMINATION": "lx", + "CURRENT_ILLUMINATION": "lx", + "AVERAGE_ILLUMINATION": "lx", + "LOWEST_ILLUMINATION": "lx", + "HIGHEST_ILLUMINATION": "lx", + "RAIN_COUNTER": "mm", + "WIND_SPEED": SPEED_KILOMETERS_PER_HOUR, + "WIND_DIRECTION": DEGREE, + "WIND_DIRECTION_RANGE": DEGREE, + "SUNSHINEDURATION": "#", + "AIR_PRESSURE": "hPa", + "FREQUENCY": FREQUENCY_HERTZ, + "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', +HM_DEVICE_CLASS_HA_CAST = { + "HUMIDITY": DEVICE_CLASS_HUMIDITY, + "TEMPERATURE": DEVICE_CLASS_TEMPERATURE, + "ACTUAL_TEMPERATURE": DEVICE_CLASS_TEMPERATURE, + "LUX": DEVICE_CLASS_ILLUMINANCE, + "CURRENT_ILLUMINATION": DEVICE_CLASS_ILLUMINANCE, + "AVERAGE_ILLUMINATION": DEVICE_CLASS_ILLUMINANCE, + "LOWEST_ILLUMINATION": DEVICE_CLASS_ILLUMINANCE, + "HIGHEST_ILLUMINATION": DEVICE_CLASS_ILLUMINANCE, + "POWER": DEVICE_CLASS_POWER, + "CURRENT": DEVICE_CLASS_POWER, } +HM_ICON_HA_CAST = {"WIND_SPEED": "mdi:weather-windy", "BRIGHTNESS": "mdi:invert-colors"} + def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the HomeMatic sensor platform.""" @@ -71,7 +89,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): new_device = HMSensor(conf) devices.append(new_device) - add_entities(devices) + add_entities(devices, True) class HMSensor(HMDevice): @@ -83,7 +101,7 @@ def state(self): # 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) + return HM_STATE_HA_CAST[name].get(self._hm_get_state()) # No cast, return original value return self._hm_get_state() @@ -91,16 +109,21 @@ def state(self): @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) + return HM_UNIT_HA_CAST.get(self._state) + + @property + def device_class(self): + """Return the device class to use in the frontend, if any.""" + return HM_DEVICE_CLASS_HA_CAST.get(self._state) @property def icon(self): """Return the icon to use in the frontend, if any.""" - return HM_ICON_HA_CAST.get(self._state, None) + return HM_ICON_HA_CAST.get(self._state) def _init_data_struct(self): """Generate a data dictionary (self._data) from metadata.""" if self._state: - self._data.update({self._state: STATE_UNKNOWN}) + self._data.update({self._state: None}) else: _LOGGER.critical("Unable to initialize sensor: %s", self._name) diff --git a/homeassistant/components/homematic/services.yaml b/homeassistant/components/homematic/services.yaml index 044bcfa46adc7..d2a14101666a8 100644 --- a/homeassistant/components/homematic/services.yaml +++ b/homeassistant/components/homematic/services.yaml @@ -21,10 +21,10 @@ set_variable_value: fields: entity_id: description: Name(s) of homematic central to set value. - example: 'homematic.ccu2' + example: "homematic.ccu2" name: description: Name of the variable to set. - example: 'testvariable' + example: "testvariable" value: description: New value example: 1 @@ -75,11 +75,10 @@ put_paramset: example: wireless address: description: Address of Homematic device - example: LEQ3948571 + example: LEQ3948571:0 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 index b77b3a1f7008b..afde1ac852777 100644 --- a/homeassistant/components/homematic/switch.py +++ b/homeassistant/components/homematic/switch.py @@ -1,10 +1,10 @@ """Support for HomeMatic switches.""" import logging -from homeassistant.components.switch import SwitchDevice -from homeassistant.const import STATE_UNKNOWN +from homeassistant.components.switch import SwitchEntity -from . import ATTR_DISCOVER_DEVICES, HMDevice +from .const import ATTR_DISCOVER_DEVICES +from .entity import HMDevice _LOGGER = logging.getLogger(__name__) @@ -19,10 +19,10 @@ def setup_platform(hass, config, add_entities, discovery_info=None): new_device = HMSwitch(conf) devices.append(new_device) - add_entities(devices) + add_entities(devices, True) -class HMSwitch(HMDevice, SwitchDevice): +class HMSwitch(HMDevice, SwitchEntity): """Representation of a HomeMatic switch.""" @property @@ -55,8 +55,8 @@ def turn_off(self, **kwargs): def _init_data_struct(self): """Generate the data dictionary (self._data) from metadata.""" self._state = "STATE" - self._data.update({self._state: STATE_UNKNOWN}) + self._data.update({self._state: None}) # Need sensor values for SwitchPowermeter for node in self._hmdevice.SENSORNODE: - self._data.update({node: STATE_UNKNOWN}) + self._data.update({node: None}) diff --git a/homeassistant/components/homematicip_cloud/.translations/ca.json b/homeassistant/components/homematicip_cloud/.translations/ca.json deleted file mode 100644 index f7c1497098272..0000000000000 --- a/homeassistant/components/homematicip_cloud/.translations/ca.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "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 deleted file mode 100644 index fa98029f6b0c8..0000000000000 --- a/homeassistant/components/homematicip_cloud/.translations/cs.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "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 deleted file mode 100644 index 4b8371fc748ac..0000000000000 --- a/homeassistant/components/homematicip_cloud/.translations/da.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "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 deleted file mode 100644 index c2a7579e4fcf7..0000000000000 --- a/homeassistant/components/homematicip_cloud/.translations/de.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "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 deleted file mode 100644 index 605bb0d250bba..0000000000000 --- a/homeassistant/components/homematicip_cloud/.translations/en.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "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 deleted file mode 100644 index 5102b25aaee92..0000000000000 --- a/homeassistant/components/homematicip_cloud/.translations/es-419.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "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 deleted file mode 100644 index 206bd05a34596..0000000000000 --- a/homeassistant/components/homematicip_cloud/.translations/es.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "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 deleted file mode 100644 index 7aedd80b5d0b7..0000000000000 --- a/homeassistant/components/homematicip_cloud/.translations/et.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "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 deleted file mode 100644 index 0e724d62bbe2a..0000000000000 --- a/homeassistant/components/homematicip_cloud/.translations/fr.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "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 deleted file mode 100644 index c60294e21d5b0..0000000000000 --- a/homeassistant/components/homematicip_cloud/.translations/he.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "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 deleted file mode 100644 index 61ff5ac5fe255..0000000000000 --- a/homeassistant/components/homematicip_cloud/.translations/hu.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "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 deleted file mode 100644 index 0487434274c58..0000000000000 --- a/homeassistant/components/homematicip_cloud/.translations/id.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "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 deleted file mode 100644 index 6e6d7c8a59fe0..0000000000000 --- a/homeassistant/components/homematicip_cloud/.translations/it.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "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/ko.json b/homeassistant/components/homematicip_cloud/.translations/ko.json deleted file mode 100644 index 2f47fcddf28ff..0000000000000 --- a/homeassistant/components/homematicip_cloud/.translations/ko.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "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 deleted file mode 100644 index 2cad909a7ee54..0000000000000 --- a/homeassistant/components/homematicip_cloud/.translations/lb.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "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 deleted file mode 100644 index ff3e2dea2cdee..0000000000000 --- a/homeassistant/components/homematicip_cloud/.translations/nl.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "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 deleted file mode 100644 index da375563d917d..0000000000000 --- a/homeassistant/components/homematicip_cloud/.translations/nn.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "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 deleted file mode 100644 index 28cfc502aba4e..0000000000000 --- a/homeassistant/components/homematicip_cloud/.translations/no.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "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 deleted file mode 100644 index 7c8714c2c113f..0000000000000 --- a/homeassistant/components/homematicip_cloud/.translations/pl.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "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 deleted file mode 100644 index 82166a1aaaf5d..0000000000000 --- a/homeassistant/components/homematicip_cloud/.translations/pt-BR.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "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 deleted file mode 100644 index 0954f3ff4f9aa..0000000000000 --- a/homeassistant/components/homematicip_cloud/.translations/pt.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "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/ru.json b/homeassistant/components/homematicip_cloud/.translations/ru.json deleted file mode 100644 index 82ecd4a32504f..0000000000000 --- a/homeassistant/components/homematicip_cloud/.translations/ru.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "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 deleted file mode 100644 index cdde0f12d7856..0000000000000 --- a/homeassistant/components/homematicip_cloud/.translations/sl.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "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 deleted file mode 100644 index f155e8fd1c15a..0000000000000 --- a/homeassistant/components/homematicip_cloud/.translations/sv.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "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/zh-Hans.json b/homeassistant/components/homematicip_cloud/.translations/zh-Hans.json deleted file mode 100644 index 4c2b6268eec35..0000000000000 --- a/homeassistant/components/homematicip_cloud/.translations/zh-Hans.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "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 deleted file mode 100644 index d2d334551913c..0000000000000 --- a/homeassistant/components/homematicip_cloud/.translations/zh-Hant.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "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 index 550ba43950b7e..df1f5062fce05 100644 --- a/homeassistant/components/homematicip_cloud/__init__.py +++ b/homeassistant/components/homematicip_cloud/__init__.py @@ -5,69 +5,103 @@ from homeassistant import config_entries from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_NAME -from homeassistant.core import HomeAssistant +from homeassistant.const import CONF_NAME, EVENT_HOMEASSISTANT_STOP from homeassistant.helpers import device_registry as dr import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.typing import ConfigType, HomeAssistantType -from .config_flow import configured_haps from .const import ( - CONF_ACCESSPOINT, CONF_AUTHTOKEN, DOMAIN, HMIPC_AUTHTOKEN, HMIPC_HAPID, - HMIPC_NAME) + CONF_ACCESSPOINT, + CONF_AUTHTOKEN, + DOMAIN, + HMIPC_AUTHTOKEN, + HMIPC_HAPID, + HMIPC_NAME, +) from .device import HomematicipGenericDevice # noqa: F401 from .hap import HomematicipAuth, HomematicipHAP # noqa: F401 +from .services import async_setup_services, async_unload_services _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: +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: HomeAssistantType, 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], - } - )) + if conf[CONF_ACCESSPOINT] not in { + entry.data[HMIPC_HAPID] + for entry in hass.config_entries.async_entries(DOMAIN) + }: + 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: +async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool: """Set up an access point from a config entry.""" + + # 0.104 introduced config entry unique id, this makes upgrading possible + if entry.unique_id is None: + new_data = dict(entry.data) + + hass.config_entries.async_update_entry( + entry, unique_id=new_data[HMIPC_HAPID], data=new_data + ) + hap = HomematicipHAP(hass, entry) - hapid = entry.data[HMIPC_HAPID].replace('-', '').upper() - hass.data[DOMAIN][hapid] = hap + hass.data[DOMAIN][entry.unique_id] = hap if not await hap.async_setup(): return False + await async_setup_services(hass) + + # Register on HA stop event to gracefully shutdown HomematicIP Cloud connection + hap.reset_connection_listener = hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, hap.shutdown + ) + # 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) + hapname = home.label if not home.name else f"{home.name} {home.label}" device_registry.async_get_or_create( config_entry_id=home.id, identifiers={(DOMAIN, home.id)}, - manufacturer='eQ-3', + manufacturer="eQ-3", name=hapname, model=home.modelType, sw_version=home.currentAPVersion, @@ -75,7 +109,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass, entry): +async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool: """Unload a config entry.""" - hap = hass.data[DOMAIN].pop(entry.data[HMIPC_HAPID]) + hap = hass.data[DOMAIN].pop(entry.unique_id) + hap.reset_connection_listener() + + await async_unload_services(hass) + 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 index 1e072c6784c1f..7e06cd6053619 100644 --- a/homeassistant/components/homematicip_cloud/alarm_control_panel.py +++ b/homeassistant/components/homematicip_cloud/alarm_control_panel.py @@ -1,161 +1,120 @@ """Support for HomematicIP Cloud alarm control panel.""" import logging +from typing import Any, Dict -from homematicip.aio.group import AsyncSecurityZoneGroup -from homematicip.aio.home import AsyncHome -from homematicip.base.enums import WindowState +from homematicip.functionalHomes import SecurityAndAlarmHome -from homeassistant.components.alarm_control_panel import AlarmControlPanel +from homeassistant.components.alarm_control_panel import AlarmControlPanelEntity +from homeassistant.components.alarm_control_panel.const import ( + SUPPORT_ALARM_ARM_AWAY, + SUPPORT_ALARM_ARM_HOME, +) 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 + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_HOME, + STATE_ALARM_DISARMED, + STATE_ALARM_TRIGGERED, +) +from homeassistant.core import callback +from homeassistant.helpers.typing import HomeAssistantType -from . import DOMAIN as HMIPC_DOMAIN, HMIPC_HAPID, HomematicipGenericDevice +from . import DOMAIN as HMIPC_DOMAIN +from .hap import HomematicipHAP _LOGGER = logging.getLogger(__name__) -CONST_ALARM_CONTROL_PANEL_NAME = 'HmIP Alarm Control Panel' +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: +async def async_setup_entry( + hass: HomeAssistantType, 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) - # To be removed in a later release. - devices.append(HomematicipSecurityZone(home, group)) - _LOGGER.warning("Homematic IP: alarm_control_panel.%s is " - "deprecated. Please switch to " - "alarm_control_panel.*hmip_alarm_control_panel.", - group.label) - if security_zones: - devices.append(HomematicipAlarmControlPanel(home, security_zones)) - - if devices: - async_add_entities(devices) - - -class HomematicipSecurityZone(HomematicipGenericDevice, AlarmControlPanel): - """Representation of an HomematicIP Cloud security zone group.""" - - def __init__(self, home: AsyncHome, device) -> None: - """Initialize the security zone group.""" - device.modelType = 'Group-SecurityZone' - device.windowState = None - super().__init__(home, device) - - @property - def state(self) -> str: - """Return the state of the device.""" - if self._device.active: - if (self._device.sabotage or self._device.motionDetected or - self._device.windowState == WindowState.OPEN or - self._device.windowState == WindowState.TILTED): - return STATE_ALARM_TRIGGERED - - active = self._home.get_security_zones_activation() - if active == (True, True): - return STATE_ALARM_ARMED_AWAY - if active == (False, True): - return STATE_ALARM_ARMED_HOME + hap = hass.data[HMIPC_DOMAIN][config_entry.unique_id] + async_add_entities([HomematicipAlarmControlPanelEntity(hap)]) - return STATE_ALARM_DISARMED - - 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) - - -class HomematicipAlarmControlPanel(AlarmControlPanel): +class HomematicipAlarmControlPanelEntity(AlarmControlPanelEntity): """Representation of an alarm control panel.""" - def __init__(self, home: AsyncHome, security_zones) -> None: + def __init__(self, hap: HomematicipHAP) -> None: """Initialize the alarm control panel.""" - self._home = home - self.alarm_state = STATE_ALARM_DISARMED + self._home = hap.home + _LOGGER.info("Setting up %s", self.name) - 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 device_info(self) -> Dict[str, Any]: + """Return device specific attributes.""" + return { + "identifiers": {(HMIPC_DOMAIN, f"ACP {self._home.id}")}, + "name": self.name, + "manufacturer": "eQ-3", + "model": CONST_ALARM_CONTROL_PANEL_NAME, + "via_device": (HMIPC_DOMAIN, self._home.id), + } @property def state(self) -> str: """Return the state of the device.""" + # check for triggered alarm + if self._security_and_alarm.alarmActive: + return STATE_ALARM_TRIGGERED + 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) + def _security_and_alarm(self) -> SecurityAndAlarmHome: + return self._home.get_functionalHome(SecurityAndAlarmHome) @property - def _external_alarm_zone_state(self) -> bool: - """Return the state of the device.""" - return _get_zone_alarm_state(self._external_alarm_zone) + def supported_features(self) -> int: + """Return the list of supported features.""" + return SUPPORT_ALARM_ARM_HOME | SUPPORT_ALARM_ARM_AWAY - async def async_alarm_disarm(self, code=None): + async def async_alarm_disarm(self, code=None) -> None: """Send disarm command.""" await self._home.set_security_zones_activation(False, False) - async def async_alarm_arm_home(self, code=None): + async def async_alarm_arm_home(self, code=None) -> None: """Send arm home command.""" await self._home.set_security_zones_activation(False, True) - async def async_alarm_arm_away(self, code=None): + async def async_alarm_arm_away(self, code=None) -> None: """Send arm away command.""" await self._home.set_security_zones_activation(True, True) - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Register callbacks.""" - self._internal_alarm_zone.on_update(self._async_device_changed) - self._external_alarm_zone.on_update(self._async_device_changed) + self._home.on_update(self._async_device_changed) - def _async_device_changed(self, *args, **kwargs): + @callback + def _async_device_changed(self, *args, **kwargs) -> None: """Handle device state changes.""" - _LOGGER.debug("Event %s (%s)", self.name, - CONST_ALARM_CONTROL_PANEL_NAME) - self.async_schedule_update_ha_state() + # Don't update disabled entities + if self.enabled: + _LOGGER.debug("Event %s (%s)", self.name, CONST_ALARM_CONTROL_PANEL_NAME) + self.async_write_ha_state() + else: + _LOGGER.debug( + "Device Changed Event for %s (Alarm Control Panel) not fired. Entity is disabled.", + self.name, + ) @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) + name = f"{self._home.name} {name}" return name @property @@ -166,22 +125,9 @@ def should_poll(self) -> bool: @property def available(self) -> bool: """Device available.""" - return not self._internal_alarm_zone.unreach or \ - not self._external_alarm_zone.unreach + return self._home.connected @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 + return f"{self.__class__.__name__}_{self._home.id}" diff --git a/homeassistant/components/homematicip_cloud/binary_sensor.py b/homeassistant/components/homematicip_cloud/binary_sensor.py index b006ec8068654..15c41be24b53d 100644 --- a/homeassistant/components/homematicip_cloud/binary_sensor.py +++ b/homeassistant/components/homematicip_cloud/binary_sensor.py @@ -1,83 +1,179 @@ """Support for HomematicIP Cloud binary sensor.""" import logging +from typing import Any, Dict from homematicip.aio.device import ( - AsyncDevice, AsyncMotionDetectorIndoor, AsyncMotionDetectorOutdoor, - AsyncMotionDetectorPushButton, AsyncPresenceDetectorIndoor, - AsyncRotaryHandleSensor, AsyncShutterContact, AsyncSmokeDetector, - AsyncWaterSensor, AsyncWeatherSensor, AsyncWeatherSensorPlus, - AsyncWeatherSensorPro) + AsyncAccelerationSensor, + AsyncContactInterface, + AsyncDevice, + AsyncFullFlushContactInterface, + AsyncMotionDetectorIndoor, + AsyncMotionDetectorOutdoor, + AsyncMotionDetectorPushButton, + AsyncPluggableMainsFailureSurveillance, + AsyncPresenceDetectorIndoor, + AsyncRotaryHandleSensor, + AsyncShutterContact, + AsyncShutterContactMagnetic, + 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) + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_DOOR, + DEVICE_CLASS_LIGHT, + DEVICE_CLASS_MOISTURE, + DEVICE_CLASS_MOTION, + DEVICE_CLASS_MOVING, + DEVICE_CLASS_OPENING, + DEVICE_CLASS_POWER, + DEVICE_CLASS_PRESENCE, + DEVICE_CLASS_SAFETY, + DEVICE_CLASS_SMOKE, + BinarySensorEntity, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import HomeAssistantType -from . import DOMAIN as HMIPC_DOMAIN, HMIPC_HAPID, HomematicipGenericDevice -from .device import ATTR_GROUP_MEMBER_UNREACHABLE +from . import DOMAIN as HMIPC_DOMAIN, HomematicipGenericDevice +from .hap import HomematicipHAP _LOGGER = logging.getLogger(__name__) -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: +ATTR_ACCELERATION_SENSOR_MODE = "acceleration_sensor_mode" +ATTR_ACCELERATION_SENSOR_NEUTRAL_POSITION = "acceleration_sensor_neutral_position" +ATTR_ACCELERATION_SENSOR_SENSITIVITY = "acceleration_sensor_sensitivity" +ATTR_ACCELERATION_SENSOR_TRIGGER_ANGLE = "acceleration_sensor_trigger_angle" +ATTR_INTRUSION_ALARM = "intrusion_alarm" +ATTR_MOISTURE_DETECTED = "moisture_detected" +ATTR_MOTION_DETECTED = "motion_detected" +ATTR_POWER_MAINS_FAILURE = "power_mains_failure" +ATTR_PRESENCE_DETECTED = "presence_detected" +ATTR_SMOKE_DETECTOR_ALARM = "smoke_detector_alarm" +ATTR_TODAY_SUNSHINE_DURATION = "today_sunshine_duration_in_minutes" +ATTR_WATER_LEVEL_DETECTED = "water_level_detected" +ATTR_WINDOW_STATE = "window_state" + +GROUP_ATTRIBUTES = { + "moistureDetected": ATTR_MOISTURE_DETECTED, + "motionDetected": ATTR_MOTION_DETECTED, + "powerMainsFailure": ATTR_POWER_MAINS_FAILURE, + "presenceDetected": ATTR_PRESENCE_DETECTED, + "waterlevelDetected": ATTR_WATER_LEVEL_DETECTED, +} + +SAM_DEVICE_ATTRIBUTES = { + "accelerationSensorNeutralPosition": ATTR_ACCELERATION_SENSOR_NEUTRAL_POSITION, + "accelerationSensorMode": ATTR_ACCELERATION_SENSOR_MODE, + "accelerationSensorSensitivity": ATTR_ACCELERATION_SENSOR_SENSITIVITY, + "accelerationSensorTriggerAngle": ATTR_ACCELERATION_SENSOR_TRIGGER_ANGLE, +} + + +async def async_setup_entry( + hass: HomeAssistantType, 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)) + hap = hass.data[HMIPC_DOMAIN][config_entry.unique_id] + entities = [] + for device in hap.home.devices: + if isinstance(device, AsyncAccelerationSensor): + entities.append(HomematicipAccelerationSensor(hap, device)) + if isinstance(device, (AsyncContactInterface, AsyncFullFlushContactInterface)): + entities.append(HomematicipContactInterface(hap, device)) + if isinstance( + device, + (AsyncShutterContact, AsyncShutterContactMagnetic, AsyncRotaryHandleSensor), + ): + entities.append(HomematicipShutterContact(hap, device)) + if isinstance( + device, + ( + AsyncMotionDetectorIndoor, + AsyncMotionDetectorOutdoor, + AsyncMotionDetectorPushButton, + ), + ): + entities.append(HomematicipMotionDetector(hap, device)) + if isinstance(device, AsyncPluggableMainsFailureSurveillance): + entities.append( + HomematicipPluggableMainsFailureSurveillanceSensor(hap, device) + ) if isinstance(device, AsyncPresenceDetectorIndoor): - devices.append(HomematicipPresenceDetector(home, device)) + entities.append(HomematicipPresenceDetector(hap, device)) if isinstance(device, AsyncSmokeDetector): - devices.append(HomematicipSmokeDetector(home, device)) + entities.append(HomematicipSmokeDetector(hap, 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)) + entities.append(HomematicipWaterDetector(hap, device)) + if isinstance(device, (AsyncWeatherSensorPlus, AsyncWeatherSensorPro)): + entities.append(HomematicipRainSensor(hap, device)) + if isinstance( + device, (AsyncWeatherSensor, AsyncWeatherSensorPlus, AsyncWeatherSensorPro) + ): + entities.append(HomematicipStormSensor(hap, device)) + entities.append(HomematicipSunshineSensor(hap, device)) if isinstance(device, AsyncDevice) and device.lowBat is not None: - devices.append(HomematicipBatterySensor(home, device)) + entities.append(HomematicipBatterySensor(hap, device)) - for group in home.groups: + for group in hap.home.groups: if isinstance(group, AsyncSecurityGroup): - devices.append(HomematicipSecuritySensorGroup(home, group)) + entities.append(HomematicipSecuritySensorGroup(hap, group)) elif isinstance(group, AsyncSecurityZoneGroup): - devices.append(HomematicipSecurityZoneSensorGroup(home, group)) + entities.append(HomematicipSecurityZoneSensorGroup(hap, group)) + + if entities: + async_add_entities(entities) + + +class HomematicipAccelerationSensor(HomematicipGenericDevice, BinarySensorEntity): + """Representation of a HomematicIP Cloud acceleration sensor.""" + + @property + def device_class(self) -> str: + """Return the class of this sensor.""" + return DEVICE_CLASS_MOVING + + @property + def is_on(self) -> bool: + """Return true if acceleration is detected.""" + return self._device.accelerationSensorTriggered + + @property + def device_state_attributes(self) -> Dict[str, Any]: + """Return the state attributes of the acceleration sensor.""" + state_attr = super().device_state_attributes + + for attr, attr_key in SAM_DEVICE_ATTRIBUTES.items(): + attr_value = getattr(self._device, attr, None) + if attr_value: + state_attr[attr_key] = attr_value + + return state_attr + + +class HomematicipContactInterface(HomematicipGenericDevice, BinarySensorEntity): + """Representation of a HomematicIP Cloud contact interface.""" - if devices: - async_add_entities(devices) + @property + def device_class(self) -> str: + """Return the class of this sensor.""" + return DEVICE_CLASS_OPENING + + @property + def is_on(self) -> bool: + """Return true if the contact interface is on/open.""" + if self._device.windowState is None: + return None + return self._device.windowState != WindowState.CLOSED -class HomematicipShutterContact(HomematicipGenericDevice, BinarySensorDevice): +class HomematicipShutterContact(HomematicipGenericDevice, BinarySensorEntity): """Representation of a HomematicIP Cloud shutter contact.""" @property @@ -88,14 +184,12 @@ def device_class(self) -> str: @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): +class HomematicipMotionDetector(HomematicipGenericDevice, BinarySensorEntity): """Representation of a HomematicIP Cloud motion detector.""" @property @@ -106,13 +200,10 @@ def device_class(self) -> str: @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): +class HomematicipPresenceDetector(HomematicipGenericDevice, BinarySensorEntity): """Representation of a HomematicIP Cloud presence detector.""" @property @@ -123,12 +214,10 @@ def device_class(self) -> str: @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): +class HomematicipSmokeDetector(HomematicipGenericDevice, BinarySensorEntity): """Representation of a HomematicIP Cloud smoke detector.""" @property @@ -139,11 +228,15 @@ def device_class(self) -> str: @property def is_on(self) -> bool: """Return true if smoke is detected.""" - return (self._device.smokeDetectorAlarmType - != SmokeDetectorAlarmType.IDLE_OFF) + if self._device.smokeDetectorAlarmType: + return ( + self._device.smokeDetectorAlarmType + == SmokeDetectorAlarmType.PRIMARY_ALARM + ) + return False -class HomematicipWaterDetector(HomematicipGenericDevice, BinarySensorDevice): +class HomematicipWaterDetector(HomematicipGenericDevice, BinarySensorEntity): """Representation of a HomematicIP Cloud water detector.""" @property @@ -157,17 +250,17 @@ def is_on(self) -> bool: return self._device.moistureDetected or self._device.waterlevelDetected -class HomematicipStormSensor(HomematicipGenericDevice, BinarySensorDevice): +class HomematicipStormSensor(HomematicipGenericDevice, BinarySensorEntity): """Representation of a HomematicIP Cloud storm sensor.""" - def __init__(self, home: AsyncHome, device) -> None: + def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize storm sensor.""" - super().__init__(home, device, "Storm") + super().__init__(hap, device, "Storm") @property def icon(self) -> str: """Return the icon.""" - return 'mdi:weather-windy' if self.is_on else 'mdi:pinwheel-outline' + return "mdi:weather-windy" if self.is_on else "mdi:pinwheel-outline" @property def is_on(self) -> bool: @@ -175,12 +268,12 @@ def is_on(self) -> bool: return self._device.storm -class HomematicipRainSensor(HomematicipGenericDevice, BinarySensorDevice): +class HomematicipRainSensor(HomematicipGenericDevice, BinarySensorEntity): """Representation of a HomematicIP Cloud rain sensor.""" - def __init__(self, home: AsyncHome, device) -> None: + def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize rain sensor.""" - super().__init__(home, device, "Raining") + super().__init__(hap, device, "Raining") @property def device_class(self) -> str: @@ -193,12 +286,12 @@ def is_on(self) -> bool: return self._device.raining -class HomematicipSunshineSensor(HomematicipGenericDevice, BinarySensorDevice): +class HomematicipSunshineSensor(HomematicipGenericDevice, BinarySensorEntity): """Representation of a HomematicIP Cloud sunshine sensor.""" - def __init__(self, home: AsyncHome, device) -> None: + def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize sunshine sensor.""" - super().__init__(home, device, 'Sunshine') + super().__init__(hap, device, "Sunshine") @property def device_class(self) -> str: @@ -211,22 +304,23 @@ def is_on(self) -> bool: return self._device.sunshine @property - def device_state_attributes(self): + def device_state_attributes(self) -> Dict[str, Any]: """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 + state_attr = super().device_state_attributes + today_sunshine_duration = getattr(self._device, "todaySunshineDuration", None) + if today_sunshine_duration: + state_attr[ATTR_TODAY_SUNSHINE_DURATION] = today_sunshine_duration -class HomematicipBatterySensor(HomematicipGenericDevice, BinarySensorDevice): + return state_attr + + +class HomematicipBatterySensor(HomematicipGenericDevice, BinarySensorEntity): """Representation of a HomematicIP Cloud low battery sensor.""" - def __init__(self, home: AsyncHome, device) -> None: + def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize battery sensor.""" - super().__init__(home, device, 'Battery') + super().__init__(hap, device, "Battery") @property def device_class(self) -> str: @@ -239,15 +333,33 @@ def is_on(self) -> bool: return self._device.lowBat -class HomematicipSecurityZoneSensorGroup(HomematicipGenericDevice, - BinarySensorDevice): +class HomematicipPluggableMainsFailureSurveillanceSensor( + HomematicipGenericDevice, BinarySensorEntity +): + """Representation of a HomematicIP Cloud pluggable mains failure surveillance sensor.""" + + def __init__(self, hap: HomematicipHAP, device) -> None: + """Initialize pluggable mains failure surveillance sensor.""" + super().__init__(hap, device) + + @property + def device_class(self) -> str: + """Return the class of this sensor.""" + return DEVICE_CLASS_POWER + + @property + def is_on(self) -> bool: + """Return true if power mains fails.""" + return not self._device.powerMainsFailure + + +class HomematicipSecurityZoneSensorGroup(HomematicipGenericDevice, BinarySensorEntity): """Representation of a HomematicIP Cloud security zone group.""" - def __init__(self, home: AsyncHome, device, - post: str = 'SecurityZone') -> None: + def __init__(self, hap: HomematicipHAP, device, post: str = "SecurityZone") -> None: """Initialize security zone group.""" - device.modelType = 'HmIP-{}'.format(post) - super().__init__(home, device, post) + device.modelType = f"HmIP-{post}" + super().__init__(hap, device, post) @property def device_class(self) -> str: @@ -262,77 +374,82 @@ def available(self) -> bool: return True @property - def device_state_attributes(self): + def device_state_attributes(self) -> Dict[str, Any]: """Return the state attributes of the security zone group.""" - attr = super().device_state_attributes + state_attr = super().device_state_attributes + + for attr, attr_key in GROUP_ATTRIBUTES.items(): + attr_value = getattr(self._device, attr, None) + if attr_value: + state_attr[attr_key] = attr_value - if self._device.motionDetected: - attr[ATTR_MOTIONDETECTED] = True - if self._device.presenceDetected: - attr[ATTR_PRESENCEDETECTED] = True + window_state = getattr(self._device, "windowState", None) + if window_state and window_state != WindowState.CLOSED: + state_attr[ATTR_WINDOW_STATE] = str(window_state) - 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 + return state_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: + 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: + if ( + self._device.windowState is not None + and self._device.windowState != WindowState.CLOSED + ): return True return False -class HomematicipSecuritySensorGroup(HomematicipSecurityZoneSensorGroup, - BinarySensorDevice): +class HomematicipSecuritySensorGroup( + HomematicipSecurityZoneSensorGroup, BinarySensorEntity +): """Representation of a HomematicIP security group.""" - def __init__(self, home: AsyncHome, device) -> None: + def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize security group.""" - super().__init__(home, device, 'Sensors') + super().__init__(hap, device, "Sensors") @property - def device_state_attributes(self): + def device_state_attributes(self) -> Dict[str, Any]: """Return the state attributes of the security group.""" - attr = super().device_state_attributes + state_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.smokeDetectorAlarmType is not None and \ - self._device.smokeDetectorAlarmType != \ - SmokeDetectorAlarmType.IDLE_OFF: - attr[ATTR_SMOKEDETECTORALARM] = \ - str(self._device.smokeDetectorAlarmType) - - return attr + smoke_detector_at = getattr(self._device, "smokeDetectorAlarmType", None) + if smoke_detector_at: + if smoke_detector_at == SmokeDetectorAlarmType.PRIMARY_ALARM: + state_attr[ATTR_SMOKE_DETECTOR_ALARM] = str(smoke_detector_at) + if smoke_detector_at == SmokeDetectorAlarmType.INTRUSION_ALARM: + state_attr[ATTR_INTRUSION_ALARM] = str(smoke_detector_at) + return state_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: + if parent_is_on: + return True + + if ( + self._device.powerMainsFailure + or self._device.moistureDetected + or self._device.waterlevelDetected + or self._device.lowBat + or self._device.dutyCycle + ): return True - if self._device.smokeDetectorAlarmType is not None and \ - self._device.smokeDetectorAlarmType != \ - SmokeDetectorAlarmType.IDLE_OFF: + + 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 index 3170fc149d53e..3d01f5d69fd31 100644 --- a/homeassistant/components/homematicip_cloud/climate.py +++ b/homeassistant/components/homematicip_cloud/climate.py @@ -1,54 +1,88 @@ """Support for HomematicIP Cloud climate devices.""" import logging +from typing import Any, Dict, List, Optional, Union +from homematicip.aio.device import AsyncHeatingThermostat, AsyncHeatingThermostatCompact from homematicip.aio.group import AsyncHeatingGroup -from homematicip.aio.home import AsyncHome +from homematicip.base.enums import AbsenceType +from homematicip.device import Switch +from homematicip.functionalHomes import IndoorClimateHome -from homeassistant.components.climate import ClimateDevice +from homeassistant.components.climate import ClimateEntity from homeassistant.components.climate.const import ( - STATE_AUTO, STATE_MANUAL, SUPPORT_TARGET_TEMPERATURE) + CURRENT_HVAC_HEAT, + CURRENT_HVAC_IDLE, + HVAC_MODE_AUTO, + HVAC_MODE_COOL, + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + PRESET_AWAY, + PRESET_BOOST, + PRESET_ECO, + PRESET_NONE, + SUPPORT_PRESET_MODE, + SUPPORT_TARGET_TEMPERATURE, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS -from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import HomeAssistantType -from . import DOMAIN as HMIPC_DOMAIN, HMIPC_HAPID, HomematicipGenericDevice +from . import DOMAIN as HMIPC_DOMAIN, HomematicipGenericDevice +from .hap import HomematicipHAP -_LOGGER = logging.getLogger(__name__) - -HA_STATE_TO_HMIP = { - STATE_AUTO: 'AUTOMATIC', - STATE_MANUAL: 'MANUAL', -} +HEATING_PROFILES = {"PROFILE_1": 0, "PROFILE_2": 1, "PROFILE_3": 2} +COOLING_PROFILES = {"PROFILE_4": 3, "PROFILE_5": 4, "PROFILE_6": 5} -HMIP_STATE_TO_HA = {value: key for key, value in HA_STATE_TO_HMIP.items()} +_LOGGER = logging.getLogger(__name__) +ATTR_PRESET_END_TIME = "preset_end_time" +PERMANENT_END_TIME = "permanent" -async def async_setup_platform( - hass, config, async_add_entities, discovery_info=None): - """Set up the HomematicIP Cloud climate devices.""" - pass +HMIP_AUTOMATIC_CM = "AUTOMATIC" +HMIP_MANUAL_CM = "MANUAL" +HMIP_ECO_CM = "ECO" -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities) -> None: +async def async_setup_entry( + hass: HomeAssistantType, 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: + hap = hass.data[HMIPC_DOMAIN][config_entry.unique_id] + entities = [] + for device in hap.home.groups: if isinstance(device, AsyncHeatingGroup): - devices.append(HomematicipHeatingGroup(home, device)) + entities.append(HomematicipHeatingGroup(hap, device)) + + if entities: + async_add_entities(entities) - if devices: - async_add_entities(devices) +class HomematicipHeatingGroup(HomematicipGenericDevice, ClimateEntity): + """Representation of a HomematicIP heating group. -class HomematicipHeatingGroup(HomematicipGenericDevice, ClimateDevice): - """Representation of a HomematicIP heating group.""" + Heat mode is supported for all heating devices incl. their defined profiles. + Boost is available for radiator thermostats only. + Cool mode is only available for floor heating systems, if basically enabled in the hmip app. + """ - def __init__(self, home: AsyncHome, device) -> None: + def __init__(self, hap: HomematicipHAP, device: AsyncHeatingGroup) -> None: """Initialize heating group.""" - device.modelType = 'Group-Heating' - super().__init__(home, device) + device.modelType = "HmIP-Heating-Group" + super().__init__(hap, device) + self._simple_heating = None + if device.actualTemperature is None: + self._simple_heating = self._first_radiator_thermostat + + @property + def device_info(self) -> Dict[str, Any]: + """Return device specific attributes.""" + return { + "identifiers": {(HMIPC_DOMAIN, self._device.id)}, + "name": self._device.label, + "manufacturer": "eQ-3", + "model": self._device.modelType, + "via_device": (HMIPC_DOMAIN, self._device.homeId), + } @property def temperature_unit(self) -> str: @@ -58,7 +92,7 @@ def temperature_unit(self) -> str: @property def supported_features(self) -> int: """Return the list of supported features.""" - return SUPPORT_TARGET_TEMPERATURE + return SUPPORT_PRESET_MODE | SUPPORT_TARGET_TEMPERATURE @property def target_temperature(self) -> float: @@ -68,6 +102,8 @@ def target_temperature(self) -> float: @property def current_temperature(self) -> float: """Return the current temperature.""" + if self._simple_heating: + return self._simple_heating.valveActualTemperature return self._device.actualTemperature @property @@ -76,9 +112,88 @@ def current_humidity(self) -> int: 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) + def hvac_mode(self) -> str: + """Return hvac operation ie.""" + if self._disabled_by_cooling_mode and not self._has_switch: + return HVAC_MODE_OFF + if self._device.boostMode: + return HVAC_MODE_HEAT + if self._device.controlMode == HMIP_MANUAL_CM: + return HVAC_MODE_HEAT if self._heat_mode_enabled else HVAC_MODE_COOL + + return HVAC_MODE_AUTO + + @property + def hvac_modes(self) -> List[str]: + """Return the list of available hvac operation modes.""" + if self._disabled_by_cooling_mode and not self._has_switch: + return [HVAC_MODE_OFF] + + return ( + [HVAC_MODE_AUTO, HVAC_MODE_HEAT] + if self._heat_mode_enabled + else [HVAC_MODE_AUTO, HVAC_MODE_COOL] + ) + + @property + def hvac_action(self) -> Optional[str]: + """ + Return the current hvac_action. + + This is only relevant for radiator thermostats. + """ + if ( + self._device.floorHeatingMode == "RADIATOR" + and self._has_radiator_thermostat + and self._heat_mode_enabled + ): + return ( + CURRENT_HVAC_HEAT if self._device.valvePosition else CURRENT_HVAC_IDLE + ) + + return None + + @property + def preset_mode(self) -> Optional[str]: + """Return the current preset mode.""" + if self._device.boostMode: + return PRESET_BOOST + if self.hvac_mode in (HVAC_MODE_COOL, HVAC_MODE_HEAT, HVAC_MODE_OFF): + return PRESET_NONE + if self._device.controlMode == HMIP_ECO_CM: + if self._indoor_climate.absenceType == AbsenceType.VACATION: + return PRESET_AWAY + if self._indoor_climate.absenceType in [ + AbsenceType.PARTY, + AbsenceType.PERIOD, + AbsenceType.PERMANENT, + ]: + return PRESET_ECO + + return ( + self._device.activeProfile.name + if self._device.activeProfile.name in self._device_profile_names + else None + ) + + @property + def preset_modes(self) -> List[str]: + """Return a list of available preset modes incl. hmip profiles.""" + # Boost is only available if a radiator thermostat is in the room, + # and heat mode is enabled. + profile_names = self._device_profile_names + + presets = [] + if ( + self._heat_mode_enabled and self._has_radiator_thermostat + ) or self._has_switch: + if not profile_names: + presets.append(PRESET_NONE) + presets.append(PRESET_BOOST) + + presets.extend(profile_names) + + return presets @property def min_temp(self) -> float: @@ -90,9 +205,132 @@ def max_temp(self) -> float: """Return the maximum temperature.""" return self._device.maxTemperature - async def async_set_temperature(self, **kwargs): + async def async_set_temperature(self, **kwargs) -> None: """Set new target temperature.""" temperature = kwargs.get(ATTR_TEMPERATURE) if temperature is None: return - await self._device.set_point_temperature(temperature) + + if self.min_temp <= temperature <= self.max_temp: + await self._device.set_point_temperature(temperature) + + async def async_set_hvac_mode(self, hvac_mode: str) -> None: + """Set new target hvac mode.""" + if hvac_mode not in self.hvac_modes: + return + + if hvac_mode == HVAC_MODE_AUTO: + await self._device.set_control_mode(HMIP_AUTOMATIC_CM) + else: + await self._device.set_control_mode(HMIP_MANUAL_CM) + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + if preset_mode not in self.preset_modes: + return + + if self._device.boostMode and preset_mode != PRESET_BOOST: + await self._device.set_boost(False) + if preset_mode == PRESET_BOOST: + await self._device.set_boost() + if preset_mode in self._device_profile_names: + profile_idx = self._get_profile_idx_by_name(preset_mode) + if self._device.controlMode != HMIP_AUTOMATIC_CM: + await self.async_set_hvac_mode(HVAC_MODE_AUTO) + await self._device.set_active_profile(profile_idx) + + @property + def device_state_attributes(self) -> Dict[str, Any]: + """Return the state attributes of the access point.""" + state_attr = super().device_state_attributes + + if self._device.controlMode == HMIP_ECO_CM: + if self._indoor_climate.absenceType in [ + AbsenceType.PARTY, + AbsenceType.PERIOD, + AbsenceType.VACATION, + ]: + state_attr[ATTR_PRESET_END_TIME] = self._indoor_climate.absenceEndTime + elif self._indoor_climate.absenceType == AbsenceType.PERMANENT: + state_attr[ATTR_PRESET_END_TIME] = PERMANENT_END_TIME + + return state_attr + + @property + def _indoor_climate(self) -> IndoorClimateHome: + """Return the hmip indoor climate functional home of this group.""" + return self._home.get_functionalHome(IndoorClimateHome) + + @property + def _device_profiles(self) -> List[str]: + """Return the relevant profiles.""" + return [ + profile + for profile in self._device.profiles + if profile.visible + and profile.name != "" + and profile.index in self._relevant_profile_group + ] + + @property + def _device_profile_names(self) -> List[str]: + """Return a collection of profile names.""" + return [profile.name for profile in self._device_profiles] + + def _get_profile_idx_by_name(self, profile_name: str) -> int: + """Return a profile index by name.""" + relevant_index = self._relevant_profile_group + index_name = [ + profile.index + for profile in self._device_profiles + if profile.name == profile_name + ] + + return relevant_index[index_name[0]] + + @property + def _heat_mode_enabled(self) -> bool: + """Return, if heating mode is enabled.""" + return not self._device.cooling + + @property + def _disabled_by_cooling_mode(self) -> bool: + """Return, if group is disabled by the cooling mode.""" + return self._device.cooling and ( + self._device.coolingIgnored or not self._device.coolingAllowed + ) + + @property + def _relevant_profile_group(self) -> List[str]: + """Return the relevant profile groups.""" + if self._disabled_by_cooling_mode: + return [] + + return HEATING_PROFILES if self._heat_mode_enabled else COOLING_PROFILES + + @property + def _has_switch(self) -> bool: + """Return, if a switch is in the hmip heating group.""" + for device in self._device.devices: + if isinstance(device, Switch): + return True + + return False + + @property + def _has_radiator_thermostat(self) -> bool: + """Return, if a radiator thermostat is in the hmip heating group.""" + return bool(self._first_radiator_thermostat) + + @property + def _first_radiator_thermostat( + self, + ) -> Optional[Union[AsyncHeatingThermostat, AsyncHeatingThermostatCompact]]: + """Return the first radiator thermostat from the hmip heating group.""" + for device in self._device.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 index 696425df5b5ac..547289f871a08 100644 --- a/homeassistant/components/homematicip_cloud/config_flow.py +++ b/homeassistant/components/homematicip_cloud/config_flow.py @@ -1,24 +1,21 @@ """Config flow to configure the HomematicIP Cloud component.""" -from typing import Set +from typing import Any, Dict 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) + _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.""" @@ -26,23 +23,23 @@ class HomematicipCloudFlowHandler(config_entries.ConfigFlow): VERSION = 1 CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_PUSH - def __init__(self): + def __init__(self) -> None: """Initialize HomematicIP Cloud config flow.""" self.auth = None - async def async_step_user(self, user_input=None): + async def async_step_user(self, user_input=None) -> Dict[str, Any]: """Handle a flow initialized by the user.""" return await self.async_step_init(user_input) - async def async_step_init(self, user_input=None): + async def async_step_init(self, user_input=None) -> Dict[str, Any]: """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') + user_input[HMIPC_HAPID] = user_input[HMIPC_HAPID].replace("-", "").upper() + + await self.async_set_unique_id(user_input[HMIPC_HAPID]) + self._abort_if_unique_id_configured() self.auth = HomematicipAuth(self.hass, user_input) connected = await self.auth.async_setup() @@ -51,16 +48,18 @@ async def async_step_init(self, user_input=None): 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 + 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): + async def async_step_link(self, user_input=None) -> Dict[str, Any]: """Attempt to link with the HomematicIP Cloud access point.""" errors = {} @@ -74,30 +73,25 @@ async def async_step_link(self, user_input=None): 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' + 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) + return self.async_show_form(step_id="link", errors=errors) - async def async_step_import(self, import_info): + async def async_step_import(self, import_info) -> Dict[str, Any]: """Import a new access point as a config entry.""" - hapid = import_info[HMIPC_HAPID] + hapid = import_info[HMIPC_HAPID].replace("-", "").upper() 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') + await self.async_set_unique_id(hapid) + self._abort_if_unique_id_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, - } + 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 index c9a5df601e4d2..5c48de975f97c 100644 --- a/homeassistant/components/homematicip_cloud/const.py +++ b/homeassistant/components/homematicip_cloud/const.py @@ -1,25 +1,25 @@ """Constants for the HomematicIP Cloud component.""" import logging -_LOGGER = logging.getLogger('.') +_LOGGER = logging.getLogger(".") -DOMAIN = 'homematicip_cloud' +DOMAIN = "homematicip_cloud" COMPONENTS = [ - 'alarm_control_panel', - 'binary_sensor', - 'climate', - 'cover', - 'light', - 'sensor', - 'switch', - 'weather', + "alarm_control_panel", + "binary_sensor", + "climate", + "cover", + "light", + "sensor", + "switch", + "weather", ] -CONF_ACCESSPOINT = 'accesspoint' -CONF_AUTHTOKEN = 'authtoken' +CONF_ACCESSPOINT = "accesspoint" +CONF_AUTHTOKEN = "authtoken" -HMIPC_NAME = 'name' -HMIPC_HAPID = 'hapid' -HMIPC_AUTHTOKEN = 'authtoken' -HMIPC_PIN = 'pin' +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 index fc75d78119d55..580e2d21a1173 100644 --- a/homeassistant/components/homematicip_cloud/cover.py +++ b/homeassistant/components/homematicip_cloud/cover.py @@ -2,48 +2,66 @@ import logging from typing import Optional -from homematicip.aio.device import AsyncFullFlushShutter - -from homeassistant.components.cover import ATTR_POSITION, CoverDevice +from homematicip.aio.device import ( + AsyncFullFlushBlind, + AsyncFullFlushShutter, + AsyncGarageDoorModuleTormatic, +) +from homematicip.aio.group import AsyncExtendedLinkedShutterGroup +from homematicip.base.enums import DoorCommand, DoorState + +from homeassistant.components.cover import ( + ATTR_POSITION, + ATTR_TILT_POSITION, + CoverEntity, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import HomeAssistantType -from . import DOMAIN as HMIPC_DOMAIN, HMIPC_HAPID, HomematicipGenericDevice +from . import DOMAIN as HMIPC_DOMAIN, HomematicipGenericDevice +from .hap import HomematicipHAP _LOGGER = logging.getLogger(__name__) HMIP_COVER_OPEN = 0 HMIP_COVER_CLOSED = 1 +HMIP_SLATS_OPEN = 0 +HMIP_SLATS_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: +async def async_setup_entry( + hass: HomeAssistantType, 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)) + hap = hass.data[HMIPC_DOMAIN][config_entry.unique_id] + entities = [] + for device in hap.home.devices: + if isinstance(device, AsyncFullFlushBlind): + entities.append(HomematicipCoverSlats(hap, device)) + elif isinstance(device, AsyncFullFlushShutter): + entities.append(HomematicipCoverShutter(hap, device)) + elif isinstance(device, AsyncGarageDoorModuleTormatic): + entities.append(HomematicipGarageDoorModuleTormatic(hap, device)) - if devices: - async_add_entities(devices) + for group in hap.home.groups: + if isinstance(group, AsyncExtendedLinkedShutterGroup): + entities.append(HomematicipCoverShutterGroup(hap, group)) + if entities: + async_add_entities(entities) -class HomematicipCoverShutter(HomematicipGenericDevice, CoverDevice): - """Representation of a HomematicIP Cloud cover device.""" + +class HomematicipCoverShutter(HomematicipGenericDevice, CoverEntity): + """Representation of a HomematicIP Cloud cover shutter device.""" @property def current_cover_position(self) -> int: """Return current position of cover.""" - return int((1 - self._device.shutterLevel) * 100) + if self._device.shutterLevel is not None: + return int((1 - self._device.shutterLevel) * 100) + return None - async def async_set_cover_position(self, **kwargs): + async def async_set_cover_position(self, **kwargs) -> None: """Move the cover to a specific position.""" position = kwargs[ATTR_POSITION] # HmIP cover is closed:1 -> open:0 @@ -57,14 +75,85 @@ def is_closed(self) -> Optional[bool]: return self._device.shutterLevel == HMIP_COVER_CLOSED return None - async def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs) -> None: """Open the cover.""" await self._device.set_shutter_level(HMIP_COVER_OPEN) - async def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs) -> None: """Close the cover.""" await self._device.set_shutter_level(HMIP_COVER_CLOSED) - async def async_stop_cover(self, **kwargs): + async def async_stop_cover(self, **kwargs) -> None: + """Stop the device if in motion.""" + await self._device.set_shutter_stop() + + +class HomematicipCoverSlats(HomematicipCoverShutter, CoverEntity): + """Representation of a HomematicIP Cloud cover slats device.""" + + @property + def current_cover_tilt_position(self) -> int: + """Return current tilt position of cover.""" + if self._device.slatsLevel is not None: + return int((1 - self._device.slatsLevel) * 100) + return None + + async def async_set_cover_tilt_position(self, **kwargs) -> None: + """Move the cover to a specific tilt position.""" + position = kwargs[ATTR_TILT_POSITION] + # HmIP slats is closed:1 -> open:0 + level = 1 - position / 100.0 + await self._device.set_slats_level(level) + + async def async_open_cover_tilt(self, **kwargs) -> None: + """Open the slats.""" + await self._device.set_slats_level(HMIP_SLATS_OPEN) + + async def async_close_cover_tilt(self, **kwargs) -> None: + """Close the slats.""" + await self._device.set_slats_level(HMIP_SLATS_CLOSED) + + async def async_stop_cover_tilt(self, **kwargs) -> None: """Stop the device if in motion.""" await self._device.set_shutter_stop() + + +class HomematicipGarageDoorModuleTormatic(HomematicipGenericDevice, CoverEntity): + """Representation of a HomematicIP Garage Door Module for Tormatic.""" + + @property + def current_cover_position(self) -> int: + """Return current position of cover.""" + door_state_to_position = { + DoorState.CLOSED: 0, + DoorState.OPEN: 100, + DoorState.VENTILATION_POSITION: 10, + DoorState.POSITION_UNKNOWN: None, + } + return door_state_to_position.get(self._device.doorState) + + @property + def is_closed(self) -> Optional[bool]: + """Return if the cover is closed.""" + return self._device.doorState == DoorState.CLOSED + + async def async_open_cover(self, **kwargs) -> None: + """Open the cover.""" + await self._device.send_door_command(DoorCommand.OPEN) + + async def async_close_cover(self, **kwargs) -> None: + """Close the cover.""" + await self._device.send_door_command(DoorCommand.CLOSE) + + async def async_stop_cover(self, **kwargs) -> None: + """Stop the cover.""" + await self._device.send_door_command(DoorCommand.STOP) + + +class HomematicipCoverShutterGroup(HomematicipCoverSlats, CoverEntity): + """Representation of a HomematicIP Cloud cover shutter group.""" + + def __init__(self, hap: HomematicipHAP, device, post: str = "ShutterGroup") -> None: + """Initialize switching group.""" + device.modelType = f"HmIP-{post}" + super().__init__(hap, device, post) diff --git a/homeassistant/components/homematicip_cloud/device.py b/homeassistant/components/homematicip_cloud/device.py index 2c77d225263ca..c2b67758152a6 100644 --- a/homeassistant/components/homematicip_cloud/device.py +++ b/homeassistant/components/homematicip_cloud/device.py @@ -1,71 +1,182 @@ """Generic device for the HomematicIP Cloud component.""" import logging -from typing import Optional +from typing import Any, Dict, Optional from homematicip.aio.device import AsyncDevice -from homematicip.aio.home import AsyncHome +from homematicip.aio.group import AsyncGroup -from homeassistant.components import homematicip_cloud +from homeassistant.core import callback +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity import Entity +from .const import DOMAIN as HMIPC_DOMAIN +from .hap import HomematicipHAP + _LOGGER = logging.getLogger(__name__) -ATTR_LOW_BATTERY = 'low_battery' -ATTR_MODEL_TYPE = 'model_type' +ATTR_MODEL_TYPE = "model_type" +ATTR_LOW_BATTERY = "low_battery" +ATTR_CONFIG_PENDING = "config_pending" +ATTR_DUTY_CYCLE_REACHED = "duty_cycle_reached" +ATTR_ID = "id" +ATTR_IS_GROUP = "is_group" # RSSI HAP -> Device -ATTR_RSSI_DEVICE = 'rssi_device' +ATTR_RSSI_DEVICE = "rssi_device" # RSSI Device -> HAP -ATTR_RSSI_PEER = 'rssi_peer' -ATTR_SABOTAGE = 'sabotage' -ATTR_GROUP_MEMBER_UNREACHABLE = 'group_member_unreachable' +ATTR_RSSI_PEER = "rssi_peer" +ATTR_SABOTAGE = "sabotage" +ATTR_GROUP_MEMBER_UNREACHABLE = "group_member_unreachable" +ATTR_DEVICE_OVERHEATED = "device_overheated" +ATTR_DEVICE_OVERLOADED = "device_overloaded" +ATTR_DEVICE_UNTERVOLTAGE = "device_undervoltage" +ATTR_EVENT_DELAY = "event_delay" + +DEVICE_ATTRIBUTE_ICONS = { + "lowBat": "mdi:battery-outline", + "sabotage": "mdi:shield-alert", + "dutyCycle": "mdi:alert", + "deviceOverheated": "mdi:alert", + "deviceOverloaded": "mdi:alert", + "deviceUndervoltage": "mdi:alert", + "configPending": "mdi:alert-circle", +} + +DEVICE_ATTRIBUTES = { + "modelType": ATTR_MODEL_TYPE, + "sabotage": ATTR_SABOTAGE, + "dutyCycle": ATTR_DUTY_CYCLE_REACHED, + "rssiDeviceValue": ATTR_RSSI_DEVICE, + "rssiPeerValue": ATTR_RSSI_PEER, + "deviceOverheated": ATTR_DEVICE_OVERHEATED, + "deviceOverloaded": ATTR_DEVICE_OVERLOADED, + "deviceUndervoltage": ATTR_DEVICE_UNTERVOLTAGE, + "configPending": ATTR_CONFIG_PENDING, + "eventDelay": ATTR_EVENT_DELAY, + "id": ATTR_ID, +} + +GROUP_ATTRIBUTES = { + "modelType": ATTR_MODEL_TYPE, + "lowBat": ATTR_LOW_BATTERY, + "sabotage": ATTR_SABOTAGE, + "dutyCycle": ATTR_DUTY_CYCLE_REACHED, + "configPending": ATTR_CONFIG_PENDING, + "unreach": ATTR_GROUP_MEMBER_UNREACHABLE, +} class HomematicipGenericDevice(Entity): """Representation of an HomematicIP generic device.""" - def __init__(self, home: AsyncHome, device, - post: Optional[str] = None) -> None: + def __init__(self, hap: HomematicipHAP, device, post: Optional[str] = None) -> None: """Initialize the generic device.""" - self._home = home + self._hap = hap + self._home = hap.home self._device = device self.post = post + # Marker showing that the HmIP device hase been removed. + self.hmip_device_removed = False _LOGGER.info("Setting up %s (%s)", self.name, self._device.modelType) @property - def device_info(self): + def device_info(self) -> Dict[str, Any]: """Return device specific attributes.""" # Only physical devices should be HA devices. if isinstance(self._device, AsyncDevice): return { - 'identifiers': { + "identifiers": { # Serial numbers of Homematic IP device - (homematicip_cloud.DOMAIN, self._device.id) + (HMIPC_DOMAIN, self._device.id) }, - 'name': self._device.label, - 'manufacturer': self._device.oem, - 'model': self._device.modelType, - 'sw_version': self._device.firmwareVersion, - 'via_hub': (homematicip_cloud.DOMAIN, self._device.homeId), + "name": self._device.label, + "manufacturer": self._device.oem, + "model": self._device.modelType, + "sw_version": self._device.firmwareVersion, + "via_device": (HMIPC_DOMAIN, self._device.homeId), } return None - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Register callbacks.""" + self._hap.hmip_device_by_entity_id[self.entity_id] = self._device self._device.on_update(self._async_device_changed) + self._device.on_remove(self._async_device_removed) - def _async_device_changed(self, *args, **kwargs): + @callback + def _async_device_changed(self, *args, **kwargs) -> None: """Handle device state changes.""" - _LOGGER.debug("Event %s (%s)", self.name, self._device.modelType) - self.async_schedule_update_ha_state() + # Don't update disabled entities + if self.enabled: + _LOGGER.debug("Event %s (%s)", self.name, self._device.modelType) + self.async_write_ha_state() + else: + _LOGGER.debug( + "Device Changed Event for %s (%s) not fired. Entity is disabled.", + self.name, + self._device.modelType, + ) + + async def async_will_remove_from_hass(self) -> None: + """Run when entity will be removed from hass.""" + + # Only go further if the device/entity should be removed from registries + # due to a removal of the HmIP device. + + if self.hmip_device_removed: + try: + del self._hap.hmip_device_by_entity_id[self.entity_id] + await self.async_remove_from_registries() + except KeyError as err: + _LOGGER.debug("Error removing HMIP entity from registry: %s", err) + + async def async_remove_from_registries(self) -> None: + """Remove entity/device from registry.""" + + # Remove callback from device. + self._device.remove_callback(self._async_device_changed) + self._device.remove_callback(self._async_device_removed) + + if not self.registry_entry: + return + + device_id = self.registry_entry.device_id + if device_id: + # Remove from device registry. + device_registry = await dr.async_get_registry(self.hass) + if device_id in device_registry.devices: + # This will also remove associated entities from entity registry. + device_registry.async_remove_device(device_id) + else: + # Remove from entity registry. + # Only relevant for entities that do not belong to a device. + entity_id = self.registry_entry.entity_id + if entity_id: + entity_registry = await er.async_get_registry(self.hass) + if entity_id in entity_registry.entities: + entity_registry.async_remove(entity_id) + + @callback + def _async_device_removed(self, *args, **kwargs) -> None: + """Handle hmip device removal.""" + # Set marker showing that the HmIP device hase been removed. + self.hmip_device_removed = True + self.hass.async_create_task(self.async_remove()) @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) + if name and self._home.name: + name = f"{self._home.name} {name}" + if name and self.post: + name = f"{name} {self.post}" + return name + + def _get_label_by_channel(self, channel: int) -> str: + """Return the name of the channel.""" + name = self._device.functionalChannels[channel].label + if name and self._home.name: + name = f"{self._home.name} {name}" return name @property @@ -81,29 +192,36 @@ def available(self) -> bool: @property def unique_id(self) -> str: """Return a unique ID.""" - return "{}_{}".format(self.__class__.__name__, self._device.id) + return f"{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' + for attr, icon in DEVICE_ATTRIBUTE_ICONS.items(): + if getattr(self._device, attr, None): + return icon + return None @property - def device_state_attributes(self): + def device_state_attributes(self) -> Dict[str, Any]: """Return the state attributes of the generic device.""" - attr = {ATTR_MODEL_TYPE: self._device.modelType} - if hasattr(self._device, 'lowBat') and self._device.lowBat: - attr[ATTR_LOW_BATTERY] = self._device.lowBat - 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 + state_attr = {} + + if isinstance(self._device, AsyncDevice): + for attr, attr_key in DEVICE_ATTRIBUTES.items(): + attr_value = getattr(self._device, attr, None) + if attr_value: + state_attr[attr_key] = attr_value + + state_attr[ATTR_IS_GROUP] = False + + if isinstance(self._device, AsyncGroup): + for attr, attr_key in GROUP_ATTRIBUTES.items(): + attr_value = getattr(self._device, attr, None) + if attr_value: + state_attr[attr_key] = attr_value + + state_attr[ATTR_IS_GROUP] = True + + return state_attr diff --git a/homeassistant/components/homematicip_cloud/hap.py b/homeassistant/components/homematicip_cloud/hap.py index b3731bc9f1af5..dd85827f1ae39 100644 --- a/homeassistant/components/homematicip_cloud/hap.py +++ b/homeassistant/components/homematicip_cloud/hap.py @@ -5,14 +5,15 @@ from homematicip.aio.auth import AsyncAuth from homematicip.aio.home import AsyncHome from homematicip.base.base_connection import HmipConnectionError +from homematicip.base.enums import EventType from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.typing import HomeAssistantType -from .const import ( - COMPONENTS, HMIPC_AUTHTOKEN, HMIPC_HAPID, HMIPC_NAME, HMIPC_PIN) +from .const import COMPONENTS, HMIPC_AUTHTOKEN, HMIPC_HAPID, HMIPC_NAME, HMIPC_PIN from .errors import HmipcConnectionError _LOGGER = logging.getLogger(__name__) @@ -21,25 +22,23 @@ class HomematicipAuth: """Manages HomematicIP client registration.""" - def __init__(self, hass, config): + def __init__(self, hass, config) -> None: """Initialize HomematicIP Cloud client registration.""" self.hass = hass self.config = config self.auth = None - async def async_setup(self): + async def async_setup(self) -> bool: """Connect to HomematicIP for registration.""" try: self.auth = await self.get_auth( - self.hass, - self.config.get(HMIPC_HAPID), - self.config.get(HMIPC_PIN) + self.hass, self.config.get(HMIPC_HAPID), self.config.get(HMIPC_PIN) ) return True except HmipcConnectionError: return False - async def async_checkbutton(self): + async def async_checkbutton(self) -> bool: """Check blue butten has been pressed.""" try: return await self.auth.isRequestAcknowledged() @@ -55,14 +54,14 @@ async def async_register(self): except HmipConnectionError: return False - async def get_auth(self, hass, hapid, pin): + async def get_auth(self, hass: HomeAssistantType, 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') + await auth.connectionRequest("HomeAssistant") except HmipConnectionError: return False return auth @@ -71,7 +70,7 @@ async def get_auth(self, hass, hapid, pin): class HomematicipHAP: """Manages HomematicIP HTTP and WebSocket connection.""" - def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistantType, config_entry: ConfigEntry) -> None: """Initialize HomematicIP Cloud connection.""" self.hass = hass self.config_entry = config_entry @@ -81,43 +80,52 @@ def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: self._retry_task = None self._tries = 0 self._accesspoint_connected = True + self.hmip_device_by_entity_id = {} + self.reset_connection_listener = None - async def async_setup(self, tries: int = 0): + async def async_setup(self, tries: int = 0) -> bool: """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) + self.config_entry.data.get(HMIPC_NAME), ) except HmipcConnectionError: raise ConfigEntryNotReady + except Exception as err: # pylint: disable=broad-except + _LOGGER.error("Error connecting with HomematicIP Cloud: %s", err) + return False - _LOGGER.info("Connected to HomematicIP with HAP %s", - self.config_entry.data.get(HMIPC_HAPID)) + _LOGGER.info( + "Connected to HomematicIP with HAP %s", self.config_entry.unique_id + ) for component in COMPONENTS: self.hass.async_create_task( self.hass.config_entries.async_forward_entry_setup( - self.config_entry, component) + self.config_entry, component + ) ) return True @callback - def async_update(self, *args, **kwargs): + def async_update(self, *args, **kwargs) -> None: """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 + 1. We are 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. + 2. We need to update home including devices and groups after a reconnect. + 3. We need to update home without devices and groups in all other cases. + """ if not self.home.connected: - _LOGGER.error( - "HMIP access point has lost connection with the cloud") + _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: @@ -128,35 +136,47 @@ def async_update(self, *args, **kwargs): job = self.hass.async_create_task(self.get_state()) job.add_done_callback(self.get_state_finished) + self._accesspoint_connected = True - async def get_state(self): + @callback + def async_create_entity(self, *args, **kwargs) -> None: + """Create a device or a group.""" + is_device = EventType(kwargs["event_type"]) == EventType.DEVICE_ADDED + self.hass.async_create_task(self.async_create_entity_lazy(is_device)) + + async def async_create_entity_lazy(self, is_device=True) -> None: + """Delay entity creation to allow the user to enter a device name.""" + if is_device: + await asyncio.sleep(30) + await self.hass.config_entries.async_reload(self.config_entry.entry_id) + + async def get_state(self) -> None: """Update HMIP state and tell Home Assistant.""" await self.home.get_current_state() self.update_all() - def get_state_finished(self, future): + def get_state_finished(self, future) -> None: """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") + _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): + def set_all_to_unavailable(self) -> None: """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): + def update_all(self) -> None: """Signal all devices to update their state.""" for device in self.home.devices: device.fire_update_event() - async def async_connect(self): + async def async_connect(self) -> None: """Start WebSocket connection.""" tries = 0 while True: @@ -168,10 +188,12 @@ async def async_connect(self): 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) + _LOGGER.error( + "Error connecting to HomematicIP with HAP %s. " + "Retrying in %d seconds", + self.config_entry.unique_id, + retry_delay, + ) if self._ws_close_requested: break @@ -179,13 +201,14 @@ async def async_connect(self): tries += 1 try: - self._retry_task = self.hass.async_create_task(asyncio.sleep( - retry_delay, loop=self.hass.loop)) + 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): + async def async_reset(self) -> bool: """Close the websocket connection.""" self._ws_close_requested = True if self._retry_task is not None: @@ -194,17 +217,31 @@ async def async_reset(self): _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) + self.config_entry, component + ) + self.hmip_device_by_entity_id = {} return True - async def get_hap(self, hass: HomeAssistant, hapid: str, authtoken: str, - name: str) -> AsyncHome: + @callback + def shutdown(self, event) -> None: + """Wrap the call to async_reset. + + Used as an argument to EventBus.async_listen_once. + """ + self.hass.async_create_task(self.async_reset()) + _LOGGER.debug( + "Reset connection to access point id %s", self.config_entry.unique_id + ) + + async def get_hap( + self, hass: HomeAssistantType, 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.label = "Access Point" + home.modelType = "HmIP-HAP" home.set_auth_token(authtoken) try: @@ -213,6 +250,7 @@ async def get_hap(self, hass: HomeAssistant, hapid: str, authtoken: str, except HmipConnectionError: raise HmipcConnectionError home.on_update(self.async_update) + home.on_create(self.async_create_entity) 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 index 7cfbae95a33b6..9ddcc44e8bd6a 100644 --- a/homeassistant/components/homematicip_cloud/light.py +++ b/homeassistant/components/homematicip_cloud/light.py @@ -1,74 +1,94 @@ """Support for HomematicIP Cloud lights.""" import logging +from typing import Any, Dict from homematicip.aio.device import ( - AsyncBrandDimmer, AsyncBrandSwitchMeasuring, - AsyncBrandSwitchNotificationLight, AsyncDimmer, AsyncFullFlushDimmer, - AsyncPluggableDimmer) -from homematicip.aio.home import AsyncHome + AsyncBrandDimmer, + AsyncBrandSwitchMeasuring, + AsyncBrandSwitchNotificationLight, + AsyncDimmer, + AsyncFullFlushDimmer, + AsyncPluggableDimmer, +) 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) + ATTR_BRIGHTNESS, + ATTR_COLOR_NAME, + ATTR_HS_COLOR, + ATTR_TRANSITION, + SUPPORT_BRIGHTNESS, + SUPPORT_COLOR, + SUPPORT_TRANSITION, + LightEntity, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import HomeAssistantType -from . import DOMAIN as HMIPC_DOMAIN, HMIPC_HAPID, HomematicipGenericDevice +from . import DOMAIN as HMIPC_DOMAIN, HomematicipGenericDevice +from .hap import HomematicipHAP _LOGGER = logging.getLogger(__name__) -ATTR_ENERGY_COUNTER = 'energy_counter_kwh' -ATTR_POWER_CONSUMPTION = 'power_consumption' +ATTR_TODAY_ENERGY_KWH = "today_energy_kwh" +ATTR_CURRENT_POWER_W = "current_power_w" -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: +async def async_setup_entry( + hass: HomeAssistantType, 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: + hap = hass.data[HMIPC_DOMAIN][config_entry.unique_id] + entities = [] + for device in hap.home.devices: if isinstance(device, AsyncBrandSwitchMeasuring): - devices.append(HomematicipLightMeasuring(home, device)) + entities.append(HomematicipLightMeasuring(hap, 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): + entities.append(HomematicipLight(hap, device)) + entities.append( + HomematicipNotificationLight(hap, device, device.topLightChannelIndex) + ) + entities.append( + HomematicipNotificationLight( + hap, device, device.bottomLightChannelIndex + ) + ) + elif isinstance( + device, + (AsyncDimmer, AsyncPluggableDimmer, AsyncBrandDimmer, AsyncFullFlushDimmer), + ): + entities.append(HomematicipDimmer(hap, device)) + + if entities: + async_add_entities(entities) + + +class HomematicipLight(HomematicipGenericDevice, LightEntity): """Representation of a HomematicIP Cloud light device.""" - def __init__(self, home: AsyncHome, device) -> None: + def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the light device.""" - super().__init__(home, device) + super().__init__(hap, device) + + @property + def name(self) -> str: + """Return the name of the multi switch channel.""" + label = self._get_label_by_channel(1) + if label: + return label + return super().name @property def is_on(self) -> bool: """Return true if device is on.""" return self._device.on - async def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs) -> None: """Turn the device on.""" await self._device.turn_on() - async def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs) -> None: """Turn the device off.""" await self._device.turn_off() @@ -77,63 +97,63 @@ class HomematicipLightMeasuring(HomematicipLight): """Representation of a HomematicIP Cloud measuring light device.""" @property - def device_state_attributes(self): + def device_state_attributes(self) -> Dict[str, Any]: """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 + state_attr = super().device_state_attributes + + current_power_w = self._device.currentPowerConsumption + if current_power_w > 0.05: + state_attr[ATTR_CURRENT_POWER_W] = round(current_power_w, 2) + + state_attr[ATTR_TODAY_ENERGY_KWH] = round(self._device.energyCounter, 2) + + return state_attr -class HomematicipDimmer(HomematicipGenericDevice, Light): +class HomematicipDimmer(HomematicipGenericDevice, LightEntity): """Representation of HomematicIP Cloud dimmer light device.""" - def __init__(self, home: AsyncHome, device) -> None: + def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the dimmer light device.""" - super().__init__(home, device) + super().__init__(hap, 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 + 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 + return int((self._device.dimLevel or 0.0) * 255) @property def supported_features(self) -> int: """Flag supported features.""" return SUPPORT_BRIGHTNESS - async def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs) -> None: """Turn the light on.""" if ATTR_BRIGHTNESS in kwargs: - await self._device.set_dim_level(kwargs[ATTR_BRIGHTNESS]/255.0) + 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): + async def async_turn_off(self, **kwargs) -> None: """Turn the light off.""" await self._device.set_dim_level(0) -class HomematicipNotificationLight(HomematicipGenericDevice, Light): +class HomematicipNotificationLight(HomematicipGenericDevice, LightEntity): """Representation of HomematicIP Cloud dimmer light device.""" - def __init__(self, home: AsyncHome, device, channel: int) -> None: + def __init__(self, hap: HomematicipHAP, device, channel: int) -> None: """Initialize the dimmer light device.""" self.channel = channel if self.channel == 2: - super().__init__(home, device, 'Top') + super().__init__(hap, device, "Top") else: - super().__init__(home, device, 'Bottom') + super().__init__(hap, device, "Bottom") self._color_switcher = { RGBColorState.WHITE: [0.0, 0.0], @@ -142,7 +162,7 @@ def __init__(self, home: AsyncHome, device, channel: int) -> None: RGBColorState.GREEN: [120.0, 100.0], RGBColorState.TURQUOISE: [180.0, 100.0], RGBColorState.BLUE: [240.0, 100.0], - RGBColorState.PURPLE: [300.0, 100.0] + RGBColorState.PURPLE: [300.0, 100.0], } @property @@ -152,15 +172,15 @@ def _func_channel(self) -> NotificationLightChannel: @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 + 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 + return int((self._func_channel.dimLevel or 0.0) * 255) @property def hs_color(self) -> tuple: @@ -169,31 +189,34 @@ def hs_color(self) -> tuple: return self._color_switcher.get(simple_rgb_color, [0.0, 0.0]) @property - def device_state_attributes(self): + def device_state_attributes(self) -> Dict[str, Any]: """Return the state attributes of the generic device.""" - attr = super().device_state_attributes + state_attr = super().device_state_attributes + if self.is_on: - attr[ATTR_COLOR_NAME] = self._func_channel.simpleRGBColorState - return attr + state_attr[ATTR_COLOR_NAME] = self._func_channel.simpleRGBColorState + + return state_attr @property def name(self) -> str: """Return the name of the generic device.""" - return "{} {}".format(super().name, 'Notification') + label = self._get_label_by_channel(self.channel) + if label: + return label + return f"{super().name} Notification" @property def supported_features(self) -> int: """Flag supported features.""" - return SUPPORT_BRIGHTNESS | SUPPORT_COLOR + return SUPPORT_BRIGHTNESS | SUPPORT_COLOR | SUPPORT_TRANSITION @property def unique_id(self) -> str: """Return a unique ID.""" - return "{}_{}_{}".format(self.__class__.__name__, - self.post, - self._device.id) + return f"{self.__class__.__name__}_{self.post}_{self._device.id}" - async def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs) -> None: """Turn the light on.""" # Use hs_color from kwargs, # if not applicable use current hs_color. @@ -211,21 +234,31 @@ async def async_turn_on(self, **kwargs): # Minimum brightness is 10, otherwise the led is disabled brightness = max(10, brightness) dim_level = brightness / 255.0 + transition = kwargs.get(ATTR_TRANSITION, 0.5) - await self._device.set_rgb_dim_level( - self.channel, - simple_rgb_color, - dim_level) + await self._device.set_rgb_dim_level_with_time( + channelIndex=self.channel, + rgb=simple_rgb_color, + dimLevel=dim_level, + onTime=0, + rampTime=transition, + ) - async def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs) -> None: """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) + transition = kwargs.get(ATTR_TRANSITION, 0.5) + + await self._device.set_rgb_dim_level_with_time( + channelIndex=self.channel, + rgb=simple_rgb_color, + dimLevel=0.0, + onTime=0, + rampTime=transition, + ) -def _convert_color(color) -> RGBColorState: +def _convert_color(color: tuple) -> RGBColorState: """ Convert the given color to the reduced RGBColorState color. diff --git a/homeassistant/components/homematicip_cloud/manifest.json b/homeassistant/components/homematicip_cloud/manifest.json index 030b4d5b79ba2..ef362300c661b 100644 --- a/homeassistant/components/homematicip_cloud/manifest.json +++ b/homeassistant/components/homematicip_cloud/manifest.json @@ -1,10 +1,9 @@ { "domain": "homematicip_cloud", - "name": "Homematicip cloud", - "documentation": "https://www.home-assistant.io/components/homematicip_cloud", - "requirements": [ - "homematicip==0.10.7" - ], - "dependencies": [], - "codeowners": [] + "name": "HomematicIP Cloud", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/homematicip_cloud", + "requirements": ["homematicip==0.10.17"], + "codeowners": ["@SukramJ"], + "quality_scale": "platinum" } diff --git a/homeassistant/components/homematicip_cloud/sensor.py b/homeassistant/components/homematicip_cloud/sensor.py index b3e23bde2be4a..a45591ecc306e 100644 --- a/homeassistant/components/homematicip_cloud/sensor.py +++ b/homeassistant/components/homematicip_cloud/sensor.py @@ -1,94 +1,138 @@ """Support for HomematicIP Cloud sensors.""" import logging +from typing import Any, Dict from homematicip.aio.device import ( - AsyncBrandSwitchMeasuring, AsyncFullFlushSwitchMeasuring, - AsyncHeatingThermostat, AsyncHeatingThermostatCompact, AsyncLightSensor, - AsyncMotionDetectorIndoor, AsyncMotionDetectorOutdoor, - AsyncMotionDetectorPushButton, AsyncPlugableSwitchMeasuring, - AsyncPresenceDetectorIndoor, AsyncTemperatureHumiditySensorDisplay, + AsyncBrandSwitchMeasuring, + AsyncFullFlushSwitchMeasuring, + AsyncHeatingThermostat, + AsyncHeatingThermostatCompact, + AsyncLightSensor, + AsyncMotionDetectorIndoor, + AsyncMotionDetectorOutdoor, + AsyncMotionDetectorPushButton, + AsyncPassageDetector, + AsyncPlugableSwitchMeasuring, + AsyncPresenceDetectorIndoor, + AsyncRoomControlDeviceAnalog, + AsyncTemperatureHumiditySensorDisplay, AsyncTemperatureHumiditySensorOutdoor, - AsyncTemperatureHumiditySensorWithoutDisplay, AsyncWeatherSensor, - AsyncWeatherSensorPlus, AsyncWeatherSensorPro) -from homematicip.aio.home import AsyncHome + AsyncTemperatureHumiditySensorWithoutDisplay, + AsyncWeatherSensor, + AsyncWeatherSensorPlus, + AsyncWeatherSensorPro, +) 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 + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_ILLUMINANCE, + DEVICE_CLASS_POWER, + DEVICE_CLASS_TEMPERATURE, + POWER_WATT, + SPEED_KILOMETERS_PER_HOUR, + TEMP_CELSIUS, + UNIT_PERCENTAGE, +) +from homeassistant.helpers.typing import HomeAssistantType + +from . import DOMAIN as HMIPC_DOMAIN, HomematicipGenericDevice +from .device import ATTR_IS_GROUP, ATTR_MODEL_TYPE +from .hap import HomematicipHAP _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: +ATTR_CURRENT_ILLUMINATION = "current_illumination" +ATTR_LOWEST_ILLUMINATION = "lowest_illumination" +ATTR_HIGHEST_ILLUMINATION = "highest_illumination" +ATTR_LEFT_COUNTER = "left_counter" +ATTR_RIGHT_COUNTER = "right_counter" +ATTR_TEMPERATURE_OFFSET = "temperature_offset" +ATTR_WIND_DIRECTION = "wind_direction" +ATTR_WIND_DIRECTION_VARIATION = "wind_direction_variation_in_degree" + +ILLUMINATION_DEVICE_ATTRIBUTES = { + "currentIllumination": ATTR_CURRENT_ILLUMINATION, + "lowestIllumination": ATTR_LOWEST_ILLUMINATION, + "highestIllumination": ATTR_HIGHEST_ILLUMINATION, +} + + +async def async_setup_entry( + hass: HomeAssistantType, 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) + hap = hass.data[HMIPC_DOMAIN][config_entry.unique_id] + entities = [HomematicipAccesspointStatus(hap)] + for device in hap.home.devices: + if isinstance(device, (AsyncHeatingThermostat, AsyncHeatingThermostatCompact)): + entities.append(HomematicipHeatingThermostat(hap, device)) + entities.append(HomematicipTemperatureSensor(hap, device)) + if isinstance( + device, + ( + AsyncTemperatureHumiditySensorDisplay, + AsyncTemperatureHumiditySensorWithoutDisplay, + AsyncTemperatureHumiditySensorOutdoor, + AsyncWeatherSensor, + AsyncWeatherSensorPlus, + AsyncWeatherSensorPro, + ), + ): + entities.append(HomematicipTemperatureSensor(hap, device)) + entities.append(HomematicipHumiditySensor(hap, device)) + elif isinstance(device, (AsyncRoomControlDeviceAnalog,)): + entities.append(HomematicipTemperatureSensor(hap, device)) + if isinstance( + device, + ( + AsyncLightSensor, + AsyncMotionDetectorIndoor, + AsyncMotionDetectorOutdoor, + AsyncMotionDetectorPushButton, + AsyncPresenceDetectorIndoor, + AsyncWeatherSensor, + AsyncWeatherSensorPlus, + AsyncWeatherSensorPro, + ), + ): + entities.append(HomematicipIlluminanceSensor(hap, device)) + if isinstance( + device, + ( + AsyncPlugableSwitchMeasuring, + AsyncBrandSwitchMeasuring, + AsyncFullFlushSwitchMeasuring, + ), + ): + entities.append(HomematicipPowerSensor(hap, device)) + if isinstance( + device, (AsyncWeatherSensor, AsyncWeatherSensorPlus, AsyncWeatherSensorPro) + ): + entities.append(HomematicipWindspeedSensor(hap, device)) + if isinstance(device, (AsyncWeatherSensorPlus, AsyncWeatherSensorPro)): + entities.append(HomematicipTodayRainSensor(hap, device)) + if isinstance(device, AsyncPassageDetector): + entities.append(HomematicipPassageDetectorDeltaCounter(hap, device)) + + if entities: + async_add_entities(entities) class HomematicipAccesspointStatus(HomematicipGenericDevice): """Representation of an HomeMaticIP Cloud access point.""" - def __init__(self, home: AsyncHome) -> None: + def __init__(self, hap: HomematicipHAP) -> None: """Initialize access point device.""" - super().__init__(home, home) + super().__init__(hap, hap.home) @property - def device_info(self): + def device_info(self) -> Dict[str, Any]: """Return device specific attributes.""" # Adds a sensor to the existing HAP device return { - 'identifiers': { + "identifiers": { # Serial numbers of Homematic IP device (HMIPC_DOMAIN, self._device.id) } @@ -97,7 +141,7 @@ def device_info(self): @property def icon(self) -> str: """Return the icon of the access point device.""" - return 'mdi:access-point-network' + return "mdi:access-point-network" @property def state(self) -> float: @@ -112,15 +156,25 @@ def available(self) -> bool: @property def unit_of_measurement(self) -> str: """Return the unit this state is expressed in.""" - return '%' + return UNIT_PERCENTAGE + + @property + def device_state_attributes(self) -> Dict[str, Any]: + """Return the state attributes of the access point.""" + state_attr = super().device_state_attributes + + state_attr[ATTR_MODEL_TYPE] = "HmIP-HAP" + state_attr[ATTR_IS_GROUP] = False + + return state_attr class HomematicipHeatingThermostat(HomematicipGenericDevice): - """Represenation of a HomematicIP heating thermostat device.""" + """Representation of a HomematicIP heating thermostat device.""" - def __init__(self, home: AsyncHome, device) -> None: + def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize heating thermostat device.""" - super().__init__(home, device, 'Heating') + super().__init__(hap, device, "Heating") @property def icon(self) -> str: @@ -128,28 +182,28 @@ def icon(self) -> str: if super().icon: return super().icon if self._device.valveState != ValveState.ADAPTION_DONE: - return 'mdi:alert' - return 'mdi:radiator' + 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) + return round(self._device.valvePosition * 100) @property def unit_of_measurement(self) -> str: """Return the unit this state is expressed in.""" - return '%' + return UNIT_PERCENTAGE class HomematicipHumiditySensor(HomematicipGenericDevice): - """Represenation of a HomematicIP Cloud humidity device.""" + """Representation of a HomematicIP Cloud humidity device.""" - def __init__(self, home: AsyncHome, device) -> None: + def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the thermometer device.""" - super().__init__(home, device, 'Humidity') + super().__init__(hap, device, "Humidity") @property def device_class(self) -> str: @@ -164,15 +218,15 @@ def state(self) -> int: @property def unit_of_measurement(self) -> str: """Return the unit this state is expressed in.""" - return '%' + return UNIT_PERCENTAGE class HomematicipTemperatureSensor(HomematicipGenericDevice): """Representation of a HomematicIP Cloud thermometer device.""" - def __init__(self, home: AsyncHome, device) -> None: + def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the thermometer device.""" - super().__init__(home, device, 'Temperature') + super().__init__(hap, device, "Temperature") @property def device_class(self) -> str: @@ -182,7 +236,7 @@ def device_class(self) -> str: @property def state(self) -> float: """Return the state.""" - if hasattr(self._device, 'valveActualTemperature'): + if hasattr(self._device, "valveActualTemperature"): return self._device.valveActualTemperature return self._device.actualTemperature @@ -193,21 +247,23 @@ def unit_of_measurement(self) -> str: return TEMP_CELSIUS @property - def device_state_attributes(self): + def device_state_attributes(self) -> Dict[str, Any]: """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 + state_attr = super().device_state_attributes + + temperature_offset = getattr(self._device, "temperatureOffset", None) + if temperature_offset: + state_attr[ATTR_TEMPERATURE_OFFSET] = temperature_offset + + return state_attr class HomematicipIlluminanceSensor(HomematicipGenericDevice): - """Represenation of a HomematicIP Illuminance device.""" + """Representation of a HomematicIP Illuminance device.""" - def __init__(self, home: AsyncHome, device) -> None: + def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the device.""" - super().__init__(home, device, 'Illuminance') + super().__init__(hap, device, "Illuminance") @property def device_class(self) -> str: @@ -217,7 +273,7 @@ def device_class(self) -> str: @property def state(self) -> float: """Return the state.""" - if hasattr(self._device, 'averageIllumination'): + if hasattr(self._device, "averageIllumination"): return self._device.averageIllumination return self._device.illumination @@ -225,15 +281,27 @@ def state(self) -> float: @property def unit_of_measurement(self) -> str: """Return the unit this state is expressed in.""" - return 'lx' + return "lx" + + @property + def device_state_attributes(self) -> Dict[str, Any]: + """Return the state attributes of the wind speed sensor.""" + state_attr = super().device_state_attributes + + for attr, attr_key in ILLUMINATION_DEVICE_ATTRIBUTES.items(): + attr_value = getattr(self._device, attr, None) + if attr_value: + state_attr[attr_key] = attr_value + + return state_attr class HomematicipPowerSensor(HomematicipGenericDevice): - """Represenation of a HomematicIP power measuring device.""" + """Representation of a HomematicIP power measuring device.""" - def __init__(self, home: AsyncHome, device) -> None: + def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the device.""" - super().__init__(home, device, 'Power') + super().__init__(hap, device, "Power") @property def device_class(self) -> str: @@ -242,7 +310,7 @@ def device_class(self) -> str: @property def state(self) -> float: - """Represenation of the HomematicIP power comsumption value.""" + """Representation of the HomematicIP power consumption value.""" return self._device.currentPowerConsumption @property @@ -252,85 +320,105 @@ def unit_of_measurement(self) -> str: class HomematicipWindspeedSensor(HomematicipGenericDevice): - """Represenation of a HomematicIP wind speed sensor.""" + """Representation of a HomematicIP wind speed sensor.""" - def __init__(self, home: AsyncHome, device) -> None: + def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the device.""" - super().__init__(home, device, 'Windspeed') + super().__init__(hap, device, "Windspeed") @property def state(self) -> float: - """Represenation of the HomematicIP wind speed value.""" + """Representation 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' + return SPEED_KILOMETERS_PER_HOUR @property - def device_state_attributes(self): + def device_state_attributes(self) -> Dict[str, Any]: """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 + state_attr = super().device_state_attributes + + wind_direction = getattr(self._device, "windDirection", None) + if wind_direction is not None: + state_attr[ATTR_WIND_DIRECTION] = _get_wind_direction(wind_direction) + + wind_direction_variation = getattr(self._device, "windDirectionVariation", None) + if wind_direction_variation: + state_attr[ATTR_WIND_DIRECTION_VARIATION] = wind_direction_variation + + return state_attr class HomematicipTodayRainSensor(HomematicipGenericDevice): - """Represenation of a HomematicIP rain counter of a day sensor.""" + """Representation of a HomematicIP rain counter of a day sensor.""" - def __init__(self, home: AsyncHome, device) -> None: + def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the device.""" - super().__init__(home, device, 'Today Rain') + super().__init__(hap, device, "Today Rain") @property def state(self) -> float: - """Represenation of the HomematicIP todays rain value.""" + """Representation of the HomematicIP today's 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' + return "mm" + + +class HomematicipPassageDetectorDeltaCounter(HomematicipGenericDevice): + """Representation of a HomematicIP passage detector delta counter.""" + + @property + def state(self) -> int: + """Representation of the HomematicIP passage detector delta counter value.""" + return self._device.leftRightCounterDelta + + @property + def device_state_attributes(self) -> Dict[str, Any]: + """Return the state attributes of the delta counter.""" + state_attr = super().device_state_attributes + + state_attr[ATTR_LEFT_COUNTER] = self._device.leftCounter + state_attr[ATTR_RIGHT_COUNTER] = self._device.rightCounter + + return state_attr 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' + return "NNE" if 33.75 <= wind_direction_degree < 56.25: - return 'NE' + return "NE" if 56.25 <= wind_direction_degree < 78.75: - return 'ENE' + return "ENE" if 78.75 <= wind_direction_degree < 101.25: - return 'E' + return "E" if 101.25 <= wind_direction_degree < 123.75: - return 'ESE' + return "ESE" if 123.75 <= wind_direction_degree < 146.25: - return 'SE' + return "SE" if 146.25 <= wind_direction_degree < 168.75: - return 'SSE' + return "SSE" if 168.75 <= wind_direction_degree < 191.25: - return 'S' + return "S" if 191.25 <= wind_direction_degree < 213.75: - return 'SSW' + return "SSW" if 213.75 <= wind_direction_degree < 236.25: - return 'SW' + return "SW" if 236.25 <= wind_direction_degree < 258.75: - return 'WSW' + return "WSW" if 258.75 <= wind_direction_degree < 281.25: - return 'W' + return "W" if 281.25 <= wind_direction_degree < 303.75: - return 'WNW' + return "WNW" if 303.75 <= wind_direction_degree < 326.25: - return 'NW' + return "NW" if 326.25 <= wind_direction_degree < 348.75: - return 'NNW' - return 'N' + return "NNW" + return "N" diff --git a/homeassistant/components/homematicip_cloud/services.py b/homeassistant/components/homematicip_cloud/services.py new file mode 100644 index 0000000000000..d8535edda50e9 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/services.py @@ -0,0 +1,353 @@ +"""Support for HomematicIP Cloud devices.""" +import logging +from pathlib import Path +from typing import Optional + +from homematicip.aio.device import AsyncSwitchMeasuring +from homematicip.aio.group import AsyncHeatingGroup +from homematicip.aio.home import AsyncHome +from homematicip.base.helpers import handle_config +import voluptuous as vol + +from homeassistant.const import ATTR_ENTITY_ID +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.config_validation import comp_entity_ids +from homeassistant.helpers.service import ( + async_register_admin_service, + verify_domain_control, +) +from homeassistant.helpers.typing import HomeAssistantType, ServiceCallType + +from .const import DOMAIN as HMIPC_DOMAIN + +_LOGGER = logging.getLogger(__name__) + +ATTR_ACCESSPOINT_ID = "accesspoint_id" +ATTR_ANONYMIZE = "anonymize" +ATTR_CLIMATE_PROFILE_INDEX = "climate_profile_index" +ATTR_CONFIG_OUTPUT_FILE_PREFIX = "config_output_file_prefix" +ATTR_CONFIG_OUTPUT_PATH = "config_output_path" +ATTR_DURATION = "duration" +ATTR_ENDTIME = "endtime" +ATTR_TEMPERATURE = "temperature" + +DEFAULT_CONFIG_FILE_PREFIX = "hmip-config" + +SERVICE_ACTIVATE_ECO_MODE_WITH_DURATION = "activate_eco_mode_with_duration" +SERVICE_ACTIVATE_ECO_MODE_WITH_PERIOD = "activate_eco_mode_with_period" +SERVICE_ACTIVATE_VACATION = "activate_vacation" +SERVICE_DEACTIVATE_ECO_MODE = "deactivate_eco_mode" +SERVICE_DEACTIVATE_VACATION = "deactivate_vacation" +SERVICE_DUMP_HAP_CONFIG = "dump_hap_config" +SERVICE_RESET_ENERGY_COUNTER = "reset_energy_counter" +SERVICE_SET_ACTIVE_CLIMATE_PROFILE = "set_active_climate_profile" + +HMIPC_SERVICES = [ + SERVICE_ACTIVATE_ECO_MODE_WITH_DURATION, + SERVICE_ACTIVATE_ECO_MODE_WITH_PERIOD, + SERVICE_ACTIVATE_VACATION, + SERVICE_DEACTIVATE_ECO_MODE, + SERVICE_DEACTIVATE_VACATION, + SERVICE_DUMP_HAP_CONFIG, + SERVICE_RESET_ENERGY_COUNTER, + SERVICE_SET_ACTIVE_CLIMATE_PROFILE, +] + +SCHEMA_ACTIVATE_ECO_MODE_WITH_DURATION = vol.Schema( + { + vol.Required(ATTR_DURATION): cv.positive_int, + vol.Optional(ATTR_ACCESSPOINT_ID): vol.All(str, vol.Length(min=24, max=24)), + } +) + +SCHEMA_ACTIVATE_ECO_MODE_WITH_PERIOD = vol.Schema( + { + vol.Required(ATTR_ENDTIME): cv.datetime, + vol.Optional(ATTR_ACCESSPOINT_ID): vol.All(str, vol.Length(min=24, max=24)), + } +) + +SCHEMA_ACTIVATE_VACATION = vol.Schema( + { + vol.Required(ATTR_ENDTIME): cv.datetime, + vol.Required(ATTR_TEMPERATURE, default=18.0): vol.All( + vol.Coerce(float), vol.Range(min=0, max=55) + ), + vol.Optional(ATTR_ACCESSPOINT_ID): vol.All(str, vol.Length(min=24, max=24)), + } +) + +SCHEMA_DEACTIVATE_ECO_MODE = vol.Schema( + {vol.Optional(ATTR_ACCESSPOINT_ID): vol.All(str, vol.Length(min=24, max=24))} +) + +SCHEMA_DEACTIVATE_VACATION = vol.Schema( + {vol.Optional(ATTR_ACCESSPOINT_ID): vol.All(str, vol.Length(min=24, max=24))} +) + +SCHEMA_SET_ACTIVE_CLIMATE_PROFILE = vol.Schema( + { + vol.Required(ATTR_ENTITY_ID): comp_entity_ids, + vol.Required(ATTR_CLIMATE_PROFILE_INDEX): cv.positive_int, + } +) + +SCHEMA_DUMP_HAP_CONFIG = vol.Schema( + { + vol.Optional(ATTR_CONFIG_OUTPUT_PATH): cv.string, + vol.Optional( + ATTR_CONFIG_OUTPUT_FILE_PREFIX, default=DEFAULT_CONFIG_FILE_PREFIX + ): cv.string, + vol.Optional(ATTR_ANONYMIZE, default=True): cv.boolean, + } +) + +SCHEMA_RESET_ENERGY_COUNTER = vol.Schema( + {vol.Required(ATTR_ENTITY_ID): comp_entity_ids} +) + + +async def async_setup_services(hass: HomeAssistantType) -> None: + """Set up the HomematicIP Cloud services.""" + + if hass.services.async_services().get(HMIPC_DOMAIN): + return + + @verify_domain_control(hass, HMIPC_DOMAIN) + async def async_call_hmipc_service(service: ServiceCallType): + """Call correct HomematicIP Cloud service.""" + service_name = service.service + + if service_name == SERVICE_ACTIVATE_ECO_MODE_WITH_DURATION: + await _async_activate_eco_mode_with_duration(hass, service) + elif service_name == SERVICE_ACTIVATE_ECO_MODE_WITH_PERIOD: + await _async_activate_eco_mode_with_period(hass, service) + elif service_name == SERVICE_ACTIVATE_VACATION: + await _async_activate_vacation(hass, service) + elif service_name == SERVICE_DEACTIVATE_ECO_MODE: + await _async_deactivate_eco_mode(hass, service) + elif service_name == SERVICE_DEACTIVATE_VACATION: + await _async_deactivate_vacation(hass, service) + elif service_name == SERVICE_DUMP_HAP_CONFIG: + await _async_dump_hap_config(hass, service) + elif service_name == SERVICE_RESET_ENERGY_COUNTER: + await _async_reset_energy_counter(hass, service) + elif service_name == SERVICE_SET_ACTIVE_CLIMATE_PROFILE: + await _set_active_climate_profile(hass, service) + + hass.services.async_register( + domain=HMIPC_DOMAIN, + service=SERVICE_ACTIVATE_ECO_MODE_WITH_DURATION, + service_func=async_call_hmipc_service, + schema=SCHEMA_ACTIVATE_ECO_MODE_WITH_DURATION, + ) + + hass.services.async_register( + domain=HMIPC_DOMAIN, + service=SERVICE_ACTIVATE_ECO_MODE_WITH_PERIOD, + service_func=async_call_hmipc_service, + schema=SCHEMA_ACTIVATE_ECO_MODE_WITH_PERIOD, + ) + + hass.services.async_register( + domain=HMIPC_DOMAIN, + service=SERVICE_ACTIVATE_VACATION, + service_func=async_call_hmipc_service, + schema=SCHEMA_ACTIVATE_VACATION, + ) + + hass.services.async_register( + domain=HMIPC_DOMAIN, + service=SERVICE_DEACTIVATE_ECO_MODE, + service_func=async_call_hmipc_service, + schema=SCHEMA_DEACTIVATE_ECO_MODE, + ) + + hass.services.async_register( + domain=HMIPC_DOMAIN, + service=SERVICE_DEACTIVATE_VACATION, + service_func=async_call_hmipc_service, + schema=SCHEMA_DEACTIVATE_VACATION, + ) + + hass.services.async_register( + domain=HMIPC_DOMAIN, + service=SERVICE_SET_ACTIVE_CLIMATE_PROFILE, + service_func=async_call_hmipc_service, + schema=SCHEMA_SET_ACTIVE_CLIMATE_PROFILE, + ) + + async_register_admin_service( + hass=hass, + domain=HMIPC_DOMAIN, + service=SERVICE_DUMP_HAP_CONFIG, + service_func=async_call_hmipc_service, + schema=SCHEMA_DUMP_HAP_CONFIG, + ) + + async_register_admin_service( + hass=hass, + domain=HMIPC_DOMAIN, + service=SERVICE_RESET_ENERGY_COUNTER, + service_func=async_call_hmipc_service, + schema=SCHEMA_RESET_ENERGY_COUNTER, + ) + + +async def async_unload_services(hass: HomeAssistantType): + """Unload HomematicIP Cloud services.""" + if hass.data[HMIPC_DOMAIN]: + return + + for hmipc_service in HMIPC_SERVICES: + hass.services.async_remove(domain=HMIPC_DOMAIN, service=hmipc_service) + + +async def _async_activate_eco_mode_with_duration( + hass: HomeAssistantType, service: ServiceCallType +) -> None: + """Service to activate eco mode with duration.""" + duration = service.data[ATTR_DURATION] + hapid = service.data.get(ATTR_ACCESSPOINT_ID) + + if hapid: + home = _get_home(hass, hapid) + if home: + await home.activate_absence_with_duration(duration) + else: + for hap in hass.data[HMIPC_DOMAIN].values(): + await hap.home.activate_absence_with_duration(duration) + + +async def _async_activate_eco_mode_with_period( + hass: HomeAssistantType, service: ServiceCallType +) -> None: + """Service to activate eco mode with period.""" + endtime = service.data[ATTR_ENDTIME] + hapid = service.data.get(ATTR_ACCESSPOINT_ID) + + if hapid: + home = _get_home(hass, hapid) + if home: + await home.activate_absence_with_period(endtime) + else: + for hap in hass.data[HMIPC_DOMAIN].values(): + await hap.home.activate_absence_with_period(endtime) + + +async def _async_activate_vacation( + hass: HomeAssistantType, service: ServiceCallType +) -> None: + """Service to activate vacation.""" + endtime = service.data[ATTR_ENDTIME] + temperature = service.data[ATTR_TEMPERATURE] + hapid = service.data.get(ATTR_ACCESSPOINT_ID) + + if hapid: + home = _get_home(hass, hapid) + if home: + await home.activate_vacation(endtime, temperature) + else: + for hap in hass.data[HMIPC_DOMAIN].values(): + await hap.home.activate_vacation(endtime, temperature) + + +async def _async_deactivate_eco_mode( + hass: HomeAssistantType, service: ServiceCallType +) -> None: + """Service to deactivate eco mode.""" + hapid = service.data.get(ATTR_ACCESSPOINT_ID) + + if hapid: + home = _get_home(hass, hapid) + if home: + await home.deactivate_absence() + else: + for hap in hass.data[HMIPC_DOMAIN].values(): + await hap.home.deactivate_absence() + + +async def _async_deactivate_vacation( + hass: HomeAssistantType, service: ServiceCallType +) -> None: + """Service to deactivate vacation.""" + hapid = service.data.get(ATTR_ACCESSPOINT_ID) + + if hapid: + home = _get_home(hass, hapid) + if home: + await home.deactivate_vacation() + else: + for hap in hass.data[HMIPC_DOMAIN].values(): + await hap.home.deactivate_vacation() + + +async def _set_active_climate_profile( + hass: HomeAssistantType, service: ServiceCallType +) -> None: + """Service to set the active climate profile.""" + entity_id_list = service.data[ATTR_ENTITY_ID] + climate_profile_index = service.data[ATTR_CLIMATE_PROFILE_INDEX] - 1 + + for hap in hass.data[HMIPC_DOMAIN].values(): + if entity_id_list != "all": + for entity_id in entity_id_list: + group = hap.hmip_device_by_entity_id.get(entity_id) + if group and isinstance(group, AsyncHeatingGroup): + await group.set_active_profile(climate_profile_index) + else: + for group in hap.home.groups: + if isinstance(group, AsyncHeatingGroup): + await group.set_active_profile(climate_profile_index) + + +async def _async_dump_hap_config( + hass: HomeAssistantType, service: ServiceCallType +) -> None: + """Service to dump the configuration of a Homematic IP Access Point.""" + config_path = service.data.get(ATTR_CONFIG_OUTPUT_PATH) or hass.config.config_dir + config_file_prefix = service.data[ATTR_CONFIG_OUTPUT_FILE_PREFIX] + anonymize = service.data[ATTR_ANONYMIZE] + + for hap in hass.data[HMIPC_DOMAIN].values(): + hap_sgtin = hap.config_entry.unique_id + + if anonymize: + hap_sgtin = hap_sgtin[-4:] + + file_name = f"{config_file_prefix}_{hap_sgtin}.json" + path = Path(config_path) + config_file = path / file_name + + json_state = await hap.home.download_configuration() + json_state = handle_config(json_state, anonymize) + + config_file.write_text(json_state, encoding="utf8") + + +async def _async_reset_energy_counter( + hass: HomeAssistantType, service: ServiceCallType +): + """Service to reset the energy counter.""" + entity_id_list = service.data[ATTR_ENTITY_ID] + + for hap in hass.data[HMIPC_DOMAIN].values(): + if entity_id_list != "all": + for entity_id in entity_id_list: + device = hap.hmip_device_by_entity_id.get(entity_id) + if device and isinstance(device, AsyncSwitchMeasuring): + await device.reset_energy_counter() + else: + for device in hap.home.devices: + if isinstance(device, AsyncSwitchMeasuring): + await device.reset_energy_counter() + + +def _get_home(hass: HomeAssistantType, hapid: str) -> Optional[AsyncHome]: + """Return a HmIP home.""" + hap = hass.data[HMIPC_DOMAIN].get(hapid) + if hap: + return hap.home + + _LOGGER.info("No matching access point found for access point id %s", hapid) + return None diff --git a/homeassistant/components/homematicip_cloud/services.yaml b/homeassistant/components/homematicip_cloud/services.yaml new file mode 100644 index 0000000000000..20447e496f700 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/services.yaml @@ -0,0 +1,78 @@ +# Describes the format for available component services + +activate_eco_mode_with_duration: + description: Activate eco mode with period. + fields: + duration: + description: The duration of eco mode in minutes. + example: 60 + accesspoint_id: + description: The ID of the Homematic IP Access Point (optional) + example: 3014xxxxxxxxxxxxxxxxxxxx + +activate_eco_mode_with_period: + description: Activate eco mode with period. + fields: + endtime: + description: The time when the eco mode should automatically be disabled. + example: 2019-02-17 14:00 + accesspoint_id: + description: The ID of the Homematic IP Access Point (optional) + example: 3014xxxxxxxxxxxxxxxxxxxx + +activate_vacation: + description: Activates the vacation mode until the given time. + fields: + endtime: + description: The time when the vacation mode should automatically be disabled. + example: 2019-09-17 14:00 + temperature: + description: the set temperature during the vacation mode. + example: 18.5 + accesspoint_id: + description: The ID of the Homematic IP Access Point (optional) + example: 3014xxxxxxxxxxxxxxxxxxxx + +deactivate_eco_mode: + description: Deactivates the eco mode immediately. + fields: + accesspoint_id: + description: The ID of the Homematic IP Access Point (optional) + example: 3014xxxxxxxxxxxxxxxxxxxx + +deactivate_vacation: + description: Deactivates the vacation mode immediately. + fields: + accesspoint_id: + description: The ID of the Homematic IP Access Point (optional) + example: 3014xxxxxxxxxxxxxxxxxxxx + +set_active_climate_profile: + description: Set the active climate profile index. + fields: + entity_id: + description: The ID of the climte entity. Use 'all' keyword to switch the profile for all entities. + example: climate.livingroom + climate_profile_index: + description: The index of the climate profile (1 based) + example: 1 + +dump_hap_config: + description: Dump the configuration of the Homematic IP Access Point(s). + fields: + config_output_path: + description: (Default is 'Your home-assistant config directory') Path where to store the config. + example: "/config" + config_output_file_prefix: + description: (Default is 'hmip-config') Name of the config file. The SGTIN of the AP will always be appended. + example: "hmip-config" + anonymize: + description: (Default is True) Should the Configuration be anonymized? + example: true + +reset_energy_counter: + description: Reset the energy counter of a measuring entity. + fields: + entity_id: + description: The ID of the measuring entity. Use 'all' keyword to reset all energy counters. + example: switch.livingroom diff --git a/homeassistant/components/homematicip_cloud/strings.json b/homeassistant/components/homematicip_cloud/strings.json index f2d38a1dc7b83..2b2a75ebc08f6 100644 --- a/homeassistant/components/homematicip_cloud/strings.json +++ b/homeassistant/components/homematicip_cloud/strings.json @@ -1,6 +1,5 @@ { "config": { - "title": "HomematicIP Cloud", "step": { "init": { "title": "Pick HomematicIP Access point", diff --git a/homeassistant/components/homematicip_cloud/switch.py b/homeassistant/components/homematicip_cloud/switch.py index 7b87f6c740e2b..f000aef0695a7 100644 --- a/homeassistant/components/homematicip_cloud/switch.py +++ b/homeassistant/components/homematicip_cloud/switch.py @@ -1,89 +1,100 @@ """Support for HomematicIP Cloud switches.""" import logging +from typing import Any, Dict 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 + AsyncBrandSwitchMeasuring, + AsyncFullFlushSwitchMeasuring, + AsyncHeatingSwitch2, + AsyncMultiIOBox, + AsyncOpenCollector8Module, + AsyncPlugableSwitch, + AsyncPlugableSwitchMeasuring, + AsyncPrintedCircuitBoardSwitch2, + AsyncPrintedCircuitBoardSwitchBattery, +) +from homematicip.aio.group import AsyncExtendedLinkedSwitchingGroup, AsyncSwitchingGroup + +from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import HomeAssistantType -from . import DOMAIN as HMIPC_DOMAIN, HMIPC_HAPID, HomematicipGenericDevice +from . import DOMAIN as HMIPC_DOMAIN, HomematicipGenericDevice from .device import ATTR_GROUP_MEMBER_UNREACHABLE +from .hap import HomematicipHAP _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: +async def async_setup_entry( + hass: HomeAssistantType, 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: + hap = hass.data[HMIPC_DOMAIN][config_entry.unique_id] + entities = [] + for device in hap.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, (AsyncPlugableSwitchMeasuring, AsyncFullFlushSwitchMeasuring) + ): + entities.append(HomematicipSwitchMeasuring(hap, device)) + elif isinstance( + device, (AsyncPlugableSwitch, AsyncPrintedCircuitBoardSwitchBattery) + ): + entities.append(HomematicipSwitch(hap, device)) elif isinstance(device, AsyncOpenCollector8Module): for channel in range(1, 9): - devices.append(HomematicipMultiSwitch(home, device, channel)) + entities.append(HomematicipMultiSwitch(hap, device, channel)) + elif isinstance(device, AsyncHeatingSwitch2): + for channel in range(1, 3): + entities.append(HomematicipMultiSwitch(hap, device, channel)) elif isinstance(device, AsyncMultiIOBox): for channel in range(1, 3): - devices.append(HomematicipMultiSwitch(home, device, channel)) + entities.append(HomematicipMultiSwitch(hap, device, channel)) + elif isinstance(device, AsyncPrintedCircuitBoardSwitch2): + for channel in range(1, 3): + entities.append(HomematicipMultiSwitch(hap, device, channel)) - for group in home.groups: - if isinstance(group, AsyncSwitchingGroup): - devices.append( - HomematicipGroupSwitch(home, group)) + for group in hap.home.groups: + if isinstance(group, (AsyncExtendedLinkedSwitchingGroup, AsyncSwitchingGroup)): + entities.append(HomematicipGroupSwitch(hap, group)) - if devices: - async_add_entities(devices) + if entities: + async_add_entities(entities) -class HomematicipSwitch(HomematicipGenericDevice, SwitchDevice): +class HomematicipSwitch(HomematicipGenericDevice, SwitchEntity): """representation of a HomematicIP Cloud switch device.""" - def __init__(self, home: AsyncHome, device) -> None: + def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the switch device.""" - super().__init__(home, device) + super().__init__(hap, device) @property def is_on(self) -> bool: """Return true if device is on.""" return self._device.on - async def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs) -> None: """Turn the device on.""" await self._device.turn_on() - async def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs) -> None: """Turn the device off.""" await self._device.turn_off() -class HomematicipGroupSwitch(HomematicipGenericDevice, SwitchDevice): +class HomematicipGroupSwitch(HomematicipGenericDevice, SwitchEntity): """representation of a HomematicIP switching group.""" - def __init__(self, home: AsyncHome, device, post: str = 'Group') -> None: + def __init__(self, hap: HomematicipHAP, device, post: str = "Group") -> None: """Initialize switching group.""" - device.modelType = 'HmIP-{}'.format(post) - super().__init__(home, device, post) + device.modelType = f"HmIP-{post}" + super().__init__(hap, device, post) @property def is_on(self) -> bool: @@ -100,18 +111,20 @@ def available(self) -> bool: return True @property - def device_state_attributes(self): + def device_state_attributes(self) -> Dict[str, Any]: """Return the state attributes of the switch-group.""" - attr = {} + state_attr = super().device_state_attributes + if self._device.unreach: - attr[ATTR_GROUP_MEMBER_UNREACHABLE] = True - return attr + state_attr[ATTR_GROUP_MEMBER_UNREACHABLE] = True + + return state_attr - async def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs) -> None: """Turn the group on.""" await self._device.turn_on() - async def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs) -> None: """Turn the group off.""" await self._device.turn_off() @@ -132,29 +145,36 @@ def today_energy_kwh(self) -> int: return round(self._device.energyCounter) -class HomematicipMultiSwitch(HomematicipGenericDevice, SwitchDevice): +class HomematicipMultiSwitch(HomematicipGenericDevice, SwitchEntity): """Representation of a HomematicIP Cloud multi switch device.""" - def __init__(self, home: AsyncHome, device, channel: int): + def __init__(self, hap: HomematicipHAP, device, channel: int) -> None: """Initialize the multi switch device.""" self.channel = channel - super().__init__(home, device, 'Channel{}'.format(channel)) + super().__init__(hap, device, f"Channel{channel}") + + @property + def name(self) -> str: + """Return the name of the multi switch channel.""" + label = self._get_label_by_channel(self.channel) + if label: + return label + return super().name @property def unique_id(self) -> str: """Return a unique ID.""" - return "{}_{}_{}".format(self.__class__.__name__, - self.post, self._device.id) + return f"{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): + async def async_turn_on(self, **kwargs) -> None: """Turn the device on.""" await self._device.turn_on(self.channel) - async def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs) -> None: """Turn the device off.""" await self._device.turn_off(self.channel) diff --git a/homeassistant/components/homematicip_cloud/translations/bg.json b/homeassistant/components/homematicip_cloud/translations/bg.json new file mode 100644 index 0000000000000..dd04e1e82505b --- /dev/null +++ b/homeassistant/components/homematicip_cloud/translations/bg.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "\u0411\u0430\u0437\u043e\u0432\u0430\u0442\u0430 \u0441\u0442\u0430\u043d\u0446\u0438\u044f \u0435 \u0432\u0435\u0447\u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u0430", + "connection_aborted": "\u041d\u0435\u0432\u044a\u0437\u043c\u043e\u0436\u043d\u043e\u0441\u0442 \u0437\u0430 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435 \u0441 HMIP \u0441\u044a\u0440\u0432\u044a\u0440", + "unknown": "\u0412\u044a\u0437\u043d\u0438\u043a\u043d\u0430 \u043d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430." + }, + "error": { + "invalid_pin": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u0435\u043d \u041f\u0418\u041d \u043a\u043e\u0434, \u043c\u043e\u043b\u044f \u043e\u043f\u0438\u0442\u0430\u0439\u0442\u0435 \u043e\u0442\u043d\u043e\u0432\u043e.", + "press_the_button": "\u041c\u043e\u043b\u044f, \u043d\u0430\u0442\u0438\u0441\u043d\u0435\u0442\u0435 \u0441\u0438\u043d\u0438\u044f \u0431\u0443\u0442\u043e\u043d.", + "register_failed": "\u0420\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u044f\u0442\u0430 \u043d\u0435 \u0431\u0435 \u0443\u0441\u043f\u0435\u0448\u043d\u0430, \u043c\u043e\u043b\u044f \u043e\u043f\u0438\u0442\u0430\u0439\u0442\u0435 \u043e\u0442\u043d\u043e\u0432\u043e.", + "timeout_button": "\u0421\u0438\u043d\u0438\u044f \u0431\u0443\u0442\u043e\u043d \u043d\u0435 \u0431\u0435 \u043d\u0430\u0442\u0438\u0441\u043d\u0430\u0442 \u0441\u0432\u043e\u0435\u0432\u0440\u0435\u043c\u0435\u043d\u043d\u043e, \u043c\u043e\u043b\u044f \u043e\u043f\u0438\u0442\u0430\u0439\u0442\u0435 \u043e\u0442\u043d\u043e\u0432\u043e." + }, + "step": { + "init": { + "data": { + "hapid": "ID \u043d\u0430 \u0431\u0430\u0437\u043e\u0432\u0430 \u0441\u0442\u0430\u043d\u0446\u0438\u044f (SGTIN)", + "name": "\u0418\u043c\u0435 (\u043d\u0435\u0437\u0430\u0434\u044a\u043b\u0436\u0438\u0442\u0435\u043b\u043d\u043e, \u0438\u0437\u043f\u043e\u043b\u0437\u0432\u0430 \u0441\u0435 \u043a\u0430\u0442\u043e \u043f\u0440\u0435\u0444\u0438\u043a\u0441 \u043d\u0430 \u0438\u043c\u0435\u043d\u0430\u0442\u0430 \u043d\u0430 \u0432\u0441\u0438\u0447\u043a\u0438 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430)", + "pin": "\u041f\u0418\u041d \u043a\u043e\u0434 (\u043d\u0435\u0437\u0430\u0434\u044a\u043b\u0436\u0438\u0442\u0435\u043b\u043d\u043e)" + }, + "title": "\u0418\u0437\u0431\u0435\u0440\u0435\u0442\u0435 HomematicIP \u0431\u0430\u0437\u043e\u0432\u0430 \u0441\u0442\u0430\u043d\u0446\u0438\u044f" + }, + "link": { + "description": "\u041d\u0430\u0442\u0438\u0441\u043d\u0435\u0442\u0435 \u0441\u0438\u043d\u0438\u044f \u0431\u0443\u0442\u043e\u043d \u043d\u0430 \u0431\u0430\u0437\u043e\u0432\u0430\u0442\u0430 \u0441\u0442\u0430\u043d\u0446\u0438\u044f \u0438 \u043d\u0430\u0442\u0438\u0441\u043d\u0435\u0442\u0435 \u0431\u0443\u0442\u043e\u043d\u0430 \"\u0418\u0437\u043f\u0440\u0430\u0449\u0430\u043d\u0435\", \u0437\u0430 \u0434\u0430 \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u0430\u0442\u0435 HomematicIP \u0441 Home Assistant. \n\n![\u041c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u043d\u0430 \u0431\u0443\u0442\u043e\u043d\u0430 \u043d\u0430 \u043d\u0430 \u0431\u0430\u0437\u043e\u0432\u0430\u0442\u0430 \u0441\u0442\u0430\u043d\u0446\u0438\u044f](/static/images/config_flows/config_homematicip_cloud.png)", + "title": "\u0421\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435 \u043d\u0430 \u0431\u0430\u0437\u043e\u0432\u0430 \u0441\u0442\u0430\u043d\u0446\u0438\u044f" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/translations/ca.json b/homeassistant/components/homematicip_cloud/translations/ca.json new file mode 100644 index 0000000000000..4cb5e8d092dbd --- /dev/null +++ b/homeassistant/components/homematicip_cloud/translations/ca.json @@ -0,0 +1,29 @@ +{ + "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" + } + } + } +} \ 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..efb73dd6e7fc6 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/translations/cs.json @@ -0,0 +1,29 @@ +{ + "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" + } + } + } +} \ 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..a520aef37d768 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/translations/da.json @@ -0,0 +1,29 @@ +{ + "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" + } + } + } +} \ 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..f3ec84fe3aff3 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/translations/de.json @@ -0,0 +1,29 @@ +{ + "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" + } + } + } +} \ 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..26ca6eb60d6e9 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/translations/en.json @@ -0,0 +1,29 @@ +{ + "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" + } + } + } +} \ 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..a853d7677c8e6 --- /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)" + }, + "title": "Elija el punto de acceso HomematicIP" + }, + "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)" + } + } + } +} \ 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..a017a5a1df7ba --- /dev/null +++ b/homeassistant/components/homematicip_cloud/translations/es.json @@ -0,0 +1,29 @@ +{ + "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" + } + } + } +} \ 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..08bd179b4f9f1 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/translations/et.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "invalid_pin": "Vale PIN, palun proovige uuesti" + }, + "step": { + "init": { + "data": { + "hapid": "P\u00e4\u00e4supunkti ID (SGTIN)", + "pin": "PIN-kood (valikuline)" + } + } + } + } +} \ 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..212a6d277964c --- /dev/null +++ b/homeassistant/components/homematicip_cloud/translations/fr.json @@ -0,0 +1,29 @@ +{ + "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" + } + } + } +} \ 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..9e650e132fe1c --- /dev/null +++ b/homeassistant/components/homematicip_cloud/translations/he.json @@ -0,0 +1,29 @@ +{ + "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" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/translations/hr.json b/homeassistant/components/homematicip_cloud/translations/hr.json new file mode 100644 index 0000000000000..648dbfe73f98e --- /dev/null +++ b/homeassistant/components/homematicip_cloud/translations/hr.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "unknown": "Do\u0161lo je do nepoznate pogre\u0161ke." + } + } +} \ 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..a410d9e28b680 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/translations/hu.json @@ -0,0 +1,28 @@ +{ + "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" + } + } + } +} \ 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..8c99cddf6fb11 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/translations/id.json @@ -0,0 +1,29 @@ +{ + "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" + } + } + } +} \ 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..d00f082d2ed3d --- /dev/null +++ b/homeassistant/components/homematicip_cloud/translations/it.json @@ -0,0 +1,29 @@ +{ + "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 (opzionale, usato 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" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/.translations/ja.json b/homeassistant/components/homematicip_cloud/translations/ja.json similarity index 100% rename from homeassistant/components/homematicip_cloud/.translations/ja.json rename to homeassistant/components/homematicip_cloud/translations/ja.json diff --git a/homeassistant/components/homematicip_cloud/translations/ko.json b/homeassistant/components/homematicip_cloud/translations/ko.json new file mode 100644 index 0000000000000..630c23b6f9947 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/translations/ko.json @@ -0,0 +1,29 @@ +{ + "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\ud558\uae30" + }, + "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\ud558\uae30" + } + } + } +} \ 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..393a0689bff3c --- /dev/null +++ b/homeassistant/components/homematicip_cloud/translations/lb.json @@ -0,0 +1,29 @@ +{ + "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.\n\n![Standuert vum Kn\u00e4ppchen op der Bridge](/static/images/config_flows/config_homematicip_cloud.png)", + "title": "Accesspoint verbannen" + } + } + } +} \ 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..af5b37773b01e --- /dev/null +++ b/homeassistant/components/homematicip_cloud/translations/nl.json @@ -0,0 +1,29 @@ +{ + "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" + } + } + } +} \ 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..c4154e7489787 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/translations/nn.json @@ -0,0 +1,29 @@ +{ + "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" + } + } + } +} \ 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..e6a506a776808 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/translations/no.json @@ -0,0 +1,29 @@ +{ + "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" + } + } + } +} \ 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..7c0497c2d43d8 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/translations/pl.json @@ -0,0 +1,29 @@ +{ + "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" + } + } + } +} \ 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..5f7fc81bd9d37 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/translations/pt-BR.json @@ -0,0 +1,29 @@ +{ + "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" + } + } + } +} \ 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..649530de53c8d --- /dev/null +++ b/homeassistant/components/homematicip_cloud/translations/pt.json @@ -0,0 +1,29 @@ +{ + "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" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/.translations/ro.json b/homeassistant/components/homematicip_cloud/translations/ro.json similarity index 100% rename from homeassistant/components/homematicip_cloud/.translations/ro.json rename to homeassistant/components/homematicip_cloud/translations/ro.json diff --git a/homeassistant/components/homematicip_cloud/translations/ru.json b/homeassistant/components/homematicip_cloud/translations/ru.json new file mode 100644 index 0000000000000..58ee71e722b8f --- /dev/null +++ b/homeassistant/components/homematicip_cloud/translations/ru.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u044d\u0442\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\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": "\u041d\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" + } + } + } +} \ 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..a0179dfc90df5 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/translations/sl.json @@ -0,0 +1,29 @@ +{ + "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" + } + } + } +} \ 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..85c71ca3fad52 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/translations/sv.json @@ -0,0 +1,29 @@ +{ + "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" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/.translations/th.json b/homeassistant/components/homematicip_cloud/translations/th.json similarity index 100% rename from homeassistant/components/homematicip_cloud/.translations/th.json rename to homeassistant/components/homematicip_cloud/translations/th.json 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..02781ba48f474 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/translations/zh-Hans.json @@ -0,0 +1,29 @@ +{ + "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" + } + } + } +} \ 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..e457ce1631c07 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/translations/zh-Hant.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "Access point \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": "Access point ID (SGTIN)", + "name": "\u540d\u7a31\uff08\u9078\u9805\uff0c\u7528\u4ee5\u4f5c\u70ba\u6240\u6709\u8a2d\u5099\u7684\u5b57\u9996\u7528\uff09", + "pin": "PIN \u78bc\uff08\u9078\u9805\uff09" + }, + "title": "\u9078\u64c7 HomematicIP Access point" + }, + "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 Access point" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/weather.py b/homeassistant/components/homematicip_cloud/weather.py index b97948b2d9fa8..04f3b06cbb0b0 100644 --- a/homeassistant/components/homematicip_cloud/weather.py +++ b/homeassistant/components/homematicip_cloud/weather.py @@ -1,48 +1,66 @@ - """Support for HomematicIP Cloud weather devices.""" import logging from homematicip.aio.device import ( - AsyncWeatherSensor, AsyncWeatherSensorPlus, AsyncWeatherSensorPro) -from homematicip.aio.home import AsyncHome + AsyncWeatherSensor, + AsyncWeatherSensorPlus, + AsyncWeatherSensorPro, +) +from homematicip.base.enums import WeatherCondition from homeassistant.components.weather import WeatherEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import TEMP_CELSIUS -from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import HomeAssistantType -from . import DOMAIN as HMIPC_DOMAIN, HMIPC_HAPID, HomematicipGenericDevice +from . import DOMAIN as HMIPC_DOMAIN, HomematicipGenericDevice +from .hap import HomematicipHAP _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: +HOME_WEATHER_CONDITION = { + WeatherCondition.CLEAR: "sunny", + WeatherCondition.LIGHT_CLOUDY: "partlycloudy", + WeatherCondition.CLOUDY: "cloudy", + WeatherCondition.CLOUDY_WITH_RAIN: "rainy", + WeatherCondition.CLOUDY_WITH_SNOW_RAIN: "snowy-rainy", + WeatherCondition.HEAVILY_CLOUDY: "cloudy", + WeatherCondition.HEAVILY_CLOUDY_WITH_RAIN: "rainy", + WeatherCondition.HEAVILY_CLOUDY_WITH_STRONG_RAIN: "snowy-rainy", + WeatherCondition.HEAVILY_CLOUDY_WITH_SNOW: "snowy", + WeatherCondition.HEAVILY_CLOUDY_WITH_SNOW_RAIN: "snowy-rainy", + WeatherCondition.HEAVILY_CLOUDY_WITH_THUNDER: "lightning", + WeatherCondition.HEAVILY_CLOUDY_WITH_RAIN_AND_THUNDER: "lightning-rainy", + WeatherCondition.FOGGY: "fog", + WeatherCondition.STRONG_WIND: "windy", + WeatherCondition.UNKNOWN: "", +} + + +async def async_setup_entry( + hass: HomeAssistantType, 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: + hap = hass.data[HMIPC_DOMAIN][config_entry.unique_id] + entities = [] + for device in hap.home.devices: if isinstance(device, AsyncWeatherSensorPro): - devices.append(HomematicipWeatherSensorPro(home, device)) + entities.append(HomematicipWeatherSensorPro(hap, device)) elif isinstance(device, (AsyncWeatherSensor, AsyncWeatherSensorPlus)): - devices.append(HomematicipWeatherSensor(home, device)) + entities.append(HomematicipWeatherSensor(hap, device)) + + entities.append(HomematicipHomeWeather(hap)) - if devices: - async_add_entities(devices) + if entities: + async_add_entities(entities) class HomematicipWeatherSensor(HomematicipGenericDevice, WeatherEntity): """representation of a HomematicIP Cloud weather sensor plus & basic.""" - def __init__(self, home: AsyncHome, device) -> None: + def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the weather sensor.""" - super().__init__(home, device) + super().__init__(hap, device) @property def name(self) -> str: @@ -77,13 +95,13 @@ def attribution(self) -> str: @property def condition(self) -> str: """Return the current condition.""" - if hasattr(self._device, "raining") and self._device.raining: - return 'rainy' + if getattr(self._device, "raining", None): + return "rainy" if self._device.storm: - return 'windy' + return "windy" if self._device.sunshine: - return 'sunny' - return '' + return "sunny" + return "" class HomematicipWeatherSensorPro(HomematicipWeatherSensor): @@ -93,3 +111,57 @@ class HomematicipWeatherSensorPro(HomematicipWeatherSensor): def wind_bearing(self) -> float: """Return the wind bearing.""" return self._device.windDirection + + +class HomematicipHomeWeather(HomematicipGenericDevice, WeatherEntity): + """representation of a HomematicIP Cloud home weather.""" + + def __init__(self, hap: HomematicipHAP) -> None: + """Initialize the home weather.""" + hap.home.modelType = "HmIP-Home-Weather" + super().__init__(hap, hap.home) + + @property + def available(self) -> bool: + """Device available.""" + return self._home.connected + + @property + def name(self) -> str: + """Return the name of the sensor.""" + return f"Weather {self._home.location.city}" + + @property + def temperature(self) -> float: + """Return the platform temperature.""" + return self._device.weather.temperature + + @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.weather.humidity + + @property + def wind_speed(self) -> float: + """Return the wind speed.""" + return round(self._device.weather.windSpeed, 1) + + @property + def wind_bearing(self) -> float: + """Return the wind bearing.""" + return self._device.weather.windDirection + + @property + def attribution(self) -> str: + """Return the attribution.""" + return "Powered by Homematic IP" + + @property + def condition(self) -> str: + """Return the current condition.""" + return HOME_WEATHER_CONDITION.get(self._device.weather.weatherCondition) diff --git a/homeassistant/components/homeworks/__init__.py b/homeassistant/components/homeworks/__init__.py index b722a5a4a2de2..7ae3d30de9081 100644 --- a/homeassistant/components/homeworks/__init__.py +++ b/homeassistant/components/homeworks/__init__.py @@ -1,66 +1,76 @@ """Support for Lutron Homeworks Series 4 and 8 systems.""" import logging +from pyhomeworks.pyhomeworks import HW_BUTTON_PRESSED, HW_BUTTON_RELEASED, Homeworks import voluptuous as vol from homeassistant.const import ( - CONF_HOST, CONF_ID, CONF_NAME, CONF_PORT, EVENT_HOMEASSISTANT_STOP) + 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.helpers.dispatcher import async_dispatcher_connect, dispatcher_send from homeassistant.util import slugify _LOGGER = logging.getLogger(__name__) -DOMAIN = 'homeworks' +DOMAIN = "homeworks" -HOMEWORKS_CONTROLLER = 'homeworks' -ENTITY_SIGNAL = 'homeworks_entity_{}' -EVENT_BUTTON_PRESS = 'homeworks_button_press' -EVENT_BUTTON_RELEASE = 'homeworks_button_release' +HOMEWORKS_CONTROLLER = "homeworks" +EVENT_BUTTON_PRESS = "homeworks_button_press" +EVENT_BUTTON_RELEASE = "homeworks_button_release" -CONF_DIMMERS = 'dimmers' -CONF_KEYPADS = 'keypads' -CONF_ADDR = 'addr' -CONF_RATE = 'rate' +CONF_DIMMERS = "dimmers" +CONF_KEYPADS = "keypads" +CONF_ADDR = "addr" +CONF_RATE = "rate" -FADE_RATE = 1. +FADE_RATE = 1.0 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) +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) + _LOGGER.debug("callback: %s, %s", msg_type, values) addr = values[0] - signal = ENTITY_SIGNAL.format(addr) + signal = f"homeworks_entity_{addr}" dispatcher_send(hass, signal, msg_type, values) config = base_config.get(DOMAIN) @@ -73,7 +83,7 @@ def cleanup(event): hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, cleanup) dimmers = config[CONF_DIMMERS] - load_platform(hass, 'light', DOMAIN, {CONF_DIMMERS: dimmers}, base_config) + load_platform(hass, "light", DOMAIN, {CONF_DIMMERS: dimmers}, base_config) for key_config in config[CONF_KEYPADS]: addr = key_config[CONF_ADDR] @@ -83,11 +93,11 @@ def cleanup(event): return True -class HomeworksDevice(): +class HomeworksDevice: """Base class of a Homeworks device.""" def __init__(self, controller, addr, name): - """Controller, address, and name of the device.""" + """Initialize Homeworks device.""" self._addr = addr self._name = name self._controller = controller @@ -95,7 +105,7 @@ def __init__(self, controller, addr, name): @property def unique_id(self): """Return a unique identifier.""" - return 'homeworks.{}'.format(self._addr) + return f"homeworks.{self._addr}" @property def name(self): @@ -121,20 +131,18 @@ def __init__(self, hass, addr, name): 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) + signal = f"homeworks_entity_{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]} + 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 index 710be7c0077ae..a5a3b9ed07713 100644 --- a/homeassistant/components/homeworks/light.py +++ b/homeassistant/components/homeworks/light.py @@ -1,15 +1,18 @@ """Support for Lutron Homeworks lights.""" import logging +from pyhomeworks.pyhomeworks import HW_LIGHT_CHANGED + from homeassistant.components.light import ( - ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, Light) + ATTR_BRIGHTNESS, + SUPPORT_BRIGHTNESS, + LightEntity, +) 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) +from . import CONF_ADDR, CONF_DIMMERS, CONF_RATE, HOMEWORKS_CONTROLLER, HomeworksDevice _LOGGER = logging.getLogger(__name__) @@ -22,13 +25,14 @@ def setup_platform(hass, config, add_entities, discover_info=None): 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]) + dev = HomeworksLight( + controller, dimmer[CONF_ADDR], dimmer[CONF_NAME], dimmer[CONF_RATE] + ) devs.append(dev) add_entities(devs, True) -class HomeworksLight(HomeworksDevice, Light): +class HomeworksLight(HomeworksDevice, LightEntity): """Homeworks Light.""" def __init__(self, controller, addr, name, rate): @@ -40,10 +44,11 @@ def __init__(self, controller, addr, name, rate): 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) + signal = f"homeworks_entity_{self._addr}" + _LOGGER.debug("connecting %s", signal) + self.async_on_remove( + async_dispatcher_connect(self.hass, signal, self._update_callback) + ) self._controller.request_dimmer_level(self._addr) @property @@ -73,13 +78,13 @@ def brightness(self): 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) + float((level * 100.0) / 255.0), self._rate, 0, self._addr + ) @property def device_state_attributes(self): """Supported attributes.""" - return {'homeworks_address': self._addr} + return {"homeworks_address": self._addr} @property def is_on(self): @@ -89,10 +94,9 @@ def is_on(self): @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.) + self._level = int((values[1] * 255.0) / 100.0) if self._level != 0: self._prev_level = self._level - self.async_schedule_update_ha_state() + self.async_write_ha_state() diff --git a/homeassistant/components/homeworks/manifest.json b/homeassistant/components/homeworks/manifest.json index cdbbffb8d3686..9432e80d04e70 100644 --- a/homeassistant/components/homeworks/manifest.json +++ b/homeassistant/components/homeworks/manifest.json @@ -1,10 +1,7 @@ { "domain": "homeworks", - "name": "Homeworks", - "documentation": "https://www.home-assistant.io/components/homeworks", - "requirements": [ - "pyhomeworks==0.0.6" - ], - "dependencies": [], + "name": "Lutron Homeworks", + "documentation": "https://www.home-assistant.io/integrations/homeworks", + "requirements": ["pyhomeworks==0.0.6"], "codeowners": [] } diff --git a/homeassistant/components/honeywell/__init__.py b/homeassistant/components/honeywell/__init__.py index 59a90711e5752..57176c9acf807 100644 --- a/homeassistant/components/honeywell/__init__.py +++ b/homeassistant/components/honeywell/__init__.py @@ -1 +1 @@ -"""Support for Honeywell Round Connected and Honeywell Evohome thermostats.""" +"""Support for Honeywell (US) Total Connect Comfort climate systems.""" diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py index 5a07b094e24c0..5969dcdcc2793 100644 --- a/homeassistant/components/honeywell/climate.py +++ b/homeassistant/components/honeywell/climate.py @@ -1,345 +1,357 @@ -"""Support for Honeywell Round Connected and Honeywell Evohome thermostats.""" -import logging +"""Support for Honeywell (US) Total Connect Comfort climate systems.""" import datetime +import logging +from typing import Any, Dict, List, Optional import requests +import somecomfort import voluptuous as vol -import homeassistant.helpers.config_validation as cv -from homeassistant.components.climate import ClimateDevice, PLATFORM_SCHEMA +from homeassistant.components.climate import PLATFORM_SCHEMA, ClimateEntity 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) + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + CURRENT_HVAC_COOL, + CURRENT_HVAC_FAN, + CURRENT_HVAC_HEAT, + CURRENT_HVAC_IDLE, + FAN_AUTO, + FAN_DIFFUSE, + FAN_ON, + HVAC_MODE_COOL, + HVAC_MODE_HEAT, + HVAC_MODE_HEAT_COOL, + HVAC_MODE_OFF, + PRESET_AWAY, + PRESET_NONE, + SUPPORT_AUX_HEAT, + SUPPORT_FAN_MODE, + SUPPORT_PRESET_MODE, + SUPPORT_TARGET_HUMIDITY, + SUPPORT_TARGET_TEMPERATURE, + SUPPORT_TARGET_TEMPERATURE_RANGE, +) from homeassistant.const import ( - CONF_PASSWORD, CONF_USERNAME, TEMP_CELSIUS, TEMP_FAHRENHEIT, - ATTR_TEMPERATURE, CONF_REGION) + ATTR_TEMPERATURE, + CONF_PASSWORD, + CONF_REGION, + CONF_USERNAME, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, +) +import homeassistant.helpers.config_validation as cv _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), -}) +ATTR_FAN_ACTION = "fan_action" + +CONF_COOL_AWAY_TEMPERATURE = "away_cool_temperature" +CONF_HEAT_AWAY_TEMPERATURE = "away_heat_temperature" + +DEFAULT_COOL_AWAY_TEMPERATURE = 88 +DEFAULT_HEAT_AWAY_TEMPERATURE = 61 +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_COOL_AWAY_TEMPERATURE, default=DEFAULT_COOL_AWAY_TEMPERATURE + ): vol.Coerce(int), + vol.Optional( + CONF_HEAT_AWAY_TEMPERATURE, default=DEFAULT_HEAT_AWAY_TEMPERATURE + ): vol.Coerce(int), + vol.Optional(CONF_REGION, default=DEFAULT_REGION): vol.In(REGIONS), + } +) + +HVAC_MODE_TO_HW_MODE = { + "SwitchOffAllowed": {HVAC_MODE_OFF: "off"}, + "SwitchAutoAllowed": {HVAC_MODE_HEAT_COOL: "auto"}, + "SwitchCoolAllowed": {HVAC_MODE_COOL: "cool"}, + "SwitchHeatAllowed": {HVAC_MODE_HEAT: "heat"}, +} +HW_MODE_TO_HVAC_MODE = { + "off": HVAC_MODE_OFF, + "emheat": HVAC_MODE_HEAT, + "heat": HVAC_MODE_HEAT, + "cool": HVAC_MODE_COOL, + "auto": HVAC_MODE_HEAT_COOL, +} +HW_MODE_TO_HA_HVAC_ACTION = { + "off": CURRENT_HVAC_IDLE, + "fan": CURRENT_HVAC_FAN, + "heat": CURRENT_HVAC_HEAT, + "cool": CURRENT_HVAC_COOL, +} +FAN_MODE_TO_HW = { + "fanModeOnAllowed": {FAN_ON: "on"}, + "fanModeAutoAllowed": {FAN_AUTO: "auto"}, + "fanModeCirculateAllowed": {FAN_DIFFUSE: "circulate"}, +} +HW_FAN_MODE_TO_HA = { + "on": FAN_ON, + "auto": FAN_AUTO, + "circulate": FAN_DIFFUSE, + "follow schedule": FAN_AUTO, +} 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.") - _LOGGER.warning( - "Please switch to the evohome component, " - "see: https://home-assistant.io/components/evohome") + if config.get(CONF_REGION) == "us": + try: + client = somecomfort.SomeComfort(username, password) + except somecomfort.AuthError: + _LOGGER.error("Failed to login to honeywell account %s", username) + return + except somecomfort.SomeComfortError: + _LOGGER.error( + "Failed to initialize the Honeywell client: " + "Check your configuration (username, password), " + "or maybe you have exceeded the API rate limit?" + ) + return - return _setup_round(username, password, config, add_entities) + 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 + _LOGGER.warning( + "The honeywell component has been deprecated for EU (i.e. non-US) " + "systems. For EU-based systems, use the evohome component, " + "see: https://www.home-assistant.io/integrations/evohome" + ) -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) +class HoneywellUSThermostat(ClimateEntity): + """Representation of a Honeywell US Thermostat.""" - 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): + def __init__( + self, client, device, cool_away_temp, heat_away_temp, username, password + ): """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._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.client, ATTR_SYSTEM_MODE): - supported |= SUPPORT_OPERATION_MODE - return supported + _LOGGER.debug("latestData = %s ", device._data) - @property - def name(self): - """Return the name of the honeywell, if any.""" - return self._name + # not all honeywell HVACs support all modes + mappings = [v for k, v in HVAC_MODE_TO_HW_MODE.items() if device.raw_ui_data[k]] + self._hvac_mode_map = {k: v for d in mappings for k, v in d.items()} - @property - def temperature_unit(self): - """Return the unit of measurement.""" - return TEMP_CELSIUS + self._supported_features = ( + SUPPORT_PRESET_MODE + | SUPPORT_TARGET_TEMPERATURE + | SUPPORT_TARGET_TEMPERATURE_RANGE + ) - @property - def current_temperature(self): - """Return the current temperature.""" - return self._current_temperature + if device._data["canControlHumidification"]: + self._supported_features |= SUPPORT_TARGET_HUMIDITY - @property - def target_temperature(self): - """Return the temperature we try to reach.""" - if self._is_dhw: - return None - return self._target_temperature + if device.raw_ui_data["SwitchEmergencyHeatAllowed"]: + self._supported_features |= SUPPORT_AUX_HEAT - def set_temperature(self, **kwargs): - """Set new target temperature.""" - temperature = kwargs.get(ATTR_TEMPERATURE) - if temperature is None: + if not device._data["hasFan"]: 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) + # not all honeywell fans support all modes + mappings = [v for k, v in FAN_MODE_TO_HW.items() if device.raw_fan_data[k]] + self._fan_mode_map = {k: v for d in mappings for k, v in d.items()} - @property - def is_away_mode_on(self): - """Return true if away mode is on.""" - return self._away + self._supported_features |= SUPPORT_FAN_MODE - 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 + @property + def name(self) -> Optional[str]: + """Return the name of the honeywell, if any.""" + return self._device.name - def turn_away_mode_on(self): - """Turn away on. + @property + def device_state_attributes(self) -> Dict[str, Any]: + """Return the device specific state attributes.""" + data = {} + data[ATTR_FAN_ACTION] = "running" if self._device.fan_running else "idle" + if self._device.raw_dr_data: + data["dr_phase"] = self._device.raw_dr_data.get("Phase") + return data - 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) + @property + def supported_features(self) -> int: + """Return the list of supported features.""" + return self._supported_features - def turn_away_mode_off(self): - """Turn away off.""" - self._away = False - self.client.cancel_temp_override(self._name) + @property + def min_temp(self) -> float: + """Return the minimum temperature.""" + if self.hvac_mode in [HVAC_MODE_COOL, HVAC_MODE_HEAT_COOL]: + return self._device.raw_ui_data["CoolLowerSetptLimit"] + if self.hvac_mode == HVAC_MODE_HEAT: + return self._device.raw_ui_data["HeatLowerSetptLimit"] + return None - 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 + @property + def max_temp(self) -> float: + """Return the maximum temperature.""" + if self.hvac_mode == HVAC_MODE_COOL: + return self._device.raw_ui_data["CoolUpperSetptLimit"] + if self.hvac_mode in [HVAC_MODE_HEAT, HVAC_MODE_HEAT_COOL]: + return self._device.raw_ui_data["HeatUpperSetptLimit"] + return None - except StopIteration: - _LOGGER.error("Did not receive any temperature data from the " - "evohomeclient API") - return + @property + def temperature_unit(self) -> str: + """Return the unit of measurement.""" + return TEMP_CELSIUS if self._device.temperature_unit == "C" else TEMP_FAHRENHEIT - 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 + @property + def current_humidity(self) -> Optional[int]: + """Return the current humidity.""" + return self._device.current_humidity - # 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'] + @property + def hvac_mode(self) -> str: + """Return hvac operation ie. heat, cool mode.""" + return HW_MODE_TO_HVAC_MODE[self._device.system_mode] + @property + def hvac_modes(self) -> List[str]: + """Return the list of available hvac operation modes.""" + return list(self._hvac_mode_map) -class HoneywellUSThermostat(ClimateDevice): - """Representation of a Honeywell US Thermostat.""" + @property + def hvac_action(self) -> Optional[str]: + """Return the current running hvac operation if supported.""" + if self.hvac_mode == HVAC_MODE_OFF: + return None + return HW_MODE_TO_HA_HVAC_ACTION[self._device.equipment_output_status] - 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 current_temperature(self) -> Optional[float]: + """Return the current temperature.""" + return self._device.current_temperature @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 + def target_temperature(self) -> Optional[float]: + """Return the temperature we try to reach.""" + if self.hvac_mode == HVAC_MODE_COOL: + return self._device.setpoint_cool + if self.hvac_mode == HVAC_MODE_HEAT: + return self._device.setpoint_heat + return None @property - def is_fan_on(self): - """Return true if fan is on.""" - return self._device.fan_running + def target_temperature_high(self) -> Optional[float]: + """Return the highbound target temperature we try to reach.""" + if self.hvac_mode == HVAC_MODE_HEAT_COOL: + return self._device.setpoint_cool + return None @property - def name(self): - """Return the name of the honeywell, if any.""" - return self._device.name + def target_temperature_low(self) -> Optional[float]: + """Return the lowbound target temperature we try to reach.""" + if self.hvac_mode == HVAC_MODE_HEAT_COOL: + return self._device.setpoint_heat + return None @property - def temperature_unit(self): - """Return the unit of measurement.""" - return (TEMP_CELSIUS if self._device.temperature_unit == 'C' - else TEMP_FAHRENHEIT) + def preset_mode(self) -> Optional[str]: + """Return the current preset mode, e.g., home, away, temp.""" + return PRESET_AWAY if self._away else None @property - def current_temperature(self): - """Return the current temperature.""" - return self._device.current_temperature + def preset_modes(self) -> Optional[List[str]]: + """Return a list of available preset modes.""" + return [PRESET_NONE, PRESET_AWAY] @property - def current_humidity(self): - """Return the current humidity.""" - return self._device.current_humidity + def is_aux_heat(self) -> Optional[str]: + """Return true if aux heater.""" + return self._device.system_mode == "emheat" @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 + def fan_mode(self) -> Optional[str]: + """Return the fan setting.""" + return HW_FAN_MODE_TO_HA[self._device.fan_mode] @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.""" + def fan_modes(self) -> Optional[List[str]]: + """Return the list of available fan modes.""" + return list(self._fan_mode_map) + + def _set_temperature(self, **kwargs) -> None: + """Set new 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: + if getattr(self._device, f"hold_{mode}") is False: # Get next period key - next_period_key = '{}NextPeriod'.format(mode.capitalize()) + next_period_key = f"{mode.capitalize()}NextPeriod" # 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)) + setattr(self._device, f"hold_{mode}", datetime.time(hour, minute)) # Set temperature - setattr(self._device, - "setpoint_{}".format(mode), - temperature) + setattr(self._device, f"setpoint_{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 set_temperature(self, **kwargs) -> None: + """Set new target temperature.""" + if {HVAC_MODE_COOL, HVAC_MODE_HEAT} & set(self._hvac_mode_map): + self._set_temperature(**kwargs) - def turn_away_mode_on(self): + try: + if HVAC_MODE_HEAT_COOL in self._hvac_mode_map: + temperature = kwargs.get(ATTR_TARGET_TEMP_HIGH) + if temperature: + self._device.setpoint_cool = temperature + temperature = kwargs.get(ATTR_TARGET_TEMP_LOW) + if temperature: + self._device.setpoint_heat = temperature + except somecomfort.SomeComfortError as err: + _LOGGER.error("Invalid temperature %s: %s", temperature, err) + + def set_fan_mode(self, fan_mode: str) -> None: + """Set new target fan mode.""" + self._device.fan_mode = self._fan_mode_map[fan_mode] + + def set_hvac_mode(self, hvac_mode: str) -> None: + """Set new target hvac mode.""" + self._device.system_mode = self._hvac_mode_map[hvac_mode] + + def _turn_away_mode_on(self) -> None: """Turn away on. Somecomfort does have a proprietary away mode, but it doesn't really @@ -347,84 +359,74 @@ def turn_away_mode_on(self): 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') + _LOGGER.error("Can not get system mode") return try: # Set permanent hold - setattr(self._device, - "hold_{}".format(mode), - True) + setattr(self._device, f"hold_{mode}", True) # Set temperature - setattr(self._device, - "setpoint_{}".format(mode), - getattr(self, "_{}_away_temp".format(mode))) + setattr( + self._device, f"setpoint_{mode}", getattr(self, f"_{mode}_away_temp") + ) except somecomfort.SomeComfortError: - _LOGGER.error('Temperature %.1f out of range', - getattr(self, "_{}_away_temp".format(mode))) + _LOGGER.error( + "Temperature %.1f out of range", getattr(self, f"_{mode}_away_temp") + ) - def turn_away_mode_off(self): + def _turn_away_mode_off(self) -> None: """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') + _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 set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + if preset_mode == PRESET_AWAY: + self._turn_away_mode_on() + else: + self._turn_away_mode_off() - 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 turn_aux_heat_on(self) -> None: + """Turn auxiliary heater on.""" + self._device.system_mode = "emheat" + + def turn_aux_heat_off(self) -> None: + """Turn auxiliary heater off.""" + if HVAC_MODE_HEAT in self.hvac_modes: + self.set_hvac_mode(HVAC_MODE_HEAT) + else: + self.set_hvac_mode(HVAC_MODE_OFF) - def _retry(self): + def _retry(self) -> bool: """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) + self._client = somecomfort.SomeComfort(self._username, self._password) except somecomfort.AuthError: - _LOGGER.error("Failed to login to honeywell account %s", - self._username) + _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)) + _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] + 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) @@ -432,3 +434,26 @@ def _retry(self): self._device = devices[0] return True + + def update(self) -> None: + """Update the state.""" + 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) + + _LOGGER.debug( + "latestData = %s ", self._device._data # pylint: disable=protected-access + ) diff --git a/homeassistant/components/honeywell/manifest.json b/homeassistant/components/honeywell/manifest.json index c3d76703e91dd..52abf20bb2fd6 100644 --- a/homeassistant/components/honeywell/manifest.json +++ b/homeassistant/components/honeywell/manifest.json @@ -1,11 +1,7 @@ { "domain": "honeywell", - "name": "Honeywell", - "documentation": "https://www.home-assistant.io/components/honeywell", - "requirements": [ - "evohomeclient==0.3.2", - "somecomfort==0.5.2" - ], - "dependencies": [], - "codeowners": [] + "name": "Honeywell Total Connect Comfort (US)", + "documentation": "https://www.home-assistant.io/integrations/honeywell", + "requirements": ["somecomfort==0.5.2"], + "codeowners": ["@zxdavb"] } diff --git a/homeassistant/components/hook/__init__.py b/homeassistant/components/hook/__init__.py deleted file mode 100644 index bc85e27d74264..0000000000000 --- a/homeassistant/components/hook/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""The hook component.""" diff --git a/homeassistant/components/hook/manifest.json b/homeassistant/components/hook/manifest.json deleted file mode 100644 index d9898a71f8b71..0000000000000 --- a/homeassistant/components/hook/manifest.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "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 deleted file mode 100644 index 7a11c1dd8b75e..0000000000000 --- a/homeassistant/components/hook/switch.py +++ /dev/null @@ -1,133 +0,0 @@ -"""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, loop=hass.loop): - 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, loop=hass.loop): - 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, loop=self.hass.loop): - 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/manifest.json b/homeassistant/components/horizon/manifest.json index 2916e81ce4f4e..0d89adb51093b 100644 --- a/homeassistant/components/horizon/manifest.json +++ b/homeassistant/components/horizon/manifest.json @@ -1,10 +1,7 @@ { "domain": "horizon", - "name": "Horizon", - "documentation": "https://www.home-assistant.io/components/horizon", - "requirements": [ - "horimote==0.4.1" - ], - "dependencies": [], + "name": "Unitymedia Horizon HD Recorder", + "documentation": "https://www.home-assistant.io/integrations/horizon", + "requirements": ["horimote==0.4.1"], "codeowners": [] } diff --git a/homeassistant/components/horizon/media_player.py b/homeassistant/components/horizon/media_player.py index ab72b051f1bd8..79a94538b70c1 100644 --- a/homeassistant/components/horizon/media_player.py +++ b/homeassistant/components/horizon/media_player.py @@ -2,43 +2,62 @@ from datetime import timedelta import logging +from horimote import Client, keys +from horimote.exceptions import AuthenticationError import voluptuous as vol from homeassistant import util -from homeassistant.components.media_player import ( - MediaPlayerDevice, PLATFORM_SCHEMA) +from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerEntity 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) + 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) + 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_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, -}) +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] @@ -59,15 +78,15 @@ def setup_platform(hass, config, add_entities, discovery_info=None): add_entities([HorizonDevice(client, name, keys)], True) -class HorizonDevice(MediaPlayerDevice): +class HorizonDevice(MediaPlayerEntity): """Representation of a Horizon HD Recorder.""" - def __init__(self, client, name, keys): + def __init__(self, client, name, remote_keys): """Initialize the remote.""" self._client = client self._name = name self._state = None - self._keys = keys + self._keys = remote_keys @property def name(self): @@ -142,8 +161,11 @@ def play_media(self, media_type, media_id, **kwargs): except ValueError: _LOGGER.error("Invalid channel: %s", media_id) else: - _LOGGER.error("Invalid media type %s. Supported type: %s", - media_type, MEDIA_TYPE_CHANNEL) + _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).""" @@ -155,7 +177,6 @@ def _send_key(self, key): def _send(self, key=None, channel=None): """Send a key to the Horizon device.""" - from horimote.exceptions import AuthenticationError try: if key: @@ -163,8 +184,9 @@ def _send(self, key=None, channel=None): elif channel: self._client.select_channel(channel) except OSError as msg: - _LOGGER.error("%s disconnected: %s. Trying to reconnect...", - self._name, msg) + _LOGGER.error( + "%s disconnected: %s. Trying to reconnect...", self._name, msg + ) # for reconnect, first gracefully disconnect self._client.disconnect() @@ -173,8 +195,7 @@ def _send(self, key=None, channel=None): self._client.connect() self._client.authorize() except AuthenticationError as msg: - _LOGGER.error("Authentication to %s failed: %s", self._name, - msg) + _LOGGER.error("Authentication to %s failed: %s", self._name, msg) return except OSError as msg: # occurs if horizon box is offline diff --git a/homeassistant/components/hp_ilo/manifest.json b/homeassistant/components/hp_ilo/manifest.json index 3df6632e47ab3..ea922edd59e10 100644 --- a/homeassistant/components/hp_ilo/manifest.json +++ b/homeassistant/components/hp_ilo/manifest.json @@ -1,10 +1,7 @@ { "domain": "hp_ilo", - "name": "Hp ilo", - "documentation": "https://www.home-assistant.io/components/hp_ilo", - "requirements": [ - "python-hpilo==3.9" - ], - "dependencies": [], + "name": "HP Integrated Lights-Out (ILO)", + "documentation": "https://www.home-assistant.io/integrations/hp_ilo", + "requirements": ["python-hpilo==4.3"], "codeowners": [] } diff --git a/homeassistant/components/hp_ilo/sensor.py b/homeassistant/components/hp_ilo/sensor.py index 46fde88561392..888fa2423ad67 100644 --- a/homeassistant/components/hp_ilo/sensor.py +++ b/homeassistant/components/hp_ilo/sensor.py @@ -2,13 +2,21 @@ from datetime import timedelta import logging +import hpilo 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) + 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 @@ -21,35 +29,43 @@ 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'] + "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, -}) +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): @@ -74,12 +90,11 @@ def setup_platform(hass, config, add_entities, discovery_info=None): new_device = HpIloSensor( hass=hass, hp_ilo_data=hp_ilo_data, - sensor_name='{} {}'.format( - config.get(CONF_NAME), monitored_variable[CONF_NAME]), + sensor_name=f"{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)) + unit_of_measurement=monitored_variable.get(CONF_UNIT_OF_MEASUREMENT), + ) devices.append(new_device) add_entities(devices, True) @@ -88,8 +103,15 @@ def setup_platform(hass, config, add_entities, discovery_info=None): 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): + 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 @@ -157,12 +179,16 @@ def __init__(self, host, port, login, password): @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)) + hostname=self._host, + login=self._login, + password=self._password, + port=self._port, + ) + except ( + hpilo.IloError, + hpilo.IloCommunicationError, + hpilo.IloLoginFailed, + ) as error: + raise ValueError(f"Unable to init HP ILO, {error}") diff --git a/homeassistant/components/html5/const.py b/homeassistant/components/html5/const.py new file mode 100644 index 0000000000000..1d0689511b2f8 --- /dev/null +++ b/homeassistant/components/html5/const.py @@ -0,0 +1,3 @@ +"""Constants for the HTML5 component.""" +DOMAIN = "html5" +SERVICE_DISMISS = "dismiss" diff --git a/homeassistant/components/html5/manifest.json b/homeassistant/components/html5/manifest.json index 7b43ec44ef386..1aaf4aed53965 100644 --- a/homeassistant/components/html5/manifest.json +++ b/homeassistant/components/html5/manifest.json @@ -1,12 +1,8 @@ { "domain": "html5", - "name": "HTML5 Notifications", - "documentation": "https://www.home-assistant.io/components/html5", - "requirements": [ - "pywebpush==1.9.2" - ], - "dependencies": ["frontend"], - "codeowners": [ - "@robbiet480" - ] + "name": "HTML5 Push Notifications", + "documentation": "https://www.home-assistant.io/integrations/html5", + "requirements": ["pywebpush==1.9.2"], + "dependencies": ["http"], + "codeowners": ["@robbiet480"] } diff --git a/homeassistant/components/html5/notify.py b/homeassistant/components/html5/notify.py index c8cd207da3e50..6970ebbedb4e0 100644 --- a/homeassistant/components/html5/notify.py +++ b/homeassistant/components/html5/notify.py @@ -1,41 +1,52 @@ """HTML5 Push Messaging notification service.""" from datetime import datetime, timedelta - from functools import partial import json import logging import time +from urllib.parse import urlparse import uuid from aiohttp.hdrs import AUTHORIZATION +import jwt +from py_vapid import Vapid +from pywebpush import WebPusher 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.components.notify import ( + ATTR_DATA, + ATTR_TARGET, + ATTR_TITLE, + ATTR_TITLE_DEFAULT, + PLATFORM_SCHEMA, + BaseNotificationService, +) from homeassistant.const import ( - HTTP_BAD_REQUEST, HTTP_INTERNAL_SERVER_ERROR, HTTP_UNAUTHORIZED, URL_ROOT) + 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) +from .const import DOMAIN, SERVICE_DISMISS _LOGGER = logging.getLogger(__name__) -REGISTRATIONS_FILE = 'html5_push_registrations.conf' - -SERVICE_DISMISS = 'html5_dismiss' +REGISTRATIONS_FILE = "html5_push_registrations.conf" -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' +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): @@ -45,92 +56,114 @@ def gcm_api_deprecated(value): "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/") + " see https://www.home-assistant.io/integrations/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' +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' +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 -}) +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, - }) + 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), - }) + 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, -}) +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 -}) +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, -}) +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' +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') + "actions", + "badge", + "body", + "dir", + "icon", + "image", + "lang", + "renotify", + "requireInteraction", + "tag", + "timestamp", + "vibrate", +) def get_service(hass, config, discovery_info=None): @@ -147,27 +180,24 @@ def get_service(hass, config, discovery_info=None): 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)) + 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(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)) + 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) + hass, gcm_api_key, vapid_prv_key, vapid_email, registrations, json_path + ) def _load_config(filename): @@ -182,8 +212,8 @@ def _load_config(filename): class HTML5PushRegistrationView(HomeAssistantView): """Accepts push registrations from a browser.""" - url = '/api/notify.html5' - name = 'api:notify.html5' + url = "/api/notify.html5" + name = "api:notify.html5" def __init__(self, registrations, json_path): """Init HTML5PushRegistrationView.""" @@ -195,12 +225,11 @@ async def post(self, request): try: data = await request.json() except ValueError: - return self.json_message('Invalid JSON', HTTP_BAD_REQUEST) + 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) + return self.json_message(humanize_error(data, ex), HTTP_BAD_REQUEST) devname = data.get(ATTR_NAME) data.pop(ATTR_NAME, None) @@ -211,12 +240,10 @@ async def post(self, request): self.registrations[name] = data try: - hass = request.app['hass'] + hass = request.app["hass"] - await hass.async_add_job(save_json, self.json_path, - self.registrations) - return self.json_message( - 'Push notification subscriber registered.') + 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 @@ -224,7 +251,8 @@ async def post(self, request): self.registrations.pop(name) return self.json_message( - 'Error saving registration.', HTTP_INTERNAL_SERVER_ERROR) + "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.""" @@ -233,15 +261,14 @@ def find_registration_name(self, data, suggested=None): subscription = registration.get(ATTR_SUBSCRIPTION) if subscription.get(ATTR_ENDPOINT) == endpoint: return key - return ensure_unique_string(suggested or 'unnamed device', - self.registrations) + 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) + return self.json_message("Invalid JSON", HTTP_BAD_REQUEST) subscription = data.get(ATTR_SUBSCRIPTION) @@ -254,29 +281,29 @@ async def delete(self, request): if not found: # If not found, unregistering was already done. Return 200 - return self.json_message('Registration not found.') + return self.json_message("Registration not found.") reg = self.registrations.pop(found) try: - hass = request.app['hass'] + hass = request.app["hass"] - await hass.async_add_job(save_json, self.json_path, - self.registrations) + 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) + "Error saving registration.", HTTP_INTERNAL_SERVER_ERROR + ) - return self.json_message('Push notification subscriber unregistered.') + 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' + url = "/api/notify.html5/callback" + name = "api:notify.html5/callback" def __init__(self, registrations): """Init HTML5PushCallbackView.""" @@ -284,7 +311,6 @@ def __init__(self, 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. @@ -300,36 +326,39 @@ def decode_jwt(self, token): except jwt.exceptions.DecodeError: pass - return self.json_message('No target found in JWT', - status_code=HTTP_UNAUTHORIZED) + 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) + + auth = request.headers.get(AUTHORIZATION) if not auth: - return self.json_message('Authorization header is expected', - status_code=HTTP_UNAUTHORIZED) + 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 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) + 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 self.json_message("token is invalid", status_code=HTTP_UNAUTHORIZED) return payload async def post(self, request): @@ -341,7 +370,7 @@ async def post(self, request): try: data = await request.json() except ValueError: - return self.json_message('Invalid JSON', HTTP_BAD_REQUEST) + return self.json_message("Invalid JSON", HTTP_BAD_REQUEST) event_payload = { ATTR_TAG: data.get(ATTR_TAG), @@ -358,20 +387,20 @@ async def post(self, request): 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)) + _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]}) + event_name = f"{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): + 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 @@ -393,8 +422,11 @@ async def async_dismiss_message(service): await self.async_dismiss(**kwargs) hass.services.async_register( - DOMAIN, SERVICE_DISMISS, async_dismiss_message, - schema=DISMISS_SERVICE_SCHEMA) + DOMAIN, + SERVICE_DISMISS, + async_dismiss_message, + schema=DISMISS_SERVICE_SCHEMA, + ) @property def targets(self): @@ -408,11 +440,7 @@ 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: {} - } + payload = {ATTR_TAG: tag, ATTR_DISMISS: True, ATTR_DATA: {}} self._push_message(payload, **kwargs) @@ -421,19 +449,18 @@ async def async_dismiss(self, **kwargs): This method must be run in the event loop. """ - await self.hass.async_add_executor_job( - partial(self.dismiss, **kwargs)) + 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, + "badge": "/static/images/notification-badge.png", + "body": message, ATTR_DATA: {}, - 'icon': '/static/icons/favicon-192x192.png', + "icon": "/static/icons/favicon-192x192.png", ATTR_TAG: tag, - ATTR_TITLE: kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) + ATTR_TITLE: kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT), } data = kwargs.get(ATTR_DATA) @@ -452,22 +479,23 @@ def send_message(self, message="", **kwargs): payload[ATTR_DATA] = data_tmp - if (payload[ATTR_DATA].get(ATTR_URL) is None and - payload.get(ATTR_ACTIONS) is None): + 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']: + if priority not in ["normal", "high"]: priority = DEFAULT_PRIORITY - payload['timestamp'] = (timestamp*1000) # Javascript ms since epoch + payload["timestamp"] = timestamp * 1000 # Javascript ms since epoch targets = kwargs.get(ATTR_TARGET) if not targets: @@ -478,42 +506,39 @@ def _push_message(self, payload, **kwargs): try: info = REGISTER_SCHEMA(info) except vol.Invalid: - _LOGGER.error("%s is not a valid HTML5 push notification" - " target", target) + _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]) + 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 - }) + 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 + 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] \ + 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 ) + 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): + if not save_json(self.registrations_json_path, self.registrations): self.registrations[target] = reg _LOGGER.error("Error saving registration") else: @@ -522,28 +547,26 @@ def _push_message(self, payload, **kwargs): 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') + + 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): + + 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) + "sub": f"mailto:{vapid_email}", + "aud": f"{url.scheme}://{url.netloc}", } vapid = Vapid.from_string(private_key=vapid_private_key) return vapid.sign(vapid_claims) diff --git a/homeassistant/components/html5/services.yaml b/homeassistant/components/html5/services.yaml index e69de29bb2d1d..f3df434159405 100644 --- a/homeassistant/components/html5/services.yaml +++ b/homeassistant/components/html5/services.yaml @@ -0,0 +1,9 @@ +dismiss: + description: Dismiss a html5 notification. + fields: + target: + description: An array of targets. Optional. + example: ["my_phone", "my_tablet"] + data: + description: Extended information of notification. Supports tag. Optional. + example: '{ "tag": "tagname" }' diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index ad64b38200af5..565f84fdb8a20 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -3,133 +3,122 @@ import logging import os import ssl -from typing import Optional +from typing import Optional, cast 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) + EVENT_HOMEASSISTANT_START, + EVENT_HOMEASSISTANT_STOP, + SERVER_PORT, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import storage import homeassistant.helpers.config_validation as cv +from homeassistant.loader import bind_hass 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 .const import KEY_AUTHENTICATED, KEY_HASS, KEY_HASS_USER, KEY_REAL_IP # noqa: F401 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' +from .view import HomeAssistantView # noqa: F401 + +# mypy: allow-untyped-defs, no-check-untyped-defs + +DOMAIN = "http" + +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_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' +DEFAULT_SERVER_HOST = "0.0.0.0" +DEFAULT_DEVELOPMENT = "0" +# To be able to load custom cards. +DEFAULT_CORS = "https://cast.home-assistant.io" NO_LOGIN_ATTEMPT_THRESHOLD = -1 +MAX_CLIENT_SIZE: int = 1024 ** 2 * 16 + +STORAGE_KEY = DOMAIN +STORAGE_VERSION = 1 + + +HTTP_SCHEMA = vol.Schema( + { + 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=[DEFAULT_CORS]): 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_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) + -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) +@bind_hass +async def async_get_last_config(hass: HomeAssistant) -> Optional[dict]: + """Return the last known working config.""" + store = storage.Store(hass, STORAGE_VERSION, STORAGE_KEY) + return cast(Optional[dict], await store.async_load()) class ApiConfig: """Configuration settings for API server.""" - def __init__(self, host: str, port: Optional[int] = SERVER_PORT, - use_ssl: bool = False) -> None: + 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 + self.use_ssl = use_ssl - host = host.rstrip('/') + host = host.rstrip("/") if host.startswith(("http://", "https://")): self.base_url = host elif use_ssl: - self.base_url = "https://{}".format(host) + self.base_url = f"https://{host}" else: - self.base_url = "http://{}".format(host) + self.base_url = f"http://{host}" if port is not None: - self.base_url += ':{}'.format(port) + self.base_url += f":{port}" async def async_setup(hass, config): @@ -139,7 +128,6 @@ async def async_setup(hass, config): 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) @@ -152,10 +140,6 @@ async def async_setup(hass, config): 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, @@ -180,6 +164,19 @@ async def start_server(event): hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_server) await server.start() + # If we are set up successful, we store the HTTP settings for safe mode. + store = storage.Store(hass, STORAGE_VERSION, STORAGE_KEY) + + if CONF_TRUSTED_PROXIES in conf: + conf_to_save = dict(conf) + conf_to_save[CONF_TRUSTED_PROXIES] = [ + str(ip.network_address) for ip in conf_to_save[CONF_TRUSTED_PROXIES] + ] + else: + conf_to_save = conf + + await store.async_save(conf_to_save) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, start_server) hass.http = server @@ -203,13 +200,25 @@ async def start_server(event): 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): + 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 = self.app = web.Application( + middlewares=[], client_max_size=MAX_CLIENT_SIZE + ) app[KEY_HASS] = hass # This order matters @@ -228,6 +237,7 @@ def __init__(self, hass, 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 @@ -245,17 +255,13 @@ def register_view(self, view): # Instantiate the view, if needed view = view() - if not hasattr(view, 'url'): + if not hasattr(view, "url"): class_name = view.__class__.__name__ - raise AttributeError( - '{0} missing required attribute "url"'.format(class_name) - ) + raise AttributeError(f'{class_name} missing required attribute "url"') - if not hasattr(view, 'name'): + if not hasattr(view, "name"): class_name = view.__class__.__name__ - raise AttributeError( - '{0} missing required attribute "name"'.format(class_name) - ) + raise AttributeError(f'{class_name} missing required attribute "name"') view.register(self.app, self.app.router) @@ -268,11 +274,12 @@ def register_redirect(self, url, redirect_to): for the redirect, otherwise it has to be a string with placeholders in rule syntax. """ - def redirect(request): + + async def redirect(request): """Redirect to location.""" raise HTTPMovedPermanently(redirect_to) - self.app.router.add_route('GET', url, redirect) + 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.""" @@ -285,15 +292,18 @@ def register_static_path(self, url_path, path, cache_headers=True): 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) + self.app.router.add_route("GET", url_path, serve_file) async def start(self): """Start the aiohttp server.""" @@ -304,18 +314,21 @@ async def start(self): else: context = ssl_util.server_context_modern() await self.hass.async_add_executor_job( - context.load_cert_chain, self.ssl_certificate, - self.ssl_key) + 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) + _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) + context.load_verify_locations, self.ssl_peer_certificate + ) else: context = None @@ -329,13 +342,15 @@ async def start(self): 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) + 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) + _LOGGER.error( + "Failed to create HTTP server at port %d: %s", self.server_port, error + ) async def stop(self): """Stop the aiohttp server.""" diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index 0d8e327e086e1..18d8ce72d912d 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -1,28 +1,23 @@ """Authentication for HTTP component.""" -import base64 import logging +import secrets 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, -) +from .const import KEY_AUTHENTICATED, KEY_HASS_USER, KEY_REAL_IP + +# mypy: allow-untyped-defs, no-check-untyped-defs _LOGGER = logging.getLogger(__name__) -DATA_API_PASSWORD = 'api_password' -DATA_SIGN_SECRET = 'http.auth.sign_secret' -SIGN_QUERY_PARAM = 'authSig' +DATA_API_PASSWORD = "api_password" +DATA_SIGN_SECRET = "http.auth.sign_secret" +SIGN_QUERY_PARAM = "authSig" @callback @@ -31,30 +26,20 @@ def async_sign_path(hass, refresh_token_id, path, expiration): secret = hass.data.get(DATA_SIGN_SECRET) if secret is None: - secret = hass.data[DATA_SIGN_SECRET] = generate_secret() + secret = hass.data[DATA_SIGN_SECRET] = secrets.token_hex() 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()) + encoded = jwt.encode( + {"iss": refresh_token_id, "path": path, "iat": now, "exp": now + expiration}, + secret, + algorithm="HS256", + ) + return f"{path}?{SIGN_QUERY_PARAM}=" f"{encoded.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): """ @@ -63,46 +48,21 @@ async def async_validate_auth_header(request): Basic auth_type is legacy code, should be removed with api_password. """ try: - auth_type, auth_val = \ - request.headers.get(hdrs.AUTHORIZATION).split(' ', 1) + 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 + if auth_type != "Bearer": + return False + + 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 async def async_validate_signed_request(request): """Validate a signed request.""" @@ -118,18 +78,15 @@ async def async_validate_signed_request(request): try: claims = jwt.decode( - signature, - secret, - algorithms=['HS256'], - options={'verify_iss': False} + signature, secret, algorithms=["HS256"], options={"verify_iss": False} ) except jwt.InvalidTokenError: return False - if claims['path'] != request.path: + if claims["path"] != request.path: return False - refresh_token = await hass.auth.async_get_refresh_token(claims['iss']) + refresh_token = await hass.auth.async_get_refresh_token(claims["iss"]) if refresh_token is None: return False @@ -137,80 +94,34 @@ async def async_validate_signed_request(request): 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 + if hdrs.AUTHORIZATION in request.headers and await async_validate_auth_header( + request + ): authenticated = True + auth_type = "bearer token" # 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])): + elif ( + request.method == "GET" + and SIGN_QUERY_PARAM in request.query + and await async_validate_signed_request(request) + ): authenticated = True + auth_type = "signed request" + + if authenticated: + _LOGGER.debug( + "Authenticated %s for %s using %s", + request[KEY_REAL_IP], + request.path, + auth_type, + ) request[KEY_AUTHENTICATED] = authenticated return await handler(request) diff --git a/homeassistant/components/http/ban.py b/homeassistant/components/http/ban.py index 92c41157a3350..8b8d2bc567130 100644 --- a/homeassistant/components/http/ban.py +++ b/homeassistant/components/http/ban.py @@ -3,13 +3,14 @@ from datetime import datetime from ipaddress import ip_address import logging -import os +from typing import List, Optional 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.const import HTTP_BAD_REQUEST from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv @@ -17,21 +18,23 @@ from .const import KEY_REAL_IP +# mypy: allow-untyped-defs, no-check-untyped-defs + _LOGGER = logging.getLogger(__name__) -KEY_BANNED_IPS = 'ha_banned_ips' -KEY_FAILED_LOGIN_ATTEMPTS = 'ha_failed_login_attempts' -KEY_LOGIN_THRESHOLD = 'ha_login_threshold' +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' +NOTIFICATION_ID_BAN = "ip-ban" +NOTIFICATION_ID_LOGIN = "http-login" -IP_BANS_FILE = 'ip_bans.yaml' -ATTR_BANNED_AT = 'banned_at' +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) -}) +SCHEMA_IP_BAN_ENTRY = vol.Schema( + {vol.Optional("banned_at"): vol.Any(None, cv.datetime)} +) @callback @@ -44,7 +47,8 @@ def setup_bans(hass, app, 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)) + hass, hass.config.path(IP_BANS_FILE) + ) app.on_startup.append(ban_startup) @@ -58,8 +62,9 @@ async def ban_middleware(request, handler): # 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]) + is_banned = any( + ip_ban.ip_address == ip_address_ for ip_ban in request.app[KEY_BANNED_IPS] + ) if is_banned: raise HTTPForbidden() @@ -73,12 +78,14 @@ async def ban_middleware(request, handler): 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: + if resp.status >= HTTP_BAD_REQUEST: await process_wrong_login(request) return resp + return handle_req @@ -90,35 +97,44 @@ async def process_wrong_login(request): """ remote_addr = request[KEY_REAL_IP] - msg = ('Login attempt or request with invalid authentication ' - 'from {}'.format(remote_addr)) + msg = f"Login attempt or request with invalid authentication from {remote_addr}" _LOGGER.warning(msg) - hass = request.app['hass'] + hass = request.app["hass"] hass.components.persistent_notification.async_create( - msg, 'Login attempt failed', NOTIFICATION_ID_LOGIN) + 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): + 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]): + # Supervisor IP should never be banned + if "hassio" in hass.config.components and hass.components.hassio.get_supervisor_ip() == str( + remote_addr + ): + return + + 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) + update_ip_bans_config, hass.config.path(IP_BANS_FILE), new_ban + ) - _LOGGER.warning( - "Banned IP %s for too many login attempts", remote_addr) + _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) + f"Too many login attempts from {remote_addr}", + "Banning IP address", + NOTIFICATION_ID_BAN, + ) async def process_success_login(request): @@ -131,43 +147,44 @@ async def process_success_login(request): 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): + 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) + 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: + def __init__(self, ip_ban: str, banned_at: Optional[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): +async def async_load_ip_bans_config(hass: HomeAssistant, path: str) -> List[IpBan]: """Load list of banned IPs from config file.""" - ip_list = [] - - if not os.path.isfile(path): - return ip_list + ip_list: List[IpBan] = [] 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)) + _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'])) + 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 @@ -175,11 +192,13 @@ async def async_load_ip_bans_config(hass: HomeAssistant, path: str): return ip_list -def update_ip_bans_config(path: str, ip_ban: IpBan): +def update_ip_bans_config(path: str, ip_ban: IpBan) -> None: """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') + 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 index f26220e63d173..9392e790d6299 100644 --- a/homeassistant/components/http/const.py +++ b/homeassistant/components/http/const.py @@ -1,5 +1,5 @@ """HTTP specific constants.""" -KEY_AUTHENTICATED = 'ha_authenticated' -KEY_HASS = 'hass' -KEY_HASS_USER = 'hass_user' -KEY_REAL_IP = 'ha_real_ip' +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 index 1ef70b5e0225b..2d99a049e4bbd 100644 --- a/homeassistant/components/http/cors.py +++ b/homeassistant/components/http/cors.py @@ -1,57 +1,78 @@ """Provide CORS support for the HTTP component.""" -from aiohttp.hdrs import ACCEPT, CONTENT_TYPE, ORIGIN, AUTHORIZATION +from aiohttp.hdrs import ACCEPT, AUTHORIZATION, CONTENT_TYPE, ORIGIN +from aiohttp.web_urldispatcher import Resource, ResourceRoute, StaticResource -from homeassistant.const import ( - HTTP_HEADER_HA_AUTH, HTTP_HEADER_X_REQUESTED_WITH) +from homeassistant.const import HTTP_HEADER_X_REQUESTED_WITH from homeassistant.core import callback +# mypy: allow-untyped-defs, no-check-untyped-defs + ALLOWED_CORS_HEADERS = [ - ORIGIN, ACCEPT, HTTP_HEADER_X_REQUESTED_WITH, CONTENT_TYPE, - HTTP_HEADER_HA_AUTH, AUTHORIZATION] + ORIGIN, + ACCEPT, + HTTP_HEADER_X_REQUESTED_WITH, + CONTENT_TYPE, + AUTHORIZATION, +] +VALID_CORS_TYPES = (Resource, ResourceRoute, StaticResource) @callback def setup_cors(app, origins): """Set up CORS.""" + # This import should remain here. That way the HTTP integration can always + # be imported by other integrations without it's requirements being installed. + # pylint: disable=import-outside-toplevel 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 = 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'): + if hasattr(route, "resource"): path = route.resource else: path = route + if not isinstance(path, VALID_CORS_TYPES): + return + path = path.canonical + if path.startswith("/api/hassio_ingress/"): + return + 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='*', - ) - }) + 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) + for resource in list(app.router.resources()): + _allow_cors(resource) app.on_startup.append(cors_startup) diff --git a/homeassistant/components/http/data_validator.py b/homeassistant/components/http/data_validator.py index 98686e5cabd10..84bdb4e1c174e 100644 --- a/homeassistant/components/http/data_validator.py +++ b/homeassistant/components/http/data_validator.py @@ -4,6 +4,10 @@ import voluptuous as vol +from homeassistant.const import HTTP_BAD_REQUEST + +# mypy: allow-untyped-defs + _LOGGER = logging.getLogger(__name__) @@ -18,11 +22,15 @@ class RequestDataValidator: def __init__(self, schema, allow_empty=False): """Initialize the decorator.""" + if isinstance(schema, dict): + schema = vol.Schema(schema) + 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.""" @@ -30,18 +38,18 @@ async def wrapper(view, request, *args, **kwargs): 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) + if not self._allow_empty or (await request.content.read()) != b"": + _LOGGER.error("Invalid JSON received.") + return view.json_message("Invalid JSON.", HTTP_BAD_REQUEST) data = {} try: - kwargs['data'] = self._schema(data) + kwargs["data"] = self._schema(data) except vol.Invalid as err: - _LOGGER.error('Data does not match schema: %s', err) + _LOGGER.error("Data does not match schema: %s", err) return view.json_message( - 'Message format incorrect: {}'.format(err), 400) + f"Message format incorrect: {err}", HTTP_BAD_REQUEST + ) result = await method(view, request, *args, **kwargs) return result diff --git a/homeassistant/components/http/manifest.json b/homeassistant/components/http/manifest.json index 0bc5586445dd6..2fd0be87a8b8e 100644 --- a/homeassistant/components/http/manifest.json +++ b/homeassistant/components/http/manifest.json @@ -1,12 +1,8 @@ { "domain": "http", "name": "HTTP", - "documentation": "https://www.home-assistant.io/components/http", - "requirements": [ - "aiohttp_cors==0.7.0" - ], - "dependencies": [], - "codeowners": [ - "@home-assistant/core" - ] + "documentation": "https://www.home-assistant.io/integrations/http", + "requirements": ["aiohttp_cors==0.7.0"], + "codeowners": ["@home-assistant/core"], + "quality_scale": "internal" } diff --git a/homeassistant/components/http/real_ip.py b/homeassistant/components/http/real_ip.py index 9bbf30bd9d17b..f2334ce0a2ff8 100644 --- a/homeassistant/components/http/real_ip.py +++ b/homeassistant/components/http/real_ip.py @@ -8,25 +8,31 @@ from .const import KEY_REAL_IP +# mypy: allow-untyped-defs + @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]) + 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)): + 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]) + request.headers.get(X_FORWARDED_FOR).split(", ")[-1] + ) except ValueError: pass diff --git a/homeassistant/components/http/static.py b/homeassistant/components/http/static.py index 4fac9bf1ae904..a5fe686a651bd 100644 --- a/homeassistant/components/http/static.py +++ b/homeassistant/components/http/static.py @@ -3,20 +3,20 @@ from aiohttp import hdrs from aiohttp.web import FileResponse -from aiohttp.web_exceptions import HTTPNotFound, HTTPForbidden +from aiohttp.web_exceptions import HTTPForbidden, HTTPNotFound from aiohttp.web_urldispatcher import StaticResource +# mypy: allow-untyped-defs + CACHE_TIME = 31 * 86400 # = 1 month -CACHE_HEADERS = {hdrs.CACHE_CONTROL: "public, max-age={}".format(CACHE_TIME)} +CACHE_HEADERS = {hdrs.CACHE_CONTROL: f"public, max-age={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'] + rel_url = request.match_info["filename"] try: filename = Path(rel_url) if filename.anchor: @@ -40,5 +40,9 @@ async def _handle(self, request): return await super()._handle(request) if filepath.is_file(): return FileResponse( - filepath, chunk_size=self._chunk_size, headers=CACHE_HEADERS) + filepath, + chunk_size=self._chunk_size, + # type ignore: https://github.com/aio-libs/aiohttp/pull/3976 + headers=CACHE_HEADERS, # type: ignore + ) raise HTTPNotFound diff --git a/homeassistant/components/http/view.py b/homeassistant/components/http/view.py index ea9ca6ac31f8e..40ca43ff69513 100644 --- a/homeassistant/components/http/view.py +++ b/homeassistant/components/http/view.py @@ -2,72 +2,82 @@ import asyncio import json import logging +from typing import List, Optional from aiohttp import web from aiohttp.web_exceptions import ( - HTTPBadRequest, HTTPInternalServerError, HTTPUnauthorized) + HTTPBadRequest, + HTTPInternalServerError, + HTTPUnauthorized, +) import voluptuous as vol from homeassistant import exceptions -from homeassistant.const import CONTENT_TYPE_JSON +from homeassistant.const import CONTENT_TYPE_JSON, HTTP_OK 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__) +# mypy: allow-untyped-defs, no-check-untyped-defs + + class HomeAssistantView: """Base view for all views.""" - url = None - extra_urls = [] + url: Optional[str] = None + extra_urls: List[str] = [] # Views inheriting from this class can override this requires_auth = True cors_allowed = False - # pylint: disable=no-self-use - def context(self, request): + @staticmethod + def context(request): """Generate a context from a request.""" - user = request.get('hass_user') + 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): + @staticmethod + def json(result, status_code=HTTP_OK, headers=None): """Return a JSON response.""" try: msg = json.dumps( result, sort_keys=True, cls=JSONEncoder, allow_nan=False - ).encode('UTF-8') + ).encode("UTF-8") except (ValueError, TypeError) as err: - _LOGGER.error('Unable to serialize to JSON: %s\n%s', err, result) + _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) + 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): + def json_message( + self, message, status_code=HTTP_OK, message_code=None, headers=None + ): """Return a JSON message response.""" - data = {'message': message} + data = {"message": message} if message_code is not None: - data['code'] = message_code + 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' + 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'): + for method in ("get", "post", "delete", "put", "patch", "head", "options"): handler = getattr(self, method, None) if not handler: @@ -82,13 +92,14 @@ def register(self, app, router): return for route in routes: - app['allow_cors'](route) + 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." + assert asyncio.iscoroutinefunction(handler) or is_callback( + handler + ), "Handler should be a coroutine or a callback." async def handle(request): """Handle incoming request.""" @@ -97,16 +108,15 @@ async def handle(request): 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() + if view.requires_auth and not authenticated: + raise HTTPUnauthorized() - _LOGGER.debug('Serving %s to %s (auth: %s)', - request.path, request.get(KEY_REAL_IP), authenticated) + _LOGGER.debug( + "Serving %s to %s (auth: %s)", + request.path, + request.get(KEY_REAL_IP), + authenticated, + ) try: result = handler(request, **request.match_info) @@ -124,18 +134,19 @@ async def handle(request): # The method handler returned a ready-made Response, how nice of it return result - status_code = 200 + status_code = HTTP_OK if isinstance(result, tuple): result, status_code = result if isinstance(result, str): - result = result.encode('utf-8') + result = result.encode("utf-8") elif result is None: - result = b'' + result = b"" elif not isinstance(result, bytes): - assert False, ('Result should be None, string, bytes or Response. ' - 'Got: {}').format(result) + assert ( + False + ), f"Result should be None, string, bytes or Response. Got: {result}" return web.Response(body=result, status=status_code) diff --git a/homeassistant/components/htu21d/manifest.json b/homeassistant/components/htu21d/manifest.json index 70093df9b55fd..18109aa40e461 100644 --- a/homeassistant/components/htu21d/manifest.json +++ b/homeassistant/components/htu21d/manifest.json @@ -1,11 +1,7 @@ { "domain": "htu21d", - "name": "Htu21d", - "documentation": "https://www.home-assistant.io/components/htu21d", - "requirements": [ - "i2csense==0.0.4", - "smbus-cffi==0.5.1" - ], - "dependencies": [], + "name": "HTU21D(F) Sensor", + "documentation": "https://www.home-assistant.io/integrations/htu21d", + "requirements": ["i2csense==0.0.4", "smbus-cffi==0.5.1"], "codeowners": [] } diff --git a/homeassistant/components/htu21d/sensor.py b/homeassistant/components/htu21d/sensor.py index 01c2b0399b9a7..5bd77d4dcb239 100644 --- a/homeassistant/components/htu21d/sensor.py +++ b/homeassistant/components/htu21d/sensor.py @@ -3,55 +3,55 @@ from functools import partial import logging +from i2csense.htu21d import HTU21D # pylint: disable=import-error +import smbus # pylint: disable=import-error import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_NAME, TEMP_FAHRENHEIT, UNIT_PERCENTAGE 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' +CONF_I2C_BUS = "i2c_bus" DEFAULT_I2C_BUS = 1 MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=5) -DEFAULT_NAME = 'HTU21D Sensor' +DEFAULT_NAME = "HTU21D Sensor" -SENSOR_TEMPERATURE = 'temperature' -SENSOR_HUMIDITY = 'humidity' +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), -}) +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): +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) - ) + 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, '%')] + dev = [ + HTU21DSensor(sensor_handler, name, SENSOR_TEMPERATURE, temp_unit), + HTU21DSensor(sensor_handler, name, SENSOR_HUMIDITY, UNIT_PERCENTAGE), + ] async_add_entities(dev) @@ -75,7 +75,7 @@ class HTU21DSensor(Entity): def __init__(self, htu21d_client, name, variable, unit): """Initialize the sensor.""" - self._name = '{}_{}'.format(name, variable) + self._name = f"{name}_{variable}" self._variable = variable self._unit_of_measurement = unit self._client = htu21d_client diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index 8e401dfd2395e..272efa5d7226a 100644 --- a/homeassistant/components/huawei_lte/__init__.py +++ b/homeassistant/components/huawei_lte/__init__.py @@ -1,147 +1,629 @@ """Support for Huawei LTE routers.""" + +from collections import defaultdict from datetime import timedelta -from functools import reduce +from functools import partial +import ipaddress import logging -import operator +import time +from typing import Any, Callable, Dict, List, Set, Tuple +from urllib.parse import urlparse -import voluptuous as vol import attr +from getmac import get_mac_address +from huawei_lte_api.AuthorizedConnection import AuthorizedConnection +from huawei_lte_api.Client import Client +from huawei_lte_api.Connection import Connection +from huawei_lte_api.exceptions import ( + ResponseErrorLoginRequiredException, + ResponseErrorNotSupportedException, +) +from requests.exceptions import Timeout +from url_normalize import url_normalize +import voluptuous as vol +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN +from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( - CONF_URL, CONF_USERNAME, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP, + CONF_NAME, + CONF_PASSWORD, + CONF_RECIPIENT, + CONF_URL, + CONF_USERNAME, + EVENT_HOMEASSISTANT_STOP, +) +from homeassistant.core import CALLBACK_TYPE +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + discovery, +) +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, + dispatcher_send, +) +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.typing import HomeAssistantType + +from .const import ( + ADMIN_SERVICES, + ALL_KEYS, + CONNECTION_TIMEOUT, + DEFAULT_DEVICE_NAME, + DEFAULT_NOTIFY_SERVICE_NAME, + DOMAIN, + KEY_DEVICE_BASIC_INFORMATION, + KEY_DEVICE_INFORMATION, + KEY_DEVICE_SIGNAL, + KEY_DIALUP_MOBILE_DATASWITCH, + KEY_MONITORING_MONTH_STATISTICS, + KEY_MONITORING_STATUS, + KEY_MONITORING_TRAFFIC_STATISTICS, + KEY_NET_CURRENT_PLMN, + KEY_NET_NET_MODE, + KEY_WLAN_HOST_LIST, + KEY_WLAN_WIFI_FEATURE_SWITCH, + NOTIFY_SUPPRESS_TIMEOUT, + SERVICE_CLEAR_TRAFFIC_STATISTICS, + SERVICE_REBOOT, + SERVICE_RESUME_INTEGRATION, + SERVICE_SUSPEND_INTEGRATION, + UPDATE_OPTIONS_SIGNAL, + UPDATE_SIGNAL, ) -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) +logging.getLogger("dicttoxml").setLevel(logging.WARNING) + +SCAN_INTERVAL = timedelta(seconds=10) + +NOTIFY_SCHEMA = vol.Any( + None, + vol.Schema( + { + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_RECIPIENT): vol.Any( + None, vol.All(cv.ensure_list, [cv.string]) + ), + } + ), +) -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=10) +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.All( + cv.ensure_list, + [ + vol.Schema( + { + vol.Required(CONF_URL): cv.url, + vol.Optional(CONF_USERNAME): cv.string, + vol.Optional(CONF_PASSWORD): cv.string, + vol.Optional(NOTIFY_DOMAIN): NOTIFY_SCHEMA, + } + ) + ], + ) + }, + extra=vol.ALLOW_EXTRA, +) -DOMAIN = 'huawei_lte' -DATA_KEY = 'huawei_lte' +SERVICE_SCHEMA = vol.Schema({vol.Optional(CONF_URL): cv.url}) -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) +CONFIG_ENTRY_PLATFORMS = ( + BINARY_SENSOR_DOMAIN, + DEVICE_TRACKER_DOMAIN, + SENSOR_DOMAIN, + SWITCH_DOMAIN, +) @attr.s -class RouterData: +class Router: """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") + connection: Connection = attr.ib() + url: str = attr.ib() + mac: str = attr.ib() + signal_update: CALLBACK_TYPE = attr.ib() - def __getitem__(self, path: str): - """ - Get value corresponding to a dotted path. + data: Dict[str, Any] = attr.ib(init=False, factory=dict) + subscriptions: Dict[str, Set[str]] = attr.ib( + init=False, + factory=lambda: defaultdict(set, ((x, {"initial_scan"}) for x in ALL_KEYS)), + ) + inflight_gets: Set[str] = attr.ib(init=False, factory=set) + unload_handlers: List[CALLBACK_TYPE] = attr.ib(init=False, factory=list) + client: Client + suspended = attr.ib(init=False, default=False) + notify_last_attempt: float = attr.ib(init=False, default=-1) + + def __attrs_post_init__(self): + """Set up internal state on init.""" + self.client = Client(self.connection) + + @property + def device_name(self) -> str: + """Get router device name.""" + for key, item in ( + (KEY_DEVICE_BASIC_INFORMATION, "devicename"), + (KEY_DEVICE_INFORMATION, "DeviceName"), + ): + try: + return self.data[key][item] + except (KeyError, TypeError): + pass + return DEFAULT_DEVICE_NAME + + @property + def device_connections(self) -> Set[Tuple[str, str]]: + """Get router connections for device registry.""" + return {(dr.CONNECTION_NETWORK_MAC, self.mac)} if self.mac else set() + + def _get_data(self, key: str, func: Callable[[None], Any]) -> None: + if not self.subscriptions.get(key): + return + if key in self.inflight_gets: + _LOGGER.debug("Skipping already inflight get for %s", key) + return + self.inflight_gets.add(key) + _LOGGER.debug("Getting %s for subscribers %s", key, self.subscriptions[key]) + try: + self.data[key] = func() + except ResponseErrorNotSupportedException: + _LOGGER.info( + "%s not supported by device, excluding from future updates", key + ) + self.subscriptions.pop(key) + except ResponseErrorLoginRequiredException: + if isinstance(self.connection, AuthorizedConnection): + _LOGGER.debug("Trying to authorize again...") + if self.connection.enforce_authorized_connection(): + _LOGGER.debug( + "...success, %s will be updated by a future periodic run", key, + ) + else: + _LOGGER.debug("...failed") + return + _LOGGER.info( + "%s requires authorization, excluding from future updates", key + ) + self.subscriptions.pop(key) + except Timeout: + grace_left = ( + self.notify_last_attempt - time.monotonic() + NOTIFY_SUPPRESS_TIMEOUT + ) + if grace_left > 0: + _LOGGER.debug( + "%s timed out, %.1fs notify timeout suppress grace remaining", + key, + grace_left, + exc_info=True, + ) + else: + raise + finally: + self.inflight_gets.discard(key) + _LOGGER.debug("%s=%s", key, self.data.get(key)) - 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(".") + def update(self) -> None: + """Update router data.""" + + if self.suspended: + _LOGGER.debug("Integration suspended, not updating data") + return + + self._get_data(KEY_DEVICE_INFORMATION, self.client.device.information) + if self.data.get(KEY_DEVICE_INFORMATION): + # Full information includes everything in basic + self.subscriptions.pop(KEY_DEVICE_BASIC_INFORMATION, None) + self._get_data( + KEY_DEVICE_BASIC_INFORMATION, self.client.device.basic_information + ) + self._get_data(KEY_DEVICE_SIGNAL, self.client.device.signal) + self._get_data( + KEY_DIALUP_MOBILE_DATASWITCH, self.client.dial_up.mobile_dataswitch + ) + self._get_data( + KEY_MONITORING_MONTH_STATISTICS, self.client.monitoring.month_statistics + ) + self._get_data(KEY_MONITORING_STATUS, self.client.monitoring.status) + self._get_data( + KEY_MONITORING_TRAFFIC_STATISTICS, self.client.monitoring.traffic_statistics + ) + self._get_data(KEY_NET_CURRENT_PLMN, self.client.net.current_plmn) + self._get_data(KEY_NET_NET_MODE, self.client.net.net_mode) + self._get_data(KEY_WLAN_HOST_LIST, self.client.wlan.host_list) + self._get_data( + KEY_WLAN_WIFI_FEATURE_SWITCH, self.client.wlan.wifi_feature_switch + ) + + self.signal_update() + + def logout(self) -> None: + """Log out router session.""" + if not isinstance(self.connection, AuthorizedConnection): + return try: - data = getattr(self, root) - except AttributeError as err: - raise KeyError from err - return reduce(operator.getitem, rest, data) + self.client.user.logout() + except ResponseErrorNotSupportedException: + _LOGGER.debug("Logout not supported by device", exc_info=True) + except ResponseErrorLoginRequiredException: + _LOGGER.debug("Logout not supported when not logged in", exc_info=True) + except Exception: # pylint: disable=broad-except + _LOGGER.warning("Logout error", exc_info=True) + + def cleanup(self, *_) -> None: + """Clean up resources.""" - def subscribe(self, path: str) -> None: - """Subscribe to given router data entries.""" - self._subscriptions.add(path.split(".")[0]) + self.subscriptions.clear() - def unsubscribe(self, path: str) -> None: - """Unsubscribe from given router data entries.""" - self._subscriptions.discard(path.split(".")[0]) + for handler in self.unload_handlers: + handler() + self.unload_handlers.clear() - @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) + self.logout() @attr.s class HuaweiLteData: """Shared state.""" - data = attr.ib(init=False, factory=dict) + hass_config: dict = attr.ib() + # Our YAML config, keyed by router URL + config: Dict[str, Dict[str, Any]] = attr.ib() + routers: Dict[str, Router] = attr.ib(init=False, factory=dict) + + +async def async_setup_entry(hass: HomeAssistantType, config_entry: ConfigEntry) -> bool: + """Set up Huawei LTE component from config entry.""" + url = config_entry.data[CONF_URL] + + # Override settings from YAML config, but only if they're changed in it + # Old values are stored as *_from_yaml in the config entry + yaml_config = hass.data[DOMAIN].config.get(url) + if yaml_config: + # Config values + new_data = {} + for key in CONF_USERNAME, CONF_PASSWORD: + if key in yaml_config: + value = yaml_config[key] + if value != config_entry.data.get(f"{key}_from_yaml"): + new_data[f"{key}_from_yaml"] = value + new_data[key] = value + # Options + new_options = {} + yaml_recipient = yaml_config.get(NOTIFY_DOMAIN, {}).get(CONF_RECIPIENT) + if yaml_recipient is not None and yaml_recipient != config_entry.options.get( + f"{CONF_RECIPIENT}_from_yaml" + ): + new_options[f"{CONF_RECIPIENT}_from_yaml"] = yaml_recipient + new_options[CONF_RECIPIENT] = yaml_recipient + yaml_notify_name = yaml_config.get(NOTIFY_DOMAIN, {}).get(CONF_NAME) + if ( + yaml_notify_name is not None + and yaml_notify_name != config_entry.options.get(f"{CONF_NAME}_from_yaml") + ): + new_options[f"{CONF_NAME}_from_yaml"] = yaml_notify_name + new_options[CONF_NAME] = yaml_notify_name + # Update entry if overrides were found + if new_data or new_options: + hass.config_entries.async_update_entry( + config_entry, + data={**config_entry.data, **new_data}, + options={**config_entry.options, **new_options}, + ) + + # Get MAC address for use in unique ids. Being able to use something + # from the API would be nice, but all of that seems to be available only + # through authenticated calls (e.g. device_information.SerialNumber), and + # we want this available and the same when unauthenticated too. + host = urlparse(url).hostname + try: + if ipaddress.ip_address(host).version == 6: + mode = "ip6" + else: + mode = "ip" + except ValueError: + mode = "hostname" + mac = await hass.async_add_executor_job(partial(get_mac_address, **{mode: host})) + + def get_connection() -> Connection: + """ + Set up a connection. - 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())) + Authorized one if username/pass specified (even if empty), unauthorized one otherwise. + """ + username = config_entry.data.get(CONF_USERNAME) + password = config_entry.data.get(CONF_PASSWORD) + if username or password: + connection = AuthorizedConnection( + url, username=username, password=password, timeout=CONNECTION_TIMEOUT + ) + else: + connection = Connection(url, timeout=CONNECTION_TIMEOUT) + return connection + + def signal_update() -> None: + """Signal updates to data.""" + dispatcher_send(hass, UPDATE_SIGNAL, url) + + try: + connection = await hass.async_add_executor_job(get_connection) + except Timeout as ex: + raise ConfigEntryNotReady from ex + + # Set up router and store reference to it + router = Router(connection, url, mac, signal_update) + hass.data[DOMAIN].routers[url] = router + + # Do initial data update + await hass.async_add_executor_job(router.update) + + # Clear all subscriptions, enabled entities will push back theirs + router.subscriptions.clear() + + # Set up device registry + device_data = {} + sw_version = None + if router.data.get(KEY_DEVICE_INFORMATION): + device_info = router.data[KEY_DEVICE_INFORMATION] + serial_number = device_info.get("SerialNumber") + if serial_number: + device_data["identifiers"] = {(DOMAIN, serial_number)} + sw_version = device_info.get("SoftwareVersion") + if device_info.get("DeviceName"): + device_data["model"] = device_info["DeviceName"] + if not sw_version and router.data.get(KEY_DEVICE_BASIC_INFORMATION): + sw_version = router.data[KEY_DEVICE_BASIC_INFORMATION].get("SoftwareVersion") + if sw_version: + device_data["sw_version"] = sw_version + device_registry = await dr.async_get_registry(hass) + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections=router.device_connections, + name=router.device_name, + manufacturer="Huawei", + **device_data, + ) - return None + # Forward config entry setup to platforms + for domain in CONFIG_ENTRY_PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(config_entry, domain) + ) + # Notify doesn't support config entry setup yet, load with discovery for now + await discovery.async_load_platform( + hass, + NOTIFY_DOMAIN, + DOMAIN, + { + CONF_URL: url, + CONF_NAME: config_entry.options.get(CONF_NAME, DEFAULT_NOTIFY_SERVICE_NAME), + CONF_RECIPIENT: config_entry.options.get(CONF_RECIPIENT), + }, + hass.data[DOMAIN].hass_config, + ) + # Add config entry options update listener + router.unload_handlers.append( + config_entry.add_update_listener(async_signal_options_update) + ) + + def _update_router(*_: Any) -> None: + """ + Update router data. + + Separate passthrough function because lambdas don't work with track_time_interval. + """ + router.update() + + # Set up periodic update + router.unload_handlers.append( + async_track_time_interval(hass, _update_router, SCAN_INTERVAL) + ) + + # Clean up at end + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, router.cleanup) -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 +async def async_unload_entry( + hass: HomeAssistantType, config_entry: ConfigEntry +) -> bool: + """Unload config entry.""" - url = lte_config[CONF_URL] - username = lte_config[CONF_USERNAME] - password = lte_config[CONF_PASSWORD] + # Forward config entry unload to platforms + for domain in CONFIG_ENTRY_PLATFORMS: + await hass.config_entries.async_forward_entry_unload(config_entry, domain) - connection = AuthorizedConnection( - url, - username=username, - password=password, - ) - client = Client(connection) + # Forget about the router and invoke its cleanup + router = hass.data[DOMAIN].routers.pop(config_entry.data[CONF_URL]) + await hass.async_add_executor_job(router.cleanup) - data = RouterData(client) - hass.data[DATA_KEY].data[url] = data + return True - def cleanup(event): - """Clean up resources.""" - client.user.logout() - hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, cleanup) +async def async_setup(hass: HomeAssistantType, config) -> bool: + """Set up Huawei LTE component.""" + + # Arrange our YAML config to dict with normalized URLs as keys + domain_config = {} + if DOMAIN not in hass.data: + hass.data[DOMAIN] = HuaweiLteData(hass_config=config, config=domain_config) + for router_config in config.get(DOMAIN, []): + domain_config[url_normalize(router_config.pop(CONF_URL))] = router_config + + def service_handler(service) -> None: + """Apply a service.""" + url = service.data.get(CONF_URL) + routers = hass.data[DOMAIN].routers + if url: + router = routers.get(url) + elif not routers: + _LOGGER.error("%s: no routers configured", service.service) + return + elif len(routers) == 1: + router = next(iter(routers.values())) + else: + _LOGGER.error( + "%s: more than one router configured, must specify one of URLs %s", + service.service, + sorted(routers), + ) + return + if not router: + _LOGGER.error("%s: router %s unavailable", service.service, url) + return + + if service.service == SERVICE_CLEAR_TRAFFIC_STATISTICS: + if router.suspended: + _LOGGER.debug("%s: ignored, integration suspended", service.service) + return + result = router.client.monitoring.set_clear_traffic() + _LOGGER.debug("%s: %s", service.service, result) + elif service.service == SERVICE_REBOOT: + if router.suspended: + _LOGGER.debug("%s: ignored, integration suspended", service.service) + return + result = router.client.device.reboot() + _LOGGER.debug("%s: %s", service.service, result) + elif service.service == SERVICE_RESUME_INTEGRATION: + # Login will be handled automatically on demand + router.suspended = False + _LOGGER.debug("%s: %s", service.service, "done") + elif service.service == SERVICE_SUSPEND_INTEGRATION: + router.logout() + router.suspended = True + _LOGGER.debug("%s: %s", service.service, "done") + else: + _LOGGER.error("%s: unsupported service", service.service) + + for service in ADMIN_SERVICES: + hass.helpers.service.async_register_admin_service( + DOMAIN, service, service_handler, schema=SERVICE_SCHEMA, + ) + + for url, router_config in domain_config.items(): + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_URL: url, + CONF_USERNAME: router_config.get(CONF_USERNAME), + CONF_PASSWORD: router_config.get(CONF_PASSWORD), + }, + ) + ) + + return True + + +async def async_signal_options_update( + hass: HomeAssistantType, config_entry: ConfigEntry +) -> None: + """Handle config entry options update.""" + async_dispatcher_send(hass, UPDATE_OPTIONS_SIGNAL, config_entry) + + +async def async_migrate_entry(hass: HomeAssistantType, config_entry: ConfigEntry): + """Migrate config entry to new version.""" + if config_entry.version == 1: + options = config_entry.options + recipient = options.get(CONF_RECIPIENT) + if isinstance(recipient, str): + options[CONF_RECIPIENT] = [x.strip() for x in recipient.split(",")] + config_entry.version = 2 + hass.config_entries.async_update_entry(config_entry, options=options) + _LOGGER.info("Migrated config entry to version %d", config_entry.version) + return True + + +@attr.s +class HuaweiLteBaseEntity(Entity): + """Huawei LTE entity base class.""" + + router: Router = attr.ib() + + _available: bool = attr.ib(init=False, default=True) + _unsub_handlers: List[Callable] = attr.ib(init=False, factory=list) + + @property + def _entity_name(self) -> str: + raise NotImplementedError + + @property + def _device_unique_id(self) -> str: + """Return unique ID for entity within a router.""" + raise NotImplementedError + + @property + def unique_id(self) -> str: + """Return unique ID for entity.""" + return f"{self.router.mac}-{self._device_unique_id}" + + @property + def name(self) -> str: + """Return entity name.""" + return f"Huawei {self.router.device_name} {self._entity_name}" + + @property + def available(self) -> bool: + """Return whether the entity is available.""" + return self._available + + @property + def should_poll(self) -> bool: + """Huawei LTE entities report their state without polling.""" + return False + + @property + def device_info(self) -> Dict[str, Any]: + """Get info for matching with parent router.""" + return {"connections": self.router.device_connections} + + async def async_update(self) -> None: + """Update state.""" + raise NotImplementedError + + async def async_update_options(self, config_entry: ConfigEntry) -> None: + """Update config entry options.""" + + async def async_added_to_hass(self) -> None: + """Connect to update signals.""" + self._unsub_handlers.append( + async_dispatcher_connect(self.hass, UPDATE_SIGNAL, self._async_maybe_update) + ) + self._unsub_handlers.append( + async_dispatcher_connect( + self.hass, UPDATE_OPTIONS_SIGNAL, self._async_maybe_update_options + ) + ) + + async def _async_maybe_update(self, url: str) -> None: + """Update state if the update signal comes from our router.""" + if url == self.router.url: + self.async_schedule_update_ha_state(True) + + async def _async_maybe_update_options(self, config_entry: ConfigEntry) -> None: + """Update options if the update signal comes from our router.""" + if config_entry.data[CONF_URL] == self.router.url: + await self.async_update_options(config_entry) + + async def async_will_remove_from_hass(self) -> None: + """Invoke unsubscription handlers.""" + for unsub in self._unsub_handlers: + unsub() + self._unsub_handlers.clear() diff --git a/homeassistant/components/huawei_lte/binary_sensor.py b/homeassistant/components/huawei_lte/binary_sensor.py new file mode 100644 index 0000000000000..575cc9789ca50 --- /dev/null +++ b/homeassistant/components/huawei_lte/binary_sensor.py @@ -0,0 +1,196 @@ +"""Support for Huawei LTE binary sensors.""" + +import logging +from typing import Optional + +import attr +from huawei_lte_api.enums.cradle import ConnectionStatusEnum + +from homeassistant.components.binary_sensor import ( + DOMAIN as BINARY_SENSOR_DOMAIN, + BinarySensorEntity, +) +from homeassistant.const import CONF_URL + +from . import HuaweiLteBaseEntity +from .const import DOMAIN, KEY_MONITORING_STATUS, KEY_WLAN_WIFI_FEATURE_SWITCH + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up from config entry.""" + router = hass.data[DOMAIN].routers[config_entry.data[CONF_URL]] + entities = [] + + if router.data.get(KEY_MONITORING_STATUS): + entities.append(HuaweiLteMobileConnectionBinarySensor(router)) + entities.append(HuaweiLteWifiStatusBinarySensor(router)) + entities.append(HuaweiLteWifi24ghzStatusBinarySensor(router)) + entities.append(HuaweiLteWifi5ghzStatusBinarySensor(router)) + + async_add_entities(entities, True) + + +@attr.s +class HuaweiLteBaseBinarySensor(HuaweiLteBaseEntity, BinarySensorEntity): + """Huawei LTE binary sensor device base class.""" + + key: str + item: str + _raw_state: Optional[str] = attr.ib(init=False, default=None) + + @property + def entity_registry_enabled_default(self) -> bool: + """Return if the entity should be enabled when first added to the entity registry.""" + return False + + @property + def _device_unique_id(self) -> str: + return f"{self.key}.{self.item}" + + async def async_added_to_hass(self): + """Subscribe to needed data on add.""" + await super().async_added_to_hass() + self.router.subscriptions[self.key].add(f"{BINARY_SENSOR_DOMAIN}/{self.item}") + + async def async_will_remove_from_hass(self): + """Unsubscribe from needed data on remove.""" + await super().async_will_remove_from_hass() + self.router.subscriptions[self.key].remove( + f"{BINARY_SENSOR_DOMAIN}/{self.item}" + ) + + async def async_update(self): + """Update state.""" + try: + value = self.router.data[self.key][self.item] + except KeyError: + _LOGGER.debug("%s[%s] not in data", self.key, self.item) + self._available = False + return + self._available = True + self._raw_state = str(value) + + +CONNECTION_STATE_ATTRIBUTES = { + str(ConnectionStatusEnum.CONNECTING): "Connecting", + str(ConnectionStatusEnum.DISCONNECTING): "Disconnecting", + str(ConnectionStatusEnum.CONNECT_FAILED): "Connect failed", + str(ConnectionStatusEnum.CONNECT_STATUS_NULL): "Status not available", + str(ConnectionStatusEnum.CONNECT_STATUS_ERROR): "Status error", +} + + +@attr.s +class HuaweiLteMobileConnectionBinarySensor(HuaweiLteBaseBinarySensor): + """Huawei LTE mobile connection binary sensor.""" + + def __attrs_post_init__(self): + """Initialize identifiers.""" + self.key = KEY_MONITORING_STATUS + self.item = "ConnectionStatus" + + @property + def _entity_name(self) -> str: + return "Mobile connection" + + @property + def is_on(self) -> bool: + """Return whether the binary sensor is on.""" + return self._raw_state and int(self._raw_state) in ( + ConnectionStatusEnum.CONNECTED, + ConnectionStatusEnum.DISCONNECTING, + ) + + @property + def assumed_state(self) -> bool: + """Return True if real state is assumed, not known.""" + return not self._raw_state or int(self._raw_state) not in ( + ConnectionStatusEnum.CONNECT_FAILED, + ConnectionStatusEnum.CONNECTED, + ConnectionStatusEnum.DISCONNECTED, + ) + + @property + def icon(self): + """Return mobile connectivity sensor icon.""" + return "mdi:signal" if self.is_on else "mdi:signal-off" + + @property + def entity_registry_enabled_default(self) -> bool: + """Return if the entity should be enabled when first added to the entity registry.""" + return True + + @property + def device_state_attributes(self): + """Get additional attributes related to connection status.""" + attributes = super().device_state_attributes + if self._raw_state in CONNECTION_STATE_ATTRIBUTES: + if attributes is None: + attributes = {} + attributes["additional_state"] = CONNECTION_STATE_ATTRIBUTES[ + self._raw_state + ] + return attributes + + +class HuaweiLteBaseWifiStatusBinarySensor(HuaweiLteBaseBinarySensor): + """Huawei LTE WiFi status binary sensor base class.""" + + @property + def is_on(self) -> bool: + """Return whether the binary sensor is on.""" + return self._raw_state is not None and int(self._raw_state) == 1 + + @property + def assumed_state(self) -> bool: + """Return True if real state is assumed, not known.""" + return self._raw_state is None + + @property + def icon(self): + """Return WiFi status sensor icon.""" + return "mdi:wifi" if self.is_on else "mdi:wifi-off" + + +@attr.s +class HuaweiLteWifiStatusBinarySensor(HuaweiLteBaseWifiStatusBinarySensor): + """Huawei LTE WiFi status binary sensor.""" + + def __attrs_post_init__(self): + """Initialize identifiers.""" + self.key = KEY_MONITORING_STATUS + self.item = "WifiStatus" + + @property + def _entity_name(self) -> str: + return "WiFi status" + + +@attr.s +class HuaweiLteWifi24ghzStatusBinarySensor(HuaweiLteBaseWifiStatusBinarySensor): + """Huawei LTE 2.4GHz WiFi status binary sensor.""" + + def __attrs_post_init__(self): + """Initialize identifiers.""" + self.key = KEY_WLAN_WIFI_FEATURE_SWITCH + self.item = "wifi24g_switch_enable" + + @property + def _entity_name(self) -> str: + return "2.4GHz WiFi status" + + +@attr.s +class HuaweiLteWifi5ghzStatusBinarySensor(HuaweiLteBaseWifiStatusBinarySensor): + """Huawei LTE 5GHz WiFi status binary sensor.""" + + def __attrs_post_init__(self): + """Initialize identifiers.""" + self.key = KEY_WLAN_WIFI_FEATURE_SWITCH + self.item = "wifi5g_enabled" + + @property + def _entity_name(self) -> str: + return "5GHz WiFi status" diff --git a/homeassistant/components/huawei_lte/config_flow.py b/homeassistant/components/huawei_lte/config_flow.py new file mode 100644 index 0000000000000..223ca9dc34aa0 --- /dev/null +++ b/homeassistant/components/huawei_lte/config_flow.py @@ -0,0 +1,278 @@ +"""Config flow for the Huawei LTE platform.""" + +from collections import OrderedDict +import logging +from typing import Optional +from urllib.parse import urlparse + +from huawei_lte_api.AuthorizedConnection import AuthorizedConnection +from huawei_lte_api.Client import Client +from huawei_lte_api.Connection import Connection +from huawei_lte_api.exceptions import ( + LoginErrorPasswordWrongException, + LoginErrorUsernamePasswordOverrunException, + LoginErrorUsernamePasswordWrongException, + LoginErrorUsernameWrongException, + ResponseErrorException, +) +from requests.exceptions import Timeout +from url_normalize import url_normalize +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components import ssdp +from homeassistant.const import ( + CONF_NAME, + CONF_PASSWORD, + CONF_RECIPIENT, + CONF_URL, + CONF_USERNAME, +) +from homeassistant.core import callback + +# see https://github.com/PyCQA/pylint/issues/3202 about the DOMAIN's pylint issue +from .const import CONNECTION_TIMEOUT, DEFAULT_DEVICE_NAME, DEFAULT_NOTIFY_SERVICE_NAME +from .const import DOMAIN # pylint: disable=unused-import + +_LOGGER = logging.getLogger(__name__) + + +class ConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle Huawei LTE config flow.""" + + VERSION = 2 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get options flow.""" + return OptionsFlowHandler(config_entry) + + async def _async_show_user_form(self, user_input=None, errors=None): + if user_input is None: + user_input = {} + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + OrderedDict( + ( + ( + vol.Required( + CONF_URL, + default=user_input.get( + CONF_URL, + # https://github.com/PyCQA/pylint/issues/3167 + self.context.get( # pylint: disable=no-member + CONF_URL, "" + ), + ), + ), + str, + ), + ( + vol.Optional( + CONF_USERNAME, default=user_input.get(CONF_USERNAME, "") + ), + str, + ), + ( + vol.Optional( + CONF_PASSWORD, default=user_input.get(CONF_PASSWORD, "") + ), + str, + ), + ) + ) + ), + errors=errors or {}, + ) + + async def async_step_import(self, user_input=None): + """Handle import initiated config flow.""" + return await self.async_step_user(user_input) + + def _already_configured(self, user_input): + """See if we already have a router matching user input configured.""" + existing_urls = { + url_normalize(entry.data[CONF_URL], default_scheme="http") + for entry in self._async_current_entries() + } + return user_input[CONF_URL] in existing_urls + + async def async_step_user(self, user_input=None): + """Handle user initiated config flow.""" + if user_input is None: + return await self._async_show_user_form() + + errors = {} + + # Normalize URL + user_input[CONF_URL] = url_normalize( + user_input[CONF_URL], default_scheme="http" + ) + if "://" not in user_input[CONF_URL]: + errors[CONF_URL] = "invalid_url" + return await self._async_show_user_form( + user_input=user_input, errors=errors + ) + + if self._already_configured(user_input): + return self.async_abort(reason="already_configured") + + conn = None + + def logout(): + if hasattr(conn, "user"): + try: + conn.user.logout() + except Exception: # pylint: disable=broad-except + _LOGGER.debug("Could not logout", exc_info=True) + + def try_connect(username: Optional[str], password: Optional[str]) -> Connection: + """Try connecting with given credentials.""" + if username or password: + conn = AuthorizedConnection( + user_input[CONF_URL], + username=username, + password=password, + timeout=CONNECTION_TIMEOUT, + ) + else: + try: + conn = AuthorizedConnection( + user_input[CONF_URL], + username="", + password="", + timeout=CONNECTION_TIMEOUT, + ) + user_input[CONF_USERNAME] = "" + user_input[CONF_PASSWORD] = "" + except ResponseErrorException: + _LOGGER.debug( + "Could not login with empty credentials, proceeding unauthenticated", + exc_info=True, + ) + conn = Connection(user_input[CONF_URL], timeout=CONNECTION_TIMEOUT) + del user_input[CONF_USERNAME] + del user_input[CONF_PASSWORD] + return conn + + def get_router_title(conn: Connection) -> str: + """Get title for router.""" + title = None + client = Client(conn) + try: + info = client.device.basic_information() + except Exception: # pylint: disable=broad-except + _LOGGER.debug("Could not get device.basic_information", exc_info=True) + else: + title = info.get("devicename") + if not title: + try: + info = client.device.information() + except Exception: # pylint: disable=broad-except + _LOGGER.debug("Could not get device.information", exc_info=True) + else: + title = info.get("DeviceName") + return title or DEFAULT_DEVICE_NAME + + username = user_input.get(CONF_USERNAME) + password = user_input.get(CONF_PASSWORD) + try: + conn = await self.hass.async_add_executor_job( + try_connect, username, password + ) + except LoginErrorUsernameWrongException: + errors[CONF_USERNAME] = "incorrect_username" + except LoginErrorPasswordWrongException: + errors[CONF_PASSWORD] = "incorrect_password" + except LoginErrorUsernamePasswordWrongException: + errors[CONF_USERNAME] = "incorrect_username_or_password" + except LoginErrorUsernamePasswordOverrunException: + errors["base"] = "login_attempts_exceeded" + except ResponseErrorException: + _LOGGER.warning("Response error", exc_info=True) + errors["base"] = "response_error" + except Timeout: + _LOGGER.warning("Connection timeout", exc_info=True) + errors[CONF_URL] = "connection_timeout" + except Exception: # pylint: disable=broad-except + _LOGGER.warning("Unknown error connecting to device", exc_info=True) + errors[CONF_URL] = "unknown_connection_error" + if errors: + await self.hass.async_add_executor_job(logout) + return await self._async_show_user_form( + user_input=user_input, errors=errors + ) + + title = await self.hass.async_add_executor_job(get_router_title, conn) + await self.hass.async_add_executor_job(logout) + + return self.async_create_entry(title=title, data=user_input) + + async def async_step_ssdp(self, discovery_info): + """Handle SSDP initiated config flow.""" + # Attempt to distinguish from other non-LTE Huawei router devices, at least + # some ones we are interested in have "Mobile Wi-Fi" friendlyName. + if "mobile" not in discovery_info.get(ssdp.ATTR_UPNP_FRIENDLY_NAME, "").lower(): + return self.async_abort(reason="not_huawei_lte") + + # https://github.com/PyCQA/pylint/issues/3167 + url = self.context[CONF_URL] = url_normalize( # pylint: disable=no-member + discovery_info.get( + ssdp.ATTR_UPNP_PRESENTATION_URL, + f"http://{urlparse(discovery_info[ssdp.ATTR_SSDP_LOCATION]).hostname}/", + ) + ) + + if any( + url == flow["context"].get(CONF_URL) for flow in self._async_in_progress() + ): + return self.async_abort(reason="already_in_progress") + + user_input = {CONF_URL: url} + if self._already_configured(user_input): + return self.async_abort(reason="already_configured") + + return await self._async_show_user_form(user_input) + + +class OptionsFlowHandler(config_entries.OptionsFlow): + """Huawei LTE options flow.""" + + def __init__(self, config_entry: config_entries.ConfigEntry): + """Initialize options flow.""" + self.config_entry = config_entry + + async def async_step_init(self, user_input=None): + """Handle options flow.""" + + # Recipients are persisted as a list, but handled as comma separated string in UI + + if user_input is not None: + # Preserve existing options, for example *_from_yaml markers + data = {**self.config_entry.options, **user_input} + if not isinstance(data[CONF_RECIPIENT], list): + data[CONF_RECIPIENT] = [ + x.strip() for x in data[CONF_RECIPIENT].split(",") + ] + return self.async_create_entry(title="", data=data) + + data_schema = vol.Schema( + { + vol.Optional( + CONF_NAME, + default=self.config_entry.options.get( + CONF_NAME, DEFAULT_NOTIFY_SERVICE_NAME + ), + ): str, + vol.Optional( + CONF_RECIPIENT, + default=", ".join( + self.config_entry.options.get(CONF_RECIPIENT, []) + ), + ): str, + } + ) + return self.async_show_form(step_id="init", data_schema=data_schema) diff --git a/homeassistant/components/huawei_lte/const.py b/homeassistant/components/huawei_lte/const.py new file mode 100644 index 0000000000000..583c1c7d6f1b1 --- /dev/null +++ b/homeassistant/components/huawei_lte/const.py @@ -0,0 +1,54 @@ +"""Huawei LTE constants.""" + +DOMAIN = "huawei_lte" + +DEFAULT_DEVICE_NAME = "LTE" +DEFAULT_NOTIFY_SERVICE_NAME = DOMAIN + +UPDATE_SIGNAL = f"{DOMAIN}_update" +UPDATE_OPTIONS_SIGNAL = f"{DOMAIN}_options_update" + +CONNECTION_TIMEOUT = 10 +NOTIFY_SUPPRESS_TIMEOUT = 30 + +SERVICE_CLEAR_TRAFFIC_STATISTICS = "clear_traffic_statistics" +SERVICE_REBOOT = "reboot" +SERVICE_RESUME_INTEGRATION = "resume_integration" +SERVICE_SUSPEND_INTEGRATION = "suspend_integration" + +ADMIN_SERVICES = { + SERVICE_CLEAR_TRAFFIC_STATISTICS, + SERVICE_REBOOT, + SERVICE_RESUME_INTEGRATION, + SERVICE_SUSPEND_INTEGRATION, +} + +KEY_DEVICE_BASIC_INFORMATION = "device_basic_information" +KEY_DEVICE_INFORMATION = "device_information" +KEY_DEVICE_SIGNAL = "device_signal" +KEY_DIALUP_MOBILE_DATASWITCH = "dialup_mobile_dataswitch" +KEY_MONITORING_MONTH_STATISTICS = "monitoring_month_statistics" +KEY_MONITORING_STATUS = "monitoring_status" +KEY_MONITORING_TRAFFIC_STATISTICS = "monitoring_traffic_statistics" +KEY_NET_CURRENT_PLMN = "net_current_plmn" +KEY_NET_NET_MODE = "net_net_mode" +KEY_WLAN_HOST_LIST = "wlan_host_list" +KEY_WLAN_WIFI_FEATURE_SWITCH = "wlan_wifi_feature_switch" + +BINARY_SENSOR_KEYS = {KEY_MONITORING_STATUS, KEY_WLAN_WIFI_FEATURE_SWITCH} + +DEVICE_TRACKER_KEYS = {KEY_WLAN_HOST_LIST} + +SENSOR_KEYS = { + KEY_DEVICE_INFORMATION, + KEY_DEVICE_SIGNAL, + KEY_MONITORING_MONTH_STATISTICS, + KEY_MONITORING_STATUS, + KEY_MONITORING_TRAFFIC_STATISTICS, + KEY_NET_CURRENT_PLMN, + KEY_NET_NET_MODE, +} + +SWITCH_KEYS = {KEY_DIALUP_MOBILE_DATASWITCH} + +ALL_KEYS = BINARY_SENSOR_KEYS | DEVICE_TRACKER_KEYS | SENSOR_KEYS | SWITCH_KEYS diff --git a/homeassistant/components/huawei_lte/device_tracker.py b/homeassistant/components/huawei_lte/device_tracker.py index 552bfb90703a7..54e8f318cf68b 100644 --- a/homeassistant/components/huawei_lte/device_tracker.py +++ b/homeassistant/components/huawei_lte/device_tracker.py @@ -1,60 +1,164 @@ """Support for device tracking of Huawei LTE routers.""" -from typing import Any, Dict, List, Optional + +import logging +import re +from typing import Any, Dict, List, Optional, Set import attr -import voluptuous as vol +from stringcase import snakecase -import homeassistant.helpers.config_validation as cv from homeassistant.components.device_tracker import ( - PLATFORM_SCHEMA, DeviceScanner, + DOMAIN as DEVICE_TRACKER_DOMAIN, + SOURCE_TYPE_ROUTER, ) +from homeassistant.components.device_tracker.config_entry import ScannerEntity from homeassistant.const import CONF_URL -from . import DATA_KEY, RouterData +from homeassistant.core import callback +from homeassistant.helpers import entity_registry +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from . import HuaweiLteBaseEntity +from .const import DOMAIN, KEY_WLAN_HOST_LIST, UPDATE_SIGNAL + +_LOGGER = logging.getLogger(__name__) + +_DEVICE_SCAN = f"{DEVICE_TRACKER_DOMAIN}/device_scan" + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up from config entry.""" + + # Grab hosts list once to examine whether the initial fetch has got some data for + # us, i.e. if wlan host list is supported. Only set up a subscription and proceed + # with adding and tracking entities if it is. + router = hass.data[DOMAIN].routers[config_entry.data[CONF_URL]] + try: + _ = router.data[KEY_WLAN_HOST_LIST]["Hosts"]["Host"] + except KeyError: + _LOGGER.debug("%s[%s][%s] not in data", KEY_WLAN_HOST_LIST, "Hosts", "Host") + return + + # Initialize already tracked entities + tracked: Set[str] = set() + registry = await entity_registry.async_get_registry(hass) + known_entities: List[HuaweiLteScannerEntity] = [] + for entity in registry.entities.values(): + if ( + entity.domain == DEVICE_TRACKER_DOMAIN + and entity.config_entry_id == config_entry.entry_id + ): + tracked.add(entity.unique_id) + known_entities.append( + HuaweiLteScannerEntity(router, entity.unique_id.partition("-")[2]) + ) + async_add_entities(known_entities, True) + + # Tell parent router to poll hosts list to gather new devices + router.subscriptions[KEY_WLAN_HOST_LIST].add(_DEVICE_SCAN) + + async def _async_maybe_add_new_entities(url: str) -> None: + """Add new entities if the update signal comes from our router.""" + if url == router.url: + async_add_new_entities(hass, url, async_add_entities, tracked) + + # Register to handle router data updates + disconnect_dispatcher = async_dispatcher_connect( + hass, UPDATE_SIGNAL, _async_maybe_add_new_entities + ) + router.unload_handlers.append(disconnect_dispatcher) + + # Add new entities from initial scan + async_add_new_entities(hass, router.url, async_add_entities, tracked) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_URL): cv.url, -}) -HOSTS_PATH = "wlan_host_list.Hosts" +@callback +def async_add_new_entities(hass, router_url, async_add_entities, tracked): + """Add new entities that are not already being tracked.""" + router = hass.data[DOMAIN].routers[router_url] + try: + hosts = router.data[KEY_WLAN_HOST_LIST]["Hosts"]["Host"] + except KeyError: + _LOGGER.debug("%s[%s][%s] not in data", KEY_WLAN_HOST_LIST, "Hosts", "Host") + return + new_entities = [] + for host in (x for x in hosts if x.get("MacAddress")): + entity = HuaweiLteScannerEntity(router, host["MacAddress"]) + if entity.unique_id in tracked: + continue + tracked.add(entity.unique_id) + new_entities.append(entity) + async_add_entities(new_entities, True) -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) + +def _better_snakecase(text: str) -> str: + if text == text.upper(): + # All uppercase to all lowercase to get http for HTTP, not h_t_t_p + text = text.lower() + else: + # Three or more consecutive uppercase with middle part lowercased + # to get http_response for HTTPResponse, not h_t_t_p_response + text = re.sub( + r"([A-Z])([A-Z]+)([A-Z](?:[^A-Z]|$))", + lambda match: f"{match.group(1)}{match.group(2).lower()}{match.group(3)}", + text, + ) + return snakecase(text) @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 {} +class HuaweiLteScannerEntity(HuaweiLteBaseEntity, ScannerEntity): + """Huawei LTE router scanner entity.""" + + mac: str = attr.ib() + + _is_connected: bool = attr.ib(init=False, default=False) + _hostname: Optional[str] = attr.ib(init=False, default=None) + _device_state_attributes: Dict[str, Any] = attr.ib(init=False, factory=dict) + + def __attrs_post_init__(self): + """Initialize internal state.""" + self._device_state_attributes["mac_address"] = self.mac + + @property + def _entity_name(self) -> str: + return self._hostname or self.mac + + @property + def _device_unique_id(self) -> str: + return self.mac + + @property + def source_type(self) -> str: + """Return SOURCE_TYPE_ROUTER.""" + return SOURCE_TYPE_ROUTER + + @property + def is_connected(self) -> bool: + """Get whether the entity is connected.""" + return self._is_connected + + @property + def device_state_attributes(self) -> Dict[str, Any]: + """Get additional attributes related to entity state.""" + return self._device_state_attributes + + async def async_update(self) -> None: + """Update state.""" + hosts = self.router.data[KEY_WLAN_HOST_LIST]["Hosts"]["Host"] + host = next((x for x in hosts if x.get("MacAddress") == self.mac), None) + self._is_connected = host is not None + if self._is_connected: + self._hostname = host.get("HostName") + self._device_state_attributes = { + _better_snakecase(k): v for k, v in host.items() if k != "HostName" + } + + +def get_scanner(*args, **kwargs): # pylint: disable=useless-return + """Old no longer used way to set up Huawei LTE device tracker.""" + _LOGGER.warning( + "Loading and configuring as a platform is no longer supported or " + "required, convert to enabling/disabling available entities" + ) + return None diff --git a/homeassistant/components/huawei_lte/manifest.json b/homeassistant/components/huawei_lte/manifest.json index 2e096343b0921..e1e91c08e9ad3 100644 --- a/homeassistant/components/huawei_lte/manifest.json +++ b/homeassistant/components/huawei_lte/manifest.json @@ -1,12 +1,19 @@ { "domain": "huawei_lte", - "name": "Huawei lte", - "documentation": "https://www.home-assistant.io/components/huawei_lte", + "name": "Huawei LTE", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/huawei_lte", "requirements": [ - "huawei-lte-api==1.1.5" + "getmac==0.8.2", + "huawei-lte-api==1.4.11", + "stringcase==1.2.0", + "url-normalize==1.4.1" ], - "dependencies": [], - "codeowners": [ - "@scop" - ] + "ssdp": [ + { + "deviceType": "urn:schemas-upnp-org:device:InternetGatewayDevice:1", + "manufacturer": "Huawei" + } + ], + "codeowners": ["@scop"] } diff --git a/homeassistant/components/huawei_lte/notify.py b/homeassistant/components/huawei_lte/notify.py index 2222c1333dd55..91cc8864eb0c9 100644 --- a/homeassistant/components/huawei_lte/notify.py +++ b/homeassistant/components/huawei_lte/notify.py @@ -1,52 +1,62 @@ """Support for Huawei LTE router notifications.""" + import logging +import time +from typing import Any, List -import voluptuous as vol import attr +from huawei_lte_api.exceptions import ResponseErrorException -from homeassistant.components.notify import ( - BaseNotificationService, ATTR_TARGET, PLATFORM_SCHEMA) +from homeassistant.components.notify import ATTR_TARGET, BaseNotificationService from homeassistant.const import CONF_RECIPIENT, CONF_URL -import homeassistant.helpers.config_validation as cv -from . import DATA_KEY +from . import Router +from .const import DOMAIN _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) + if discovery_info is None: + _LOGGER.warning( + "Loading as a platform is no longer supported, convert to use " + "config entries or the huawei_lte component" + ) + return None + + router = hass.data[DOMAIN].routers[discovery_info[CONF_URL]] + default_targets = discovery_info[CONF_RECIPIENT] or [] + + return HuaweiLteSmsNotificationService(router, default_targets) @attr.s class HuaweiLteSmsNotificationService(BaseNotificationService): """Huawei LTE router SMS notification service.""" - hass = attr.ib() - config = attr.ib() + router: Router = attr.ib() + default_targets: List[str] = attr.ib() - def send_message(self, message="", **kwargs): + def send_message(self, message: str = "", **kwargs: Any) -> None: """Send message to target numbers.""" - from huawei_lte_api.exceptions import ResponseErrorException - targets = kwargs.get(ATTR_TARGET, self.config.get(CONF_RECIPIENT)) + targets = kwargs.get(ATTR_TARGET, self.default_targets) 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") + if self.router.suspended: + _LOGGER.debug( + "Integration suspended, not sending notification to %s", targets + ) return try: - resp = data.client.sms.send_sms( - phone_numbers=targets, message=message) + resp = self.router.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) + finally: + self.router.notify_last_attempt = time.monotonic() diff --git a/homeassistant/components/huawei_lte/sensor.py b/homeassistant/components/huawei_lte/sensor.py index 5dac3c2c78752..84d8e72c2ff3c 100644 --- a/homeassistant/components/huawei_lte/sensor.py +++ b/homeassistant/components/huawei_lte/sensor.py @@ -1,118 +1,225 @@ """Support for Huawei LTE sensors.""" + import logging import re +from typing import Optional import attr -import voluptuous as vol -from homeassistant.const import ( - CONF_URL, CONF_MONITORED_CONDITIONS, STATE_UNKNOWN, +from homeassistant.components.sensor import ( + DEVICE_CLASS_SIGNAL_STRENGTH, + DOMAIN as SENSOR_DOMAIN, ) -from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.helpers.entity import Entity -import homeassistant.helpers.config_validation as cv +from homeassistant.const import CONF_URL, DATA_BYTES, STATE_UNKNOWN, TIME_SECONDS -from . import DATA_KEY, RouterData +from . import HuaweiLteBaseEntity +from .const import ( + DOMAIN, + KEY_DEVICE_INFORMATION, + KEY_DEVICE_SIGNAL, + KEY_MONITORING_MONTH_STATISTICS, + KEY_MONITORING_STATUS, + KEY_MONITORING_TRAFFIC_STATISTICS, + KEY_NET_CURRENT_PLMN, + KEY_NET_NET_MODE, + SENSOR_KEYS, +) _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", + KEY_DEVICE_INFORMATION: dict( + include=re.compile(r"^WanIP.*Address$", re.IGNORECASE) ), - "device_signal.band": dict( - name="Band", + (KEY_DEVICE_INFORMATION, "WanIPAddress"): dict( + name="WAN IP address", icon="mdi:ip", enabled_default=True ), - "device_signal.cell_id": dict( - name="Cell ID", + (KEY_DEVICE_INFORMATION, "WanIPv6Address"): dict( + name="WAN IPv6 address", icon="mdi:ip" ), - "device_signal.lac": dict( - name="LAC", - ), - "device_signal.mode": dict( + (KEY_DEVICE_SIGNAL, "band"): dict(name="Band"), + (KEY_DEVICE_SIGNAL, "cell_id"): dict(name="Cell ID"), + (KEY_DEVICE_SIGNAL, "lac"): dict(name="LAC", icon="mdi:map-marker"), + (KEY_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", + formatter=lambda x: ({"0": "2G", "2": "3G", "7": "4G"}.get(x, "Unknown"), None), ), - "device_signal.rsrq": dict( + (KEY_DEVICE_SIGNAL, "pci"): dict(name="PCI"), + (KEY_DEVICE_SIGNAL, "rsrq"): dict( name="RSRQ", + device_class=DEVICE_CLASS_SIGNAL_STRENGTH, # 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" + 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", + enabled_default=True, ), - "device_signal.rsrp": dict( + (KEY_DEVICE_SIGNAL, "rsrp"): dict( name="RSRP", + device_class=DEVICE_CLASS_SIGNAL_STRENGTH, # 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" + 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", + enabled_default=True, ), - "device_signal.rssi": dict( + (KEY_DEVICE_SIGNAL, "rssi"): dict( name="RSSI", + device_class=DEVICE_CLASS_SIGNAL_STRENGTH, # 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" + 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", + enabled_default=True, ), - "device_signal.sinr": dict( + (KEY_DEVICE_SIGNAL, "sinr"): dict( name="SINR", + device_class=DEVICE_CLASS_SIGNAL_STRENGTH, # 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" + 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", + enabled_default=True, + ), + (KEY_DEVICE_SIGNAL, "rscp"): dict( + name="RSCP", + device_class=DEVICE_CLASS_SIGNAL_STRENGTH, + # https://wiki.teltonika.lt/view/RSCP + icon=lambda x: (x is None or x < -95) + and "mdi:signal-cellular-outline" + or x < -85 + and "mdi:signal-cellular-1" + or x < -75 + and "mdi:signal-cellular-2" + or "mdi:signal-cellular-3", + ), + (KEY_DEVICE_SIGNAL, "ecio"): dict( + name="EC/IO", + device_class=DEVICE_CLASS_SIGNAL_STRENGTH, + # https://wiki.teltonika.lt/view/EC/IO + icon=lambda x: (x is None or x < -20) + and "mdi:signal-cellular-outline" + or x < -10 + and "mdi:signal-cellular-1" + or x < -6 + and "mdi:signal-cellular-2" + or "mdi:signal-cellular-3", + ), + KEY_MONITORING_MONTH_STATISTICS: dict( + exclude=re.compile(r"^month(duration|lastcleartime)$", re.IGNORECASE) + ), + (KEY_MONITORING_MONTH_STATISTICS, "CurrentMonthDownload"): dict( + name="Current month download", unit=DATA_BYTES, icon="mdi:download" + ), + (KEY_MONITORING_MONTH_STATISTICS, "CurrentMonthUpload"): dict( + name="Current month upload", unit=DATA_BYTES, icon="mdi:upload" + ), + KEY_MONITORING_STATUS: dict( + include=re.compile( + r"^(currentwifiuser|(primary|secondary).*dns)$", re.IGNORECASE + ) + ), + (KEY_MONITORING_STATUS, "CurrentWifiUser"): dict( + name="WiFi clients connected", icon="mdi:wifi" + ), + (KEY_MONITORING_STATUS, "PrimaryDns"): dict( + name="Primary DNS server", icon="mdi:ip" + ), + (KEY_MONITORING_STATUS, "SecondaryDns"): dict( + name="Secondary DNS server", icon="mdi:ip" + ), + (KEY_MONITORING_STATUS, "PrimaryIPv6Dns"): dict( + name="Primary IPv6 DNS server", icon="mdi:ip" + ), + (KEY_MONITORING_STATUS, "SecondaryIPv6Dns"): dict( + name="Secondary IPv6 DNS server", icon="mdi:ip" + ), + KEY_MONITORING_TRAFFIC_STATISTICS: dict( + exclude=re.compile(r"^showtraffic$", re.IGNORECASE) + ), + (KEY_MONITORING_TRAFFIC_STATISTICS, "CurrentConnectTime"): dict( + name="Current connection duration", unit=TIME_SECONDS, icon="mdi:timer" + ), + (KEY_MONITORING_TRAFFIC_STATISTICS, "CurrentDownload"): dict( + name="Current connection download", unit=DATA_BYTES, icon="mdi:download" + ), + (KEY_MONITORING_TRAFFIC_STATISTICS, "CurrentUpload"): dict( + name="Current connection upload", unit=DATA_BYTES, icon="mdi:upload" + ), + (KEY_MONITORING_TRAFFIC_STATISTICS, "TotalConnectTime"): dict( + name="Total connected duration", unit=TIME_SECONDS, icon="mdi:timer" + ), + (KEY_MONITORING_TRAFFIC_STATISTICS, "TotalDownload"): dict( + name="Total download", unit=DATA_BYTES, icon="mdi:download" + ), + (KEY_MONITORING_TRAFFIC_STATISTICS, "TotalUpload"): dict( + name="Total upload", unit=DATA_BYTES, icon="mdi:upload" + ), + KEY_NET_CURRENT_PLMN: dict(exclude=re.compile(r"^(Rat|ShortName)$", re.IGNORECASE)), + (KEY_NET_CURRENT_PLMN, "State"): dict( + name="Operator search mode", + formatter=lambda x: ({"0": "Auto", "1": "Manual"}.get(x, "Unknown"), None), + ), + (KEY_NET_CURRENT_PLMN, "FullName"): dict(name="Operator name",), + (KEY_NET_CURRENT_PLMN, "Numeric"): dict(name="Operator code",), + KEY_NET_NET_MODE: dict(include=re.compile(r"^NetworkMode$", re.IGNORECASE)), + (KEY_NET_NET_MODE, "NetworkMode"): dict( + name="Preferred mode", + formatter=lambda x: ( + { + "00": "4G/3G/2G", + "01": "2G", + "02": "3G", + "03": "4G", + "0301": "4G/2G", + "0302": "4G/3G", + "0201": "3G/2G", + }.get(x, "Unknown"), + None, + ), ), } -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) +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up from config entry.""" + router = hass.data[DOMAIN].routers[config_entry.data[CONF_URL]] sensors = [] - for path in config.get(CONF_MONITORED_CONDITIONS): - data.subscribe(path) - sensors.append(HuaweiLteSensor(data, path, SENSOR_META.get(path, {}))) + for key in SENSOR_KEYS: + items = router.data.get(key) + if not items: + continue + key_meta = SENSOR_META.get(key) + if key_meta: + include = key_meta.get("include") + if include: + items = filter(include.search, items) + exclude = key_meta.get("exclude") + if exclude: + items = [x for x in items if not exclude.search(x)] + for item in items: + sensors.append( + HuaweiLteSensor(router, key, item, SENSOR_META.get((key, item), {})) + ) - add_entities(sensors, True) + async_add_entities(sensors, True) def format_default(value): @@ -121,7 +228,8 @@ def format_default(value): 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)) + r"([>=<]*)(?P.+?)\s*(?P[a-zA-Z]+)\s*$", str(value) + ) if match: try: value = float(match.group("value")) @@ -132,36 +240,44 @@ def format_default(value): @attr.s -class HuaweiLteSensor(Entity): +class HuaweiLteSensor(HuaweiLteBaseEntity): """Huawei LTE sensor entity.""" - data = attr.ib(type=RouterData) - path = attr.ib(type=list) - meta = attr.ib(type=dict) + key: str = attr.ib() + item: str = attr.ib() + meta: dict = attr.ib() _state = attr.ib(init=False, default=STATE_UNKNOWN) - _unit = attr.ib(init=False, type=str) + _unit: str = attr.ib(init=False) + + async def async_added_to_hass(self): + """Subscribe to needed data on add.""" + await super().async_added_to_hass() + self.router.subscriptions[self.key].add(f"{SENSOR_DOMAIN}/{self.item}") + + async def async_will_remove_from_hass(self): + """Unsubscribe from needed data on remove.""" + await super().async_will_remove_from_hass() + self.router.subscriptions[self.key].remove(f"{SENSOR_DOMAIN}/{self.item}") @property - def unique_id(self) -> str: - """Return unique ID for sensor.""" - return "{}_{}".format( - self.data["device_information.SerialNumber"], - ".".join(self.path), - ) + def _entity_name(self) -> str: + return self.meta.get("name", self.item) @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) + def _device_unique_id(self) -> str: + return f"{self.key}.{self.item}" @property def state(self): """Return sensor state.""" return self._state + @property + def device_class(self) -> Optional[str]: + """Return sensor device class.""" + return self.meta.get("device_class") + @property def unit_of_measurement(self): """Return sensor's unit of measurement.""" @@ -175,18 +291,31 @@ def icon(self): return icon(self.state) return icon - def update(self): - """Update state.""" - self.data.update() + @property + def entity_registry_enabled_default(self) -> bool: + """Return if the entity should be enabled when first added to the entity registry.""" + return bool(self.meta.get("enabled_default")) + async def async_update(self): + """Update state.""" try: - value = self.data[self.path] + value = self.router.data[self.key][self.item] except KeyError: - _LOGGER.warning("%s not in data", self.path) - value = None + _LOGGER.debug("%s[%s] not in data", self.key, self.item) + self._available = False + return + self._available = True formatter = self.meta.get("formatter") if not callable(formatter): formatter = format_default self._state, self._unit = formatter(value) + + +async def async_setup_platform(*args, **kwargs): + """Old no longer used way to set up Huawei LTE sensors.""" + _LOGGER.warning( + "Loading and configuring as a platform is no longer supported or " + "required, convert to enabling/disabling available entities" + ) diff --git a/homeassistant/components/huawei_lte/services.yaml b/homeassistant/components/huawei_lte/services.yaml new file mode 100644 index 0000000000000..bcb9be33299cc --- /dev/null +++ b/homeassistant/components/huawei_lte/services.yaml @@ -0,0 +1,30 @@ +clear_traffic_statistics: + description: Clear traffic statistics. + fields: + url: + description: URL of router to clear; optional when only one is configured. + example: http://192.168.100.1/ + +reboot: + description: Reboot router. + fields: + url: + description: URL of router to reboot; optional when only one is configured. + example: http://192.168.100.1/ + +resume_integration: + description: Resume suspended integration. + fields: + url: + description: URL of router to resume integration for; optional when only one is configured. + example: http://192.168.100.1/ + +suspend_integration: + description: > + Suspend integration. Suspending logs the integration out from the router, and stops accessing it. + Useful e.g. if accessing the router web interface from another source such as a web browser is temporarily required. + Invoke the resume_integration service to resume. + fields: + url: + description: URL of router to resume integration for; optional when only one is configured. + example: http://192.168.100.1/ diff --git a/homeassistant/components/huawei_lte/strings.json b/homeassistant/components/huawei_lte/strings.json new file mode 100644 index 0000000000000..e3a89b8f4184c --- /dev/null +++ b/homeassistant/components/huawei_lte/strings.json @@ -0,0 +1,42 @@ +{ + "config": { + "abort": { + "already_configured": "This device has already been configured", + "already_in_progress": "This device is already being configured", + "not_huawei_lte": "Not a Huawei LTE device" + }, + "error": { + "connection_failed": "Connection failed", + "connection_timeout": "Connection timeout", + "incorrect_password": "Incorrect password", + "incorrect_username": "Incorrect username", + "incorrect_username_or_password": "Incorrect username or password", + "invalid_url": "Invalid URL", + "login_attempts_exceeded": "Maximum login attempts exceeded, please try again later", + "response_error": "Unknown error from device", + "unknown_connection_error": "Unknown error connecting to device" + }, + "step": { + "user": { + "data": { + "password": "Password", + "url": "URL", + "username": "User name" + }, + "description": "Enter device access details. Specifying username and password is optional, but enables support for more integration features. On the other hand, use of an authorized connection may cause problems accessing the device web interface from outside Home Assistant while the integration is active, and the other way around.", + "title": "Configure Huawei LTE" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "name": "Notification service name (change requires restart)", + "recipient": "SMS notification recipients", + "track_new_devices": "Track new devices" + } + } + } + } +} diff --git a/homeassistant/components/huawei_lte/switch.py b/homeassistant/components/huawei_lte/switch.py new file mode 100644 index 0000000000000..45b179f470fc4 --- /dev/null +++ b/homeassistant/components/huawei_lte/switch.py @@ -0,0 +1,109 @@ +"""Support for Huawei LTE switches.""" + +import logging +from typing import Optional + +import attr + +from homeassistant.components.switch import ( + DEVICE_CLASS_SWITCH, + DOMAIN as SWITCH_DOMAIN, + SwitchEntity, +) +from homeassistant.const import CONF_URL + +from . import HuaweiLteBaseEntity +from .const import DOMAIN, KEY_DIALUP_MOBILE_DATASWITCH + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up from config entry.""" + router = hass.data[DOMAIN].routers[config_entry.data[CONF_URL]] + switches = [] + + if router.data.get(KEY_DIALUP_MOBILE_DATASWITCH): + switches.append(HuaweiLteMobileDataSwitch(router)) + + async_add_entities(switches, True) + + +@attr.s +class HuaweiLteBaseSwitch(HuaweiLteBaseEntity, SwitchEntity): + """Huawei LTE switch device base class.""" + + key: str + item: str + _raw_state: Optional[str] = attr.ib(init=False, default=None) + + def _turn(self, state: bool) -> None: + raise NotImplementedError + + def turn_on(self, **kwargs): + """Turn switch on.""" + self._turn(state=True) + + def turn_off(self, **kwargs): + """Turn switch off.""" + self._turn(state=False) + + @property + def device_class(self): + """Return device class.""" + return DEVICE_CLASS_SWITCH + + async def async_added_to_hass(self): + """Subscribe to needed data on add.""" + await super().async_added_to_hass() + self.router.subscriptions[self.key].add(f"{SWITCH_DOMAIN}/{self.item}") + + async def async_will_remove_from_hass(self): + """Unsubscribe from needed data on remove.""" + await super().async_will_remove_from_hass() + self.router.subscriptions[self.key].remove(f"{SWITCH_DOMAIN}/{self.item}") + + async def async_update(self): + """Update state.""" + try: + value = self.router.data[self.key][self.item] + except KeyError: + _LOGGER.debug("%s[%s] not in data", self.key, self.item) + self._available = False + return + self._available = True + self._raw_state = str(value) + + +@attr.s +class HuaweiLteMobileDataSwitch(HuaweiLteBaseSwitch): + """Huawei LTE mobile data switch device.""" + + def __attrs_post_init__(self): + """Initialize identifiers.""" + self.key = KEY_DIALUP_MOBILE_DATASWITCH + self.item = "dataswitch" + + @property + def _entity_name(self) -> str: + return "Mobile data" + + @property + def _device_unique_id(self) -> str: + return f"{self.key}.{self.item}" + + @property + def is_on(self) -> bool: + """Return whether the switch is on.""" + return self._raw_state == "1" + + def _turn(self, state: bool) -> None: + value = 1 if state else 0 + self.router.client.dial_up.set_mobile_dataswitch(dataswitch=value) + self._raw_state = str(value) + self.schedule_update_ha_state() + + @property + def icon(self): + """Return switch icon.""" + return "mdi:signal" if self.is_on else "mdi:signal-off" diff --git a/homeassistant/components/huawei_lte/translations/bg.json b/homeassistant/components/huawei_lte/translations/bg.json new file mode 100644 index 0000000000000..5c765c83bb995 --- /dev/null +++ b/homeassistant/components/huawei_lte/translations/bg.json @@ -0,0 +1,41 @@ +{ + "config": { + "abort": { + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e", + "already_in_progress": "\u0422\u043e\u0432\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0432\u0435\u0447\u0435 \u0441\u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430", + "not_huawei_lte": "\u041d\u0435 \u0435 Huawei LTE \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e" + }, + "error": { + "connection_failed": "\u0421\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435\u0442\u043e \u0435 \u043d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e", + "connection_timeout": "\u0412\u0440\u0435\u043c\u0435\u0442\u043e \u0437\u0430 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435 \u0438\u0437\u0442\u0435\u0447\u0435", + "incorrect_password": "\u041d\u0435\u043f\u0440\u0430\u0432\u0438\u043b\u043d\u0430 \u043f\u0430\u0440\u043e\u043b\u0430", + "incorrect_username": "\u041d\u0435\u043f\u0440\u0430\u0432\u0438\u043b\u043d\u043e \u043f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435", + "incorrect_username_or_password": "\u041d\u0435\u043f\u0440\u0430\u0432\u0438\u043b\u043d\u043e \u043f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435 \u0438\u043b\u0438 \u043f\u0430\u0440\u043e\u043b\u0430", + "invalid_url": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u0435\u043d \u0430\u0434\u0440\u0435\u0441", + "login_attempts_exceeded": "\u041c\u0430\u043a\u0441\u0438\u043c\u0430\u043b\u043d\u0438\u0442\u0435 \u043e\u043f\u0438\u0442\u0438 \u0437\u0430 \u0432\u043b\u0438\u0437\u0430\u043d\u0435 \u0441\u0430 \u043d\u0430\u0434\u0432\u0438\u0448\u0435\u043d\u0438. \u041c\u043e\u043b\u044f, \u043e\u043f\u0438\u0442\u0430\u0439\u0442\u0435 \u043e\u0442\u043d\u043e\u0432\u043e \u043f\u043e-\u043a\u044a\u0441\u043d\u043e", + "response_error": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430 \u043e\u0442 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e", + "unknown_connection_error": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430 \u043f\u0440\u0438 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435 \u0441 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e" + }, + "step": { + "user": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u0430", + "url": "URL \u0410\u0434\u0440\u0435\u0441", + "username": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435" + }, + "description": "\u0412\u044a\u0432\u0435\u0434\u0435\u0442\u0435 \u0434\u0430\u043d\u043d\u0438 \u0437\u0430 \u0434\u043e\u0441\u0442\u044a\u043f \u0434\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e. \u041f\u043e\u0441\u043e\u0447\u0432\u0430\u043d\u0435\u0442\u043e \u043d\u0430 \u043f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435 \u0438 \u043f\u0430\u0440\u043e\u043b\u0430 \u043d\u0435 \u0435 \u0437\u0430\u0434\u044a\u043b\u0436\u0438\u0442\u0435\u043b\u043d\u043e, \u043d\u043e \u0434\u0430\u0432\u0430 \u0432\u044a\u0437\u043c\u043e\u0436\u043d\u043e\u0441\u0442 \u0437\u0430 \u043f\u043e\u0432\u0435\u0447\u0435 \u0444\u0443\u043d\u043a\u0446\u0438\u0438 \u0437\u0430 \u0438\u043d\u0442\u0435\u0433\u0440\u0438\u0440\u0430\u043d\u0435. \u041e\u0442 \u0434\u0440\u0443\u0433\u0430 \u0441\u0442\u0440\u0430\u043d\u0430, \u0438\u0437\u043f\u043e\u043b\u0437\u0432\u0430\u043d\u0435\u0442\u043e \u043d\u0430 \u043e\u0442\u043e\u0440\u0438\u0437\u0438\u0440\u0430\u043d\u0430 \u0432\u0440\u044a\u0437\u043a\u0430 \u043c\u043e\u0436\u0435 \u0434\u0430 \u0434\u043e\u0432\u0435\u0434\u0435 \u0434\u043e \u043f\u0440\u043e\u0431\u043b\u0435\u043c\u0438 \u0441 \u0434\u043e\u0441\u0442\u044a\u043f\u0430 \u0434\u043e \u0443\u0435\u0431 \u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441\u0430 \u043d\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u043e\u0442\u0432\u044a\u043d Home Assistant, \u0434\u043e\u043a\u0430\u0442\u043e \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044f\u0442\u0430 \u0435 \u0430\u043a\u0442\u0438\u0432\u043d\u0430, \u0438 \u043e\u0431\u0440\u0430\u0442\u043d\u043e\u0442\u043e.", + "title": "\u041a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u0435 \u043d\u0430 Huawei LTE" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "recipient": "\u041f\u043e\u043b\u0443\u0447\u0430\u0442\u0435\u043b\u0438 \u043d\u0430 SMS \u0438\u0437\u0432\u0435\u0441\u0442\u0438\u044f", + "track_new_devices": "\u041f\u0440\u043e\u0441\u043b\u0435\u0434\u044f\u0432\u0430\u043d\u0435 \u043d\u0430 \u043d\u043e\u0432\u0438 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/huawei_lte/translations/ca.json b/homeassistant/components/huawei_lte/translations/ca.json new file mode 100644 index 0000000000000..cb8a4331a5787 --- /dev/null +++ b/homeassistant/components/huawei_lte/translations/ca.json @@ -0,0 +1,42 @@ +{ + "config": { + "abort": { + "already_configured": "Aquest dispositiu ja est\u00e0 configurat", + "already_in_progress": "Aquest dispositiu ja s'est\u00e0 configurant", + "not_huawei_lte": "No \u00e9s un dispositiu Huawei LTE" + }, + "error": { + "connection_failed": "La connexi\u00f3 ha fallat", + "connection_timeout": "S'ha acabat el temps d'espera de la connexi\u00f3", + "incorrect_password": "Contrasenya incorrecta", + "incorrect_username": "Nom d'usuari incorrecte", + "incorrect_username_or_password": "Nom d'usuari o contrasenya incorrectes", + "invalid_url": "URL inv\u00e0lid", + "login_attempts_exceeded": "Nombre m\u00e0xim d'intents d'inici de sessi\u00f3 superat, torna-ho a provar m\u00e9s tard", + "response_error": "S'ha produ\u00eft un error desconegut del dispositiu", + "unknown_connection_error": "S'ha produ\u00eft un error desconegut en connectar-se al dispositiu" + }, + "step": { + "user": { + "data": { + "password": "Contrasenya", + "url": "URL", + "username": "Nom d'usuari" + }, + "description": "Introdueix les dades d'acc\u00e9s del dispositiu. El nom d'usuari i contrasenya s\u00f3n opcionals, per\u00f2 habiliten m\u00e9s funcions de la integraci\u00f3. D'altra banda, (mentre la integraci\u00f3 estigui activa) l'\u00fas d'una connexi\u00f3 autoritzada pot causar problemes per accedir a la interf\u00edcie web del dispositiu des de fora de Home Assistant i viceversa.", + "title": "Configuraci\u00f3 de Huawei LTE" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "name": "Nom del servei de notificacions (reinici necessari si canvia)", + "recipient": "Destinataris de notificacions SMS", + "track_new_devices": "Segueix dispositius nous" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/huawei_lte/translations/cs.json b/homeassistant/components/huawei_lte/translations/cs.json new file mode 100644 index 0000000000000..6ba708f2b0e28 --- /dev/null +++ b/homeassistant/components/huawei_lte/translations/cs.json @@ -0,0 +1,37 @@ +{ + "config": { + "abort": { + "already_configured": "Toto za\u0159\u00edzen\u00ed je ji\u017e nakonfigurov\u00e1no" + }, + "error": { + "connection_failed": "P\u0159ipojen\u00ed se nezda\u0159ilo", + "incorrect_password": "Nespr\u00e1vn\u00e9 heslo", + "incorrect_username": "Nespr\u00e1vn\u00e9 u\u017eivatelsk\u00e9 jm\u00e9no", + "incorrect_username_or_password": "Nespr\u00e1vn\u00e9 u\u017eivatelsk\u00e9 jm\u00e9no \u010di heslo", + "invalid_url": "Neplatn\u00e1 adresa URL", + "login_attempts_exceeded": "Maxim\u00e1ln\u00ed pokus o p\u0159ihl\u00e1\u0161en\u00ed byl p\u0159ekro\u010den, zkuste to znovu pozd\u011bji", + "response_error": "Nezn\u00e1m\u00e1 chyba ze za\u0159\u00edzen\u00ed", + "unknown_connection_error": "Nezn\u00e1m\u00e1 chyba p\u0159i p\u0159ipojov\u00e1n\u00ed k za\u0159\u00edzen\u00ed" + }, + "step": { + "user": { + "data": { + "password": "Heslo", + "url": "URL", + "username": "U\u017eivatelsk\u00e9 jm\u00e9no" + }, + "title": "Konfigurovat Huawei LTE" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "recipient": "P\u0159\u00edjemci ozn\u00e1men\u00ed SMS", + "track_new_devices": "Sledovat nov\u00e1 za\u0159\u00edzen\u00ed" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/huawei_lte/translations/da.json b/homeassistant/components/huawei_lte/translations/da.json new file mode 100644 index 0000000000000..e07499b0073e6 --- /dev/null +++ b/homeassistant/components/huawei_lte/translations/da.json @@ -0,0 +1,42 @@ +{ + "config": { + "abort": { + "already_configured": "Denne enhed er allerede konfigureret", + "already_in_progress": "Denne enhed er allerede ved at blive konfigureret", + "not_huawei_lte": "Ikke en Huawei LTE-enhed" + }, + "error": { + "connection_failed": "Forbindelsen mislykkedes", + "connection_timeout": "Timeout for forbindelse", + "incorrect_password": "Forkert adgangskode", + "incorrect_username": "Forkert brugernavn", + "incorrect_username_or_password": "Forkert brugernavn eller adgangskode", + "invalid_url": "Ugyldig webadresse", + "login_attempts_exceeded": "Maksimale loginfors\u00f8g overskredet. Pr\u00f8v igen senere", + "response_error": "Ukendt fejl fra enheden", + "unknown_connection_error": "Ukendt fejl ved tilslutning til enheden" + }, + "step": { + "user": { + "data": { + "password": "Adgangskode", + "url": "Webadresse", + "username": "Brugernavn" + }, + "description": "Indtast oplysninger om enhedsadgang. Det er valgfrit at specificere brugernavn og adgangskode, men muligg\u00f8r underst\u00f8ttelse af flere integrationsfunktioner. P\u00e5 den anden side kan brug af en autoriseret forbindelse for\u00e5rsage problemer med at f\u00e5 adgang til enhedens webgr\u00e6nseflade uden for Home Assistant, mens integrationen er aktiv, og omvendt.", + "title": "Konfigurer Huawei LTE" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "name": "Navn p\u00e5 meddelelsestjeneste (\u00e6ndring kr\u00e6ver genstart)", + "recipient": "Modtagere af SMS-meddelelse", + "track_new_devices": "Spor nye enheder" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/huawei_lte/translations/de.json b/homeassistant/components/huawei_lte/translations/de.json new file mode 100644 index 0000000000000..d3a9aa3efbcd4 --- /dev/null +++ b/homeassistant/components/huawei_lte/translations/de.json @@ -0,0 +1,42 @@ +{ + "config": { + "abort": { + "already_configured": "Dieses Ger\u00e4t wurde bereits konfiguriert", + "already_in_progress": "Dieses Ger\u00e4t wurde bereits konfiguriert", + "not_huawei_lte": "Kein Huawei LTE-Ger\u00e4t" + }, + "error": { + "connection_failed": "Verbindung fehlgeschlagen.", + "connection_timeout": "Verbindungszeit\u00fcberschreitung", + "incorrect_password": "Ung\u00fcltiges Passwort", + "incorrect_username": "Ung\u00fcltiger Benutzername", + "incorrect_username_or_password": "Ung\u00fcltiger Benutzername oder Kennwort", + "invalid_url": "Ung\u00fcltige URL", + "login_attempts_exceeded": "Maximale Anzahl von Anmeldeversuchen \u00fcberschritten. Bitte versuche es sp\u00e4ter erneut", + "response_error": "Unbekannter Fehler vom Ger\u00e4t", + "unknown_connection_error": "Unbekannter Fehler beim Herstellen der Verbindung zum Ger\u00e4t" + }, + "step": { + "user": { + "data": { + "password": "Passwort", + "url": "URL", + "username": "Benutzername" + }, + "description": "Gib die Zugangsdaten zum Ger\u00e4t ein. Die Angabe von Benutzername und Passwort ist optional, erm\u00f6glicht aber die Unterst\u00fctzung weiterer Integrationsfunktionen. Andererseits kann die Verwendung einer autorisierten Verbindung zu Problemen beim Zugriff auf die Web-Schnittstelle des Ger\u00e4ts von au\u00dferhalb des Home Assistant f\u00fchren, w\u00e4hrend die Integration aktiv ist, und umgekehrt.", + "title": "Konfiguriere Huawei LTE" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "name": "Name des Benachrichtigungsdienstes (\u00c4nderung erfordert Neustart)", + "recipient": "SMS-Benachrichtigungsempf\u00e4nger", + "track_new_devices": "Neue Ger\u00e4te verfolgen" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/huawei_lte/translations/en.json b/homeassistant/components/huawei_lte/translations/en.json new file mode 100644 index 0000000000000..3a66c447e54a3 --- /dev/null +++ b/homeassistant/components/huawei_lte/translations/en.json @@ -0,0 +1,42 @@ +{ + "config": { + "abort": { + "already_configured": "This device has already been configured", + "already_in_progress": "This device is already being configured", + "not_huawei_lte": "Not a Huawei LTE device" + }, + "error": { + "connection_failed": "Connection failed", + "connection_timeout": "Connection timeout", + "incorrect_password": "Incorrect password", + "incorrect_username": "Incorrect username", + "incorrect_username_or_password": "Incorrect username or password", + "invalid_url": "Invalid URL", + "login_attempts_exceeded": "Maximum login attempts exceeded, please try again later", + "response_error": "Unknown error from device", + "unknown_connection_error": "Unknown error connecting to device" + }, + "step": { + "user": { + "data": { + "password": "Password", + "url": "URL", + "username": "User name" + }, + "description": "Enter device access details. Specifying username and password is optional, but enables support for more integration features. On the other hand, use of an authorized connection may cause problems accessing the device web interface from outside Home Assistant while the integration is active, and the other way around.", + "title": "Configure Huawei LTE" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "name": "Notification service name (change requires restart)", + "recipient": "SMS notification recipients", + "track_new_devices": "Track new devices" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/huawei_lte/translations/es.json b/homeassistant/components/huawei_lte/translations/es.json new file mode 100644 index 0000000000000..495ddb81bc323 --- /dev/null +++ b/homeassistant/components/huawei_lte/translations/es.json @@ -0,0 +1,42 @@ +{ + "config": { + "abort": { + "already_configured": "Este dispositivo ya ha sido configurado", + "already_in_progress": "Este dispositivo ya se est\u00e1 configurando", + "not_huawei_lte": "No es un dispositivo Huawei LTE" + }, + "error": { + "connection_failed": "Fallo de conexi\u00f3n", + "connection_timeout": "Tiempo de espera de la conexi\u00f3n superado", + "incorrect_password": "Contrase\u00f1a incorrecta", + "incorrect_username": "Nombre de usuario incorrecto", + "incorrect_username_or_password": "Nombre de usuario o contrase\u00f1a incorrectos", + "invalid_url": "URL no v\u00e1lida", + "login_attempts_exceeded": "Se han superado los intentos de inicio de sesi\u00f3n m\u00e1ximos, int\u00e9ntelo de nuevo m\u00e1s tarde.", + "response_error": "Error desconocido del dispositivo", + "unknown_connection_error": "Error desconocido al conectarse al dispositivo" + }, + "step": { + "user": { + "data": { + "password": "Contrase\u00f1a", + "url": "URL", + "username": "Nombre de usuario" + }, + "description": "Introduzca los detalles de acceso al dispositivo. La especificaci\u00f3n del nombre de usuario y la contrase\u00f1a es opcional, pero permite admitir m\u00e1s funciones de integraci\u00f3n. Por otro lado, el uso de una conexi\u00f3n autorizada puede causar problemas para acceder a la interfaz web del dispositivo desde fuera de Home Assistant mientras la integraci\u00f3n est\u00e1 activa, y viceversa.", + "title": "Configurar Huawei LTE" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "name": "Nombre del servicio de notificaci\u00f3n", + "recipient": "Destinatarios de notificaciones por SMS", + "track_new_devices": "Rastrea nuevos dispositivos" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/huawei_lte/translations/fr.json b/homeassistant/components/huawei_lte/translations/fr.json new file mode 100644 index 0000000000000..39e8b1045b5d2 --- /dev/null +++ b/homeassistant/components/huawei_lte/translations/fr.json @@ -0,0 +1,42 @@ +{ + "config": { + "abort": { + "already_configured": "Cet appareil est d\u00e9j\u00e0 configur\u00e9", + "already_in_progress": "Ce p\u00e9riph\u00e9rique est d\u00e9j\u00e0 en cours de configuration", + "not_huawei_lte": "Pas un appareil Huawei LTE" + }, + "error": { + "connection_failed": "La connexion a \u00e9chou\u00e9", + "connection_timeout": "D\u00e9lai de connexion d\u00e9pass\u00e9", + "incorrect_password": "Mot de passe incorrect", + "incorrect_username": "Nom d'utilisateur incorrect", + "incorrect_username_or_password": "identifiant ou mot de passe incorrect", + "invalid_url": "URL invalide", + "login_attempts_exceeded": "Nombre maximal de tentatives de connexion d\u00e9pass\u00e9, veuillez r\u00e9essayer ult\u00e9rieurement", + "response_error": "Erreur inconnue de l'appareil", + "unknown_connection_error": "Erreur inconnue lors de la connexion \u00e0 l'appareil" + }, + "step": { + "user": { + "data": { + "password": "Mot de passe", + "url": "URL", + "username": "Nom d'utilisateur" + }, + "description": "Entrez les d\u00e9tails d'acc\u00e8s au p\u00e9riph\u00e9rique. La sp\u00e9cification du nom d'utilisateur et du mot de passe est facultative, mais permet de prendre en charge davantage de fonctionnalit\u00e9s d'int\u00e9gration. En revanche, l\u2019utilisation d\u2019une connexion autoris\u00e9e peut entra\u00eener des probl\u00e8mes d\u2019acc\u00e8s \u00e0 l\u2019interface Web du p\u00e9riph\u00e9rique depuis l\u2019assistant externe lorsque l\u2019int\u00e9gration est active et inversement.", + "title": "Configurer Huawei LTE" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "name": "Nom du service de notification (red\u00e9marrage requis)", + "recipient": "Destinataires des notifications SMS", + "track_new_devices": "Suivre les nouveaux appareils" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/huawei_lte/translations/hu.json b/homeassistant/components/huawei_lte/translations/hu.json new file mode 100644 index 0000000000000..485a29f5a7771 --- /dev/null +++ b/homeassistant/components/huawei_lte/translations/hu.json @@ -0,0 +1,38 @@ +{ + "config": { + "abort": { + "already_configured": "Ez az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "already_in_progress": "Ez az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "not_huawei_lte": "Nem Huawei LTE eszk\u00f6z" + }, + "error": { + "connection_failed": "Kapcsol\u00f3d\u00e1s sikertelen", + "connection_timeout": "Kapcsolat id\u0151t\u00fall\u00e9p\u00e9se", + "incorrect_password": "Hib\u00e1s jelsz\u00f3", + "incorrect_username": "Helytelen felhaszn\u00e1l\u00f3n\u00e9v", + "incorrect_username_or_password": "Helytelen felhaszn\u00e1l\u00f3n\u00e9v vagy jelsz\u00f3", + "invalid_url": "\u00c9rv\u00e9nytelen URL" + }, + "step": { + "user": { + "data": { + "password": "Jelsz\u00f3", + "url": "URL", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + }, + "title": "Huawei LTE konfigur\u00e1l\u00e1sa" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "name": "\u00c9rtes\u00edt\u00e9si szolg\u00e1ltat\u00e1s neve (a m\u00f3dos\u00edt\u00e1s \u00fajraind\u00edt\u00e1st ig\u00e9nyel)", + "recipient": "SMS-\u00e9rtes\u00edt\u00e9s c\u00edmzettjei", + "track_new_devices": "\u00daj eszk\u00f6z\u00f6k nyomk\u00f6vet\u00e9se" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/huawei_lte/translations/it.json b/homeassistant/components/huawei_lte/translations/it.json new file mode 100644 index 0000000000000..9f5a2cf04b2c9 --- /dev/null +++ b/homeassistant/components/huawei_lte/translations/it.json @@ -0,0 +1,42 @@ +{ + "config": { + "abort": { + "already_configured": "Questo dispositivo \u00e8 gi\u00e0 stato configurato", + "already_in_progress": "Questo dispositivo \u00e8 gi\u00e0 in fase di configurazione", + "not_huawei_lte": "Non \u00e8 un dispositivo Huawei LTE" + }, + "error": { + "connection_failed": "Connessione fallita", + "connection_timeout": "Timeout di connessione", + "incorrect_password": "Password errata", + "incorrect_username": "Nome utente errato", + "incorrect_username_or_password": "Nome utente o password errati", + "invalid_url": "URL non valido", + "login_attempts_exceeded": "Superati i tentativi di accesso massimi, riprovare pi\u00f9 tardi", + "response_error": "Errore sconosciuto dal dispositivo", + "unknown_connection_error": "Errore sconosciuto durante la connessione al dispositivo" + }, + "step": { + "user": { + "data": { + "password": "Password", + "url": "URL", + "username": "Nome utente" + }, + "description": "Immettere i dettagli di accesso al dispositivo. La specifica di nome utente e password \u00e8 facoltativa, ma abilita il supporto per altre funzionalit\u00e0 di integrazione. D'altra parte, l'uso di una connessione autorizzata pu\u00f2 causare problemi di accesso all'interfaccia Web del dispositivo dall'esterno di Home Assistant mentre l'integrazione \u00e8 attiva e viceversa.", + "title": "Configura Huawei LTE" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "name": "Nome del servizio di notifica (la modifica richiede il riavvio)", + "recipient": "Destinatari della notifica SMS", + "track_new_devices": "Traccia nuovi dispositivi" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/huawei_lte/translations/ko.json b/homeassistant/components/huawei_lte/translations/ko.json new file mode 100644 index 0000000000000..fdf6e03c4f739 --- /dev/null +++ b/homeassistant/components/huawei_lte/translations/ko.json @@ -0,0 +1,42 @@ +{ + "config": { + "abort": { + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "already_in_progress": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "not_huawei_lte": "\ud654\uc6e8\uc774 LTE \uae30\uae30\uac00 \uc544\ub2d8" + }, + "error": { + "connection_failed": "\uc5f0\uacb0\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4", + "connection_timeout": "\uc811\uc18d \uc2dc\uac04 \ucd08\uacfc", + "incorrect_password": "\ube44\ubc00\ubc88\ud638\uac00 \uc77c\uce58\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4", + "incorrect_username": "\uc0ac\uc6a9\uc790 \uc774\ub984\uc774 \uc77c\uce58\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4", + "incorrect_username_or_password": "\uc0ac\uc6a9\uc790 \uc774\ub984 \ub610\ub294 \ube44\ubc00\ubc88\ud638\uac00 \uc77c\uce58\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4", + "invalid_url": "URL \uc8fc\uc18c\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "login_attempts_exceeded": "\ucd5c\ub300 \ub85c\uadf8\uc778 \uc2dc\ub3c4 \ud69f\uc218\ub97c \ucd08\uacfc\ud588\uc2b5\ub2c8\ub2e4. \ub098\uc911\uc5d0 \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694", + "response_error": "\uae30\uae30\uc5d0\uc11c \uc54c \uc218 \uc5c6\ub294 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4", + "unknown_connection_error": "\uae30\uae30\uc5d0 \uc5f0\uacb0\ud558\ub294 \uc911 \uc54c \uc218 \uc5c6\ub294 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" + }, + "step": { + "user": { + "data": { + "password": "\ube44\ubc00\ubc88\ud638", + "url": "URL \uc8fc\uc18c", + "username": "\uc0ac\uc6a9\uc790 \uc774\ub984" + }, + "description": "\uae30\uae30 \uc561\uc138\uc2a4 \uc138\ubd80 \uc0ac\ud56d\uc744 \uc785\ub825\ud574\uc8fc\uc138\uc694. \uc0ac\uc6a9\uc790 \uc774\ub984\uacfc \ube44\ubc00\ubc88\ud638\ub97c \uc124\uc815\ud558\ub294 \uac83\uc740 \uc120\ud0dd \uc0ac\ud56d\uc774\uc9c0\ub9cc \ub354 \ub9ce\uc740 \uae30\ub2a5\uc744 \uc9c0\uc6d0\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4. \ubc18\uba74, \uc778\uc99d \ub41c \uc5f0\uacb0\uc744 \uc0ac\uc6a9\ud558\uba74, \ud1b5\ud569 \uad6c\uc131\uc694\uc18c\uac00 \ud65c\uc131\ud654 \ub41c \uc0c1\ud0dc\uc5d0\uc11c \ub2e4\ub978 \ubc29\ubc95\uc73c\ub85c Home Assistant \uc758 \uc678\ubd80\uc5d0\uc11c \uae30\uae30\uc758 \uc6f9 \uc778\ud130\ud398\uc774\uc2a4\uc5d0 \uc561\uc138\uc2a4\ud558\ub294 \ub370 \ubb38\uc81c\uac00 \ubc1c\uc0dd\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.", + "title": "Huawei LTE \uc124\uc815\ud558\uae30" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "name": "\uc54c\ub9bc \uc11c\ube44\uc2a4 \uc774\ub984 (\ubcc0\uacbd \uc2dc \ub2e4\uc2dc \uc2dc\uc791\ud574\uc57c \ud568)", + "recipient": "SMS \uc54c\ub9bc \uc218\uc2e0\uc790", + "track_new_devices": "\uc0c8\ub85c\uc6b4 \uae30\uae30 \ucd94\uc801" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/huawei_lte/translations/lb.json b/homeassistant/components/huawei_lte/translations/lb.json new file mode 100644 index 0000000000000..25846f40b7cb7 --- /dev/null +++ b/homeassistant/components/huawei_lte/translations/lb.json @@ -0,0 +1,42 @@ +{ + "config": { + "abort": { + "already_configured": "D\u00ebsen Apparat ass scho konfigur\u00e9iert", + "already_in_progress": "D\u00ebsen Apparat g\u00ebtt scho konfigur\u00e9iert", + "not_huawei_lte": "Ken Huawei LTE Apparat" + }, + "error": { + "connection_failed": "Feeler bei der Verbindung", + "connection_timeout": "Z\u00e4it Iwwerschreidung beim verbannen", + "incorrect_password": "Ong\u00ebltegt Passwuert", + "incorrect_username": "Ong\u00ebltege Benotzernumm", + "incorrect_username_or_password": "Ong\u00ebltege Benotzernumm oder Passwuert", + "invalid_url": "Ong\u00eblteg URL", + "login_attempts_exceeded": "Maximal Login Versich iwwerschratt, w.e.g. m\u00e9i sp\u00e9it nach eng K\u00e9ier", + "response_error": "Onbekannte Feeler vum Apparat", + "unknown_connection_error": "Onbekannte Feeler beim verbannen mam Apparat" + }, + "step": { + "user": { + "data": { + "password": "Passwuert", + "url": "URL", + "username": "Benotzernumm" + }, + "description": "Gitt Detailer fir den Acc\u00e8s op den Apparat an. Benotzernumm a Passwuert si fakultativ, erm\u00e9iglecht awer d'\u00cbnnerst\u00ebtzung fir m\u00e9i Integratiouns Optiounen. Op der anerer S\u00e4it kann d'Benotzung vun enger autoris\u00e9ierter Verbindung Problemer mam Acc\u00e8s zum Web Interface vum Apparat ausserhalb vum Home Assistant verursaachen, w\u00e4rend d'Integratioun aktiv ass.", + "title": "Huawei LTE ariichten" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "name": "Numm vum Notifikatioun's Service (Restart n\u00e9ideg bei \u00c4nnerung)", + "recipient": "Empf\u00e4nger vun SMS Notifikatioune", + "track_new_devices": "Nei Apparater verfollegen" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/huawei_lte/translations/lv.json b/homeassistant/components/huawei_lte/translations/lv.json new file mode 100644 index 0000000000000..e276ee03f24f3 --- /dev/null +++ b/homeassistant/components/huawei_lte/translations/lv.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "Parole", + "url": "URL", + "username": "Lietot\u0101jv\u0101rds" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/huawei_lte/translations/nl.json b/homeassistant/components/huawei_lte/translations/nl.json new file mode 100644 index 0000000000000..0a1ebbf2ea2b6 --- /dev/null +++ b/homeassistant/components/huawei_lte/translations/nl.json @@ -0,0 +1,42 @@ +{ + "config": { + "abort": { + "already_configured": "Dit apparaat is reeds geconfigureerd", + "already_in_progress": "Dit apparaat wordt al geconfigureerd", + "not_huawei_lte": "Geen Huawei LTE-apparaat" + }, + "error": { + "connection_failed": "Verbinding mislukt", + "connection_timeout": "Time-out van de verbinding", + "incorrect_password": "Onjuist wachtwoord", + "incorrect_username": "Onjuiste gebruikersnaam", + "incorrect_username_or_password": "Onjuiste gebruikersnaam of wachtwoord", + "invalid_url": "Ongeldige URL", + "login_attempts_exceeded": "Maximale aanmeldingspogingen overschreden, probeer het later opnieuw.", + "response_error": "Onbekende fout van het apparaat", + "unknown_connection_error": "Onbekende fout bij verbinden met apparaat" + }, + "step": { + "user": { + "data": { + "password": "Wachtwoord", + "url": "URL", + "username": "Gebruikersnaam" + }, + "description": "Voer de toegangsgegevens van het apparaat in. Opgeven van gebruikersnaam en wachtwoord is optioneel, maar biedt ondersteuning voor meer integratiefuncties. Aan de andere kant kan het gebruik van een geautoriseerde verbinding problemen veroorzaken bij het openen van het webinterface van het apparaat buiten de Home Assitant, terwijl de integratie actief is en andersom.", + "title": "Configureer Huawei LTE" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "name": "Naam meldingsservice (wijziging vereist opnieuw opstarten)", + "recipient": "Ontvangers van sms-berichten", + "track_new_devices": "Volg nieuwe apparaten" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/huawei_lte/translations/nn.json b/homeassistant/components/huawei_lte/translations/nn.json new file mode 100644 index 0000000000000..ea06e4158e9cd --- /dev/null +++ b/homeassistant/components/huawei_lte/translations/nn.json @@ -0,0 +1,3 @@ +{ + "title": "Huawei LTE" +} \ No newline at end of file diff --git a/homeassistant/components/huawei_lte/translations/no.json b/homeassistant/components/huawei_lte/translations/no.json new file mode 100644 index 0000000000000..77c212c10b809 --- /dev/null +++ b/homeassistant/components/huawei_lte/translations/no.json @@ -0,0 +1,42 @@ +{ + "config": { + "abort": { + "already_configured": "Denne enheten er allerede konfigurert", + "already_in_progress": "Denne enheten blir allerede konfigurert", + "not_huawei_lte": "Ikke en Huawei LTE-enhet" + }, + "error": { + "connection_failed": "Tilkoblingen mislyktes", + "connection_timeout": "Tilkoblingsavbrudd", + "incorrect_password": "feil passord", + "incorrect_username": "Feil brukernavn", + "incorrect_username_or_password": "Feil brukernavn eller passord", + "invalid_url": "Ugyldig URL-adresse", + "login_attempts_exceeded": "Maksimalt antall p\u00e5loggingsfors\u00f8k er overskredet, vennligst pr\u00f8v igjen senere", + "response_error": "Ukjent feil fra enheten", + "unknown_connection_error": "Ukjent feil under tilkobling til enhet" + }, + "step": { + "user": { + "data": { + "password": "Passord", + "url": "", + "username": "Brukernavn" + }, + "description": "Angi detaljer for enhetstilgang. Angivelse av brukernavn og passord er valgfritt, men gir st\u00f8tte for flere integreringsfunksjoner. P\u00e5 den annen side kan bruk av en autorisert tilkobling f\u00f8re til problemer med tilgang til enhetens webgrensesnitt utenfor Home Assistant mens integreringen er aktiv, og omvendt.", + "title": "Konfigurer Huawei LTE" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "name": "Navn p\u00e5 varslingstjeneste (endring krever omstart)", + "recipient": "Mottakere av SMS-varsling", + "track_new_devices": "Spor nye enheter" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/huawei_lte/translations/pl.json b/homeassistant/components/huawei_lte/translations/pl.json new file mode 100644 index 0000000000000..86e6e4e5853f1 --- /dev/null +++ b/homeassistant/components/huawei_lte/translations/pl.json @@ -0,0 +1,42 @@ +{ + "config": { + "abort": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane.", + "already_in_progress": "Urz\u0105dzenie jest ju\u017c skonfigurowane.", + "not_huawei_lte": "To nie jest urz\u0105dzenie Huawei LTE" + }, + "error": { + "connection_failed": "Po\u0142\u0105czenie nie powiod\u0142o si\u0119", + "connection_timeout": "Przekroczono limit czasu pr\u00f3by po\u0142\u0105czenia.", + "incorrect_password": "Nieprawid\u0142owe has\u0142o", + "incorrect_username": "Nieprawid\u0142owa nazwa u\u017cytkownika", + "incorrect_username_or_password": "Nieprawid\u0142owa nazwa u\u017cytkownika lub has\u0142o", + "invalid_url": "Nieprawid\u0142owy URL", + "login_attempts_exceeded": "Przekroczono maksymaln\u0105 liczb\u0119 pr\u00f3b logowania. Spr\u00f3buj ponownie p\u00f3\u017aniej.", + "response_error": "Wyst\u0105pi\u0142 nieznany b\u0142\u0105d w urz\u0105dzeniu.", + "unknown_connection_error": "Nieznany b\u0142\u0105d podczas \u0142\u0105czenia z urz\u0105dzeniem" + }, + "step": { + "user": { + "data": { + "password": "Has\u0142o", + "url": "URL", + "username": "Nazwa u\u017cytkownika" + }, + "description": "Wprowad\u017a szczeg\u00f3\u0142y dost\u0119pu do urz\u0105dzenia. Okre\u015blenie nazwy u\u017cytkownika i has\u0142a jest opcjonalne, ale umo\u017cliwia obs\u0142ug\u0119 wi\u0119kszej liczby funkcji integracji. Z drugiej strony u\u017cycie autoryzowanego po\u0142\u0105czenia mo\u017ce powodowa\u0107 problemy z dost\u0119pem do interfejsu internetowego urz\u0105dzenia z zewn\u0105trz Home Assistant'a gdy integracja jest aktywna.", + "title": "Konfiguracja Huawei LTE" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "name": "Nazwa us\u0142ugi powiadomie\u0144 (zmiana wymaga ponownego uruchomienia)", + "recipient": "Odbiorcy powiadomie\u0144 SMS", + "track_new_devices": "\u015aled\u017a nowe urz\u0105dzenia" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/huawei_lte/translations/pt.json b/homeassistant/components/huawei_lte/translations/pt.json new file mode 100644 index 0000000000000..a71678deecc89 --- /dev/null +++ b/homeassistant/components/huawei_lte/translations/pt.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "Este dispositivo j\u00e1 foi configurado", + "already_in_progress": "Este dispositivo j\u00e1 est\u00e1 a ser configurado" + }, + "error": { + "connection_timeout": "Liga\u00e7\u00e3o expirou", + "incorrect_password": "Palavra-passe incorreta", + "incorrect_username": "Nome de Utilizador incorreto", + "incorrect_username_or_password": "Nome de utilizador ou palavra passe incorretos" + }, + "step": { + "user": { + "data": { + "password": "Palavra-passe", + "url": "", + "username": "Nome do utilizador" + }, + "title": "Configurar o Huawei LTE" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "recipient": "Destinat\u00e1rios de notifica\u00e7\u00e3o por SMS", + "track_new_devices": "Seguir novos dispositivos" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/huawei_lte/translations/ru.json b/homeassistant/components/huawei_lte/translations/ru.json new file mode 100644 index 0000000000000..6e4c34c095d7f --- /dev/null +++ b/homeassistant/components/huawei_lte/translations/ru.json @@ -0,0 +1,42 @@ +{ + "config": { + "abort": { + "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u044d\u0442\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\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 \u044d\u0442\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430.", + "not_huawei_lte": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0435 \u044f\u0432\u043b\u044f\u0435\u0442\u0441\u044f \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u043c Huawei LTE" + }, + "error": { + "connection_failed": "\u041e\u0448\u0438\u0431\u043a\u0430 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f.", + "connection_timeout": "\u0418\u0441\u0442\u0435\u043a\u043b\u043e \u0432\u0440\u0435\u043c\u044f \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f.", + "incorrect_password": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043f\u0430\u0440\u043e\u043b\u044c.", + "incorrect_username": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043b\u043e\u0433\u0438\u043d.", + "incorrect_username_or_password": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043b\u043e\u0433\u0438\u043d \u0438\u043b\u0438 \u043f\u0430\u0440\u043e\u043b\u044c.", + "invalid_url": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 URL-\u0430\u0434\u0440\u0435\u0441.", + "login_attempts_exceeded": "\u041f\u0440\u0435\u0432\u044b\u0448\u0435\u043d\u043e \u043c\u0430\u043a\u0441\u0438\u043c\u0430\u043b\u044c\u043d\u043e\u0435 \u043a\u043e\u043b\u0438\u0447\u0435\u0441\u0442\u0432\u043e \u043f\u043e\u043f\u044b\u0442\u043e\u043a \u0432\u0445\u043e\u0434\u0430, \u043f\u043e\u0432\u0442\u043e\u0440\u0438\u0442\u0435 \u043f\u043e\u043f\u044b\u0442\u043a\u0443 \u043f\u043e\u0437\u0436\u0435.", + "response_error": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430.", + "unknown_connection_error": "\u041e\u0448\u0438\u0431\u043a\u0430 \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": { + "password": "\u041f\u0430\u0440\u043e\u043b\u044c", + "url": "URL-\u0430\u0434\u0440\u0435\u0441", + "username": "\u041b\u043e\u0433\u0438\u043d" + }, + "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0434\u0430\u043d\u043d\u044b\u0435 \u0434\u043b\u044f \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f \u043a \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443. \u0423\u043a\u0430\u0437\u044b\u0432\u0430\u0442\u044c \u043b\u043e\u0433\u0438\u043d \u0438 \u043f\u0430\u0440\u043e\u043b\u044c \u043d\u0435\u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u043e, \u043d\u043e \u044d\u0442\u043e \u043f\u043e\u0437\u0432\u043e\u043b\u0438\u0442 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u0434\u043e\u043f\u043e\u043b\u043d\u0438\u0442\u0435\u043b\u044c\u043d\u044b\u0435 \u0444\u0443\u043d\u043a\u0446\u0438\u0438. \u0421 \u0434\u0440\u0443\u0433\u043e\u0439 \u0441\u0442\u043e\u0440\u043e\u043d\u044b, \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435 \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u043e\u0432\u0430\u043d\u043d\u043e\u0433\u043e \u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u044f \u043c\u043e\u0436\u0435\u0442 \u0432\u044b\u0437\u0432\u0430\u0442\u044c \u043f\u0440\u043e\u0431\u043b\u0435\u043c\u044b \u0441 \u0434\u043e\u0441\u0442\u0443\u043f\u043e\u043c \u043a \u0432\u0435\u0431-\u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441\u0443 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u043d\u0435 \u0438\u0437 Home Assistant, \u043a\u043e\u0433\u0434\u0430 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044f \u0430\u043a\u0442\u0438\u0432\u043d\u0430, \u0438 \u043d\u0430\u043e\u0431\u043e\u0440\u043e\u0442.", + "title": "Huawei LTE" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435 \u0441\u043b\u0443\u0436\u0431\u044b \u0443\u0432\u0435\u0434\u043e\u043c\u043b\u0435\u043d\u0438\u0439 (\u043f\u043e\u0442\u0440\u0435\u0431\u0443\u0435\u0442\u0441\u044f \u043f\u0435\u0440\u0435\u0437\u0430\u043f\u0443\u0441\u043a)", + "recipient": "\u041f\u043e\u043b\u0443\u0447\u0430\u0442\u0435\u043b\u0438 SMS-\u0443\u0432\u0435\u0434\u043e\u043c\u043b\u0435\u043d\u0438\u0439", + "track_new_devices": "\u041e\u0442\u0441\u043b\u0435\u0436\u0438\u0432\u0430\u0442\u044c \u043d\u043e\u0432\u044b\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/huawei_lte/translations/sl.json b/homeassistant/components/huawei_lte/translations/sl.json new file mode 100644 index 0000000000000..fe4a8db5d069b --- /dev/null +++ b/homeassistant/components/huawei_lte/translations/sl.json @@ -0,0 +1,42 @@ +{ + "config": { + "abort": { + "already_configured": "Ta naprava je \u017ee konfigurirana", + "already_in_progress": "Ta naprava se \u017ee nastavlja", + "not_huawei_lte": "Ni naprava Huawei LTE" + }, + "error": { + "connection_failed": "Povezava ni uspela", + "connection_timeout": "\u010casovna omejitev povezave", + "incorrect_password": "Nepravilno geslo", + "incorrect_username": "Nepravilno uporabni\u0161ko ime", + "incorrect_username_or_password": "Nepravilno uporabni\u0161ko ime ali geslo", + "invalid_url": "Neveljaven URL", + "login_attempts_exceeded": "Najve\u010d poskusov prijave prese\u017eeno, prosimo, poskusite znova pozneje", + "response_error": "Neznana napaka iz naprave", + "unknown_connection_error": "Neznana napaka pri povezovanju z napravo" + }, + "step": { + "user": { + "data": { + "password": "Geslo", + "url": "URL", + "username": "Uporabni\u0161ko ime" + }, + "description": "Vnesite podatke za dostop do naprave. Dolo\u010danje uporabni\u0161kega imena in gesla je izbirno, vendar omogo\u010da podporo za ve\u010d funkcij integracije. Po drugi strani pa lahko uporaba poobla\u0161\u010dene povezave povzro\u010di te\u017eave pri dostopu do spletnega vmesnika naprave zunaj Home Assistant-a, medtem ko je integracija aktivna, in obratno.", + "title": "Konfigurirajte Huawei LTE" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "name": "Ime storitve obve\u0161\u010danja (sprememba zahteva ponovni zagon)", + "recipient": "Prejemniki obvestil SMS", + "track_new_devices": "Sledi novim napravam" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/huawei_lte/translations/sv.json b/homeassistant/components/huawei_lte/translations/sv.json new file mode 100644 index 0000000000000..3dd267ed73126 --- /dev/null +++ b/homeassistant/components/huawei_lte/translations/sv.json @@ -0,0 +1,42 @@ +{ + "config": { + "abort": { + "already_configured": "Den h\u00e4r enheten har redan konfigurerats", + "already_in_progress": "Den h\u00e4r enheten har redan konfigurerats", + "not_huawei_lte": "Inte en Huawei LTE-enhet" + }, + "error": { + "connection_failed": "Anslutningen misslyckades", + "connection_timeout": "Timeout f\u00f6r anslutning", + "incorrect_password": "Felaktigt l\u00f6senord", + "incorrect_username": "Felaktigt anv\u00e4ndarnamn", + "incorrect_username_or_password": "Felaktigt anv\u00e4ndarnamn eller l\u00f6senord", + "invalid_url": "Ogiltig URL", + "login_attempts_exceeded": "Maximala inloggningsf\u00f6rs\u00f6k har \u00f6verskridits, f\u00f6rs\u00f6k igen senare", + "response_error": "Ok\u00e4nt fel fr\u00e5n enheten", + "unknown_connection_error": "Ok\u00e4nt fel vid anslutning till enheten" + }, + "step": { + "user": { + "data": { + "password": "L\u00f6senord", + "url": "URL", + "username": "Anv\u00e4ndarnamn" + }, + "description": "Ange information om enhets\u00e5tkomst. Det \u00e4r valfritt att ange anv\u00e4ndarnamn och l\u00f6senord, men st\u00f6djer d\u00e5 fler integrationsfunktioner. \u00c5 andra sidan kan anv\u00e4ndning av en auktoriserad anslutning orsaka problem med att komma \u00e5t enhetens webbgr\u00e4nssnitt utanf\u00f6r Home Assistant medan integrationen \u00e4r aktiv och tv\u00e4rtom.", + "title": "Konfigurera Huawei LTE" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "name": "Namn p\u00e5 meddelandetj\u00e4nsten (\u00e4ndring kr\u00e4ver omstart)", + "recipient": "Mottagare av SMS-meddelanden", + "track_new_devices": "Sp\u00e5ra nya enheter" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/huawei_lte/translations/zh-Hant.json b/homeassistant/components/huawei_lte/translations/zh-Hant.json new file mode 100644 index 0000000000000..4094733ba629d --- /dev/null +++ b/homeassistant/components/huawei_lte/translations/zh-Hant.json @@ -0,0 +1,42 @@ +{ + "config": { + "abort": { + "already_configured": "\u6b64\u8a2d\u5099\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "already_in_progress": "\u6b64\u8a2d\u5099\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "not_huawei_lte": "\u4e26\u975e\u83ef\u70ba LTE \u8a2d\u5099" + }, + "error": { + "connection_failed": "\u9023\u7dda\u5931\u6557", + "connection_timeout": "\u9023\u7dda\u903e\u6642", + "incorrect_password": "\u5bc6\u78bc\u932f\u8aa4", + "incorrect_username": "\u4f7f\u7528\u8005\u540d\u7a31\u932f\u8aa4", + "incorrect_username_or_password": "\u4f7f\u7528\u8005\u540d\u7a31\u6216\u5bc6\u78bc\u932f\u8aa4", + "invalid_url": "\u7db2\u5740\u7121\u6548", + "login_attempts_exceeded": "\u5df2\u9054\u5617\u8a66\u767b\u5165\u6700\u5927\u6b21\u6578\uff0c\u8acb\u7a0d\u5f8c\u518d\u8a66", + "response_error": "\u4f86\u81ea\u8a2d\u5099\u672a\u77e5\u932f\u8aa4", + "unknown_connection_error": "\u9023\u7dda\u81f3\u8a2d\u5099\u672a\u77e5\u932f\u8aa4" + }, + "step": { + "user": { + "data": { + "password": "\u5bc6\u78bc", + "url": "\u7db2\u5740", + "username": "\u4f7f\u7528\u8005\u540d\u7a31" + }, + "description": "\u8f38\u5165\u8a2d\u5099\u5b58\u53d6\u8a73\u7d30\u8cc7\u6599\u3002\u6307\u5b9a\u4f7f\u7528\u8005\u540d\u7a31\u8207\u5bc6\u78bc\u70ba\u9078\u9805\u8f38\u5165\uff0c\u4f46\u958b\u555f\u5c07\u652f\u63f4\u66f4\u591a\u6574\u5408\u529f\u80fd\u3002\u6b64\u5916\uff0c\u4f7f\u7528\u6388\u6b0a\u9023\u7dda\uff0c\u53ef\u80fd\u5c0e\u81f4\u6574\u5408\u555f\u7528\u5f8c\uff0c\u7531\u5916\u90e8\u9023\u7dda\u81f3 Home Assistant \u8a2d\u5099 Web \u4ecb\u9762\u51fa\u73fe\u67d0\u4e9b\u554f\u984c\uff0c\u53cd\u4e4b\u4ea6\u7136\u3002", + "title": "\u8a2d\u5b9a\u83ef\u70ba LTE" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "name": "\u901a\u77e5\u670d\u52d9\u540d\u7a31\uff08\u8b8a\u66f4\u5f8c\u9700\u91cd\u555f\uff09", + "recipient": "\u7c21\u8a0a\u901a\u77e5\u6536\u4ef6\u8005", + "track_new_devices": "\u8ffd\u8e64\u65b0\u8a2d\u5099" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/huawei_router/device_tracker.py b/homeassistant/components/huawei_router/device_tracker.py index 88e2a57a579b0..be34b26be0d4b 100644 --- a/homeassistant/components/huawei_router/device_tracker.py +++ b/homeassistant/components/huawei_router/device_tracker.py @@ -1,24 +1,29 @@ """Support for HUAWEI routers.""" import base64 +from collections import namedtuple 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) + 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__) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Required(CONF_USERNAME): cv.string -}) +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): @@ -28,14 +33,14 @@ def get_scanner(hass, config): return scanner -Device = namedtuple('Device', ['name', 'ip', 'mac', 'state']) +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\((.*?)\),') + 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.*?)",' @@ -43,14 +48,15 @@ class HuaweiDeviceScanner(DeviceScanner): '"(?P.*?)","(?P.*?)",' '"(?P