mirror of
https://github.com/home-assistant/core.git
synced 2025-07-21 12:17:07 +00:00
commit
4dbfafa8ca
15
.coveragerc
15
.coveragerc
@ -13,6 +13,10 @@ omit =
|
||||
homeassistant/components/abode/*
|
||||
homeassistant/components/acer_projector/switch.py
|
||||
homeassistant/components/actiontec/device_tracker.py
|
||||
homeassistant/components/adguard/__init__.py
|
||||
homeassistant/components/adguard/const.py
|
||||
homeassistant/components/adguard/sensor.py
|
||||
homeassistant/components/adguard/switch.py
|
||||
homeassistant/components/ads/*
|
||||
homeassistant/components/aftership/sensor.py
|
||||
homeassistant/components/airvisual/sensor.py
|
||||
@ -153,6 +157,7 @@ omit =
|
||||
homeassistant/components/eight_sleep/*
|
||||
homeassistant/components/eliqonline/sensor.py
|
||||
homeassistant/components/elkm1/*
|
||||
homeassistant/components/elv/switch.py
|
||||
homeassistant/components/emby/media_player.py
|
||||
homeassistant/components/emoncms/sensor.py
|
||||
homeassistant/components/emoncms_history/*
|
||||
@ -161,6 +166,7 @@ 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
|
||||
@ -223,6 +229,7 @@ omit =
|
||||
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/*
|
||||
@ -312,6 +319,7 @@ 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
|
||||
@ -444,6 +452,7 @@ 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/plum_lightpad/*
|
||||
@ -544,6 +553,7 @@ omit =
|
||||
homeassistant/components/slack/notify.py
|
||||
homeassistant/components/sma/sensor.py
|
||||
homeassistant/components/smappee/*
|
||||
homeassistant/components/smarty/*
|
||||
homeassistant/components/smarthab/*
|
||||
homeassistant/components/smtp/notify.py
|
||||
homeassistant/components/snapcast/media_player.py
|
||||
@ -551,7 +561,9 @@ omit =
|
||||
homeassistant/components/sochain/sensor.py
|
||||
homeassistant/components/socialblade/sensor.py
|
||||
homeassistant/components/solaredge/sensor.py
|
||||
homeassistant/components/solaredge_local/sensor.py
|
||||
homeassistant/components/solax/sensor.py
|
||||
homeassistant/components/somfy/*
|
||||
homeassistant/components/somfy_mylink/*
|
||||
homeassistant/components/sonarr/sensor.py
|
||||
homeassistant/components/songpal/media_player.py
|
||||
@ -567,6 +579,7 @@ omit =
|
||||
homeassistant/components/starlingbank/sensor.py
|
||||
homeassistant/components/steam_online/sensor.py
|
||||
homeassistant/components/stiebel_eltron/*
|
||||
homeassistant/components/streamlabswater/*
|
||||
homeassistant/components/stride/notify.py
|
||||
homeassistant/components/supervisord/sensor.py
|
||||
homeassistant/components/swiss_hydrological_data/sensor.py
|
||||
@ -620,6 +633,7 @@ omit =
|
||||
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
|
||||
@ -651,6 +665,7 @@ omit =
|
||||
homeassistant/components/viaggiatreno/sensor.py
|
||||
homeassistant/components/vizio/media_player.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/*
|
||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -95,6 +95,7 @@ virtualization/vagrant/config
|
||||
|
||||
# Visual Studio Code
|
||||
.vscode
|
||||
.devcontainer
|
||||
|
||||
# Built docs
|
||||
docs/build
|
||||
|
21
CODEOWNERS
21
CODEOWNERS
@ -17,6 +17,7 @@ virtualization/Docker/* @home-assistant/docker
|
||||
homeassistant/scripts/check_config.py @kellerza
|
||||
|
||||
# Integrations
|
||||
homeassistant/components/adguard/* @frenck
|
||||
homeassistant/components/airvisual/* @bachya
|
||||
homeassistant/components/alarm_control_panel/* @colinodell
|
||||
homeassistant/components/alpha_vantage/* @fabaff
|
||||
@ -24,12 +25,14 @@ homeassistant/components/amazon_polly/* @robbiet480
|
||||
homeassistant/components/ambiclimate/* @danielhiversen
|
||||
homeassistant/components/ambient_station/* @bachya
|
||||
homeassistant/components/api/* @home-assistant/core
|
||||
homeassistant/components/aprs/* @PhilRW
|
||||
homeassistant/components/arduino/* @fabaff
|
||||
homeassistant/components/arest/* @fabaff
|
||||
homeassistant/components/asuswrt/* @kennedyshead
|
||||
homeassistant/components/auth/* @home-assistant/core
|
||||
homeassistant/components/automatic/* @armills
|
||||
homeassistant/components/automation/* @home-assistant/core
|
||||
homeassistant/components/awair/* @danielsjf
|
||||
homeassistant/components/aws/* @awarecan @robbiet480
|
||||
homeassistant/components/axis/* @kane610
|
||||
homeassistant/components/azure_event_hub/* @eavanvalkenburg
|
||||
@ -41,6 +44,7 @@ homeassistant/components/braviatv/* @robbiet480
|
||||
homeassistant/components/broadlink/* @danielhiversen
|
||||
homeassistant/components/brunt/* @eavanvalkenburg
|
||||
homeassistant/components/bt_smarthub/* @jxwolstenholme
|
||||
homeassistant/components/buienradar/* @ties
|
||||
homeassistant/components/cisco_ios/* @fbradyirl
|
||||
homeassistant/components/cisco_mobility_express/* @fbradyirl
|
||||
homeassistant/components/cisco_webex_teams/* @fbradyirl
|
||||
@ -59,6 +63,7 @@ homeassistant/components/daikin/* @fredrike @rofrantz
|
||||
homeassistant/components/darksky/* @fabaff
|
||||
homeassistant/components/deconz/* @kane610
|
||||
homeassistant/components/demo/* @home-assistant/core
|
||||
homeassistant/components/device_automation/* @home-assistant/core
|
||||
homeassistant/components/digital_ocean/* @fabaff
|
||||
homeassistant/components/discogs/* @thibmaek
|
||||
homeassistant/components/doorbird/* @oblogic7
|
||||
@ -67,9 +72,11 @@ homeassistant/components/ecovacs/* @OverloadUT
|
||||
homeassistant/components/edp_redy/* @abmantis
|
||||
homeassistant/components/egardia/* @jeroenterheerdt
|
||||
homeassistant/components/eight_sleep/* @mezz64
|
||||
homeassistant/components/elv/* @majuss
|
||||
homeassistant/components/emby/* @mezz64
|
||||
homeassistant/components/enigma2/* @fbradyirl
|
||||
homeassistant/components/enocean/* @bdurrer
|
||||
homeassistant/components/environment_canada/* @michaeldavie
|
||||
homeassistant/components/ephember/* @ttroy50
|
||||
homeassistant/components/epsonworkforce/* @ThaStealth
|
||||
homeassistant/components/eq3btsmart/* @rytilahti
|
||||
@ -90,6 +97,7 @@ homeassistant/components/geniushub/* @zxdavb
|
||||
homeassistant/components/gitter/* @fabaff
|
||||
homeassistant/components/glances/* @fabaff
|
||||
homeassistant/components/gntp/* @robbiet480
|
||||
homeassistant/components/google_cloud/* @lufton
|
||||
homeassistant/components/google_translate/* @awarecan
|
||||
homeassistant/components/google_travel_time/* @robbiet480
|
||||
homeassistant/components/googlehome/* @ludeeus
|
||||
@ -108,6 +116,7 @@ homeassistant/components/homeassistant/* @home-assistant/core
|
||||
homeassistant/components/homekit/* @cdce8p
|
||||
homeassistant/components/homekit_controller/* @Jc2k
|
||||
homeassistant/components/homematic/* @pvizeli @danielperna84
|
||||
homeassistant/components/honeywell/* @zxdavb
|
||||
homeassistant/components/html5/* @robbiet480
|
||||
homeassistant/components/http/* @home-assistant/core
|
||||
homeassistant/components/huawei_lte/* @scop
|
||||
@ -133,9 +142,11 @@ homeassistant/components/konnected/* @heythisisnate
|
||||
homeassistant/components/lametric/* @robbiet480
|
||||
homeassistant/components/launch_library/* @ludeeus
|
||||
homeassistant/components/lcn/* @alengwenus
|
||||
homeassistant/components/life360/* @pnbruckner
|
||||
homeassistant/components/lifx/* @amelchio
|
||||
homeassistant/components/lifx_cloud/* @amelchio
|
||||
homeassistant/components/lifx_legacy/* @amelchio
|
||||
homeassistant/components/linky/* @tiste @Quentame
|
||||
homeassistant/components/linux_battery/* @fabaff
|
||||
homeassistant/components/liveboxplaytv/* @pschmitt
|
||||
homeassistant/components/logger/* @home-assistant/core
|
||||
@ -149,6 +160,7 @@ homeassistant/components/mcp23017/* @jardiamj
|
||||
homeassistant/components/mediaroom/* @dgomes
|
||||
homeassistant/components/melissa/* @kennedyshead
|
||||
homeassistant/components/met/* @danielhiversen
|
||||
homeassistant/components/meteo_france/* @victorcerutti @oncleben31
|
||||
homeassistant/components/meteoalarm/* @rolfberkenbosch
|
||||
homeassistant/components/miflora/* @danielhiversen @ChristianKuehnel
|
||||
homeassistant/components/mill/* @danielhiversen
|
||||
@ -181,12 +193,14 @@ homeassistant/components/panel_iframe/* @home-assistant/frontend
|
||||
homeassistant/components/persistent_notification/* @home-assistant/core
|
||||
homeassistant/components/philips_js/* @elupus
|
||||
homeassistant/components/pi_hole/* @fabaff
|
||||
homeassistant/components/plaato/* @JohNan
|
||||
homeassistant/components/plant/* @ChristianKuehnel
|
||||
homeassistant/components/point/* @fredrike
|
||||
homeassistant/components/ps4/* @ktnrg45
|
||||
homeassistant/components/ptvsd/* @swamp-ig
|
||||
homeassistant/components/push/* @dgomes
|
||||
homeassistant/components/pvoutput/* @fabaff
|
||||
homeassistant/components/qld_bushfire/* @exxamalte
|
||||
homeassistant/components/qnap/* @colinodell
|
||||
homeassistant/components/quantum_gateway/* @cisasteelersfan
|
||||
homeassistant/components/qwikswitch/* @kellerza
|
||||
@ -201,6 +215,7 @@ homeassistant/components/ruter/* @ludeeus
|
||||
homeassistant/components/scene/* @home-assistant/core
|
||||
homeassistant/components/scrape/* @fabaff
|
||||
homeassistant/components/script/* @home-assistant/core
|
||||
homeassistant/components/sense/* @kbickar
|
||||
homeassistant/components/sensibo/* @andrey-git
|
||||
homeassistant/components/serial/* @fabaff
|
||||
homeassistant/components/seventeentrack/* @bachya
|
||||
@ -211,8 +226,11 @@ homeassistant/components/simplisafe/* @bachya
|
||||
homeassistant/components/sma/* @kellerza
|
||||
homeassistant/components/smarthab/* @outadoc
|
||||
homeassistant/components/smartthings/* @andrewsayre
|
||||
homeassistant/components/smarty/* @z0mbieprocess
|
||||
homeassistant/components/smtp/* @fabaff
|
||||
homeassistant/components/solaredge_local/* @drobtravels
|
||||
homeassistant/components/solax/* @squishykid
|
||||
homeassistant/components/somfy/* @tetienne
|
||||
homeassistant/components/sonos/* @amelchio
|
||||
homeassistant/components/spaceapi/* @fabaff
|
||||
homeassistant/components/spider/* @peternijssen
|
||||
@ -248,7 +266,6 @@ homeassistant/components/tradfri/* @ggravlingen
|
||||
homeassistant/components/tts/* @robbiet480
|
||||
homeassistant/components/twilio_call/* @robbiet480
|
||||
homeassistant/components/twilio_sms/* @robbiet480
|
||||
homeassistant/components/uber/* @robbiet480
|
||||
homeassistant/components/unifi/* @kane610
|
||||
homeassistant/components/upcloud/* @scop
|
||||
homeassistant/components/updater/* @home-assistant/core
|
||||
@ -258,6 +275,7 @@ homeassistant/components/utility_meter/* @dgomes
|
||||
homeassistant/components/velux/* @Julius2342
|
||||
homeassistant/components/version/* @fabaff
|
||||
homeassistant/components/vizio/* @raman325
|
||||
homeassistant/components/vlc_telnet/* @rodripf
|
||||
homeassistant/components/waqi/* @andrey-git
|
||||
homeassistant/components/watson_tts/* @rutkai
|
||||
homeassistant/components/weather/* @fabaff
|
||||
@ -275,6 +293,7 @@ homeassistant/components/yeelight/* @rytilahti @zewelor
|
||||
homeassistant/components/yeelightsunflower/* @lindsaymarkward
|
||||
homeassistant/components/yessssms/* @flowolf
|
||||
homeassistant/components/yi/* @bachya
|
||||
homeassistant/components/yr/* @danielhiversen
|
||||
homeassistant/components/zeroconf/* @robbiet480 @Kane610
|
||||
homeassistant/components/zha/* @dmulcahey @adminiuga
|
||||
homeassistant/components/zone/* @home-assistant/core
|
||||
|
150
azure-pipelines-ci.yml
Normal file
150
azure-pipelines-ci.yml
Normal file
@ -0,0 +1,150 @@
|
||||
# https://dev.azure.com/home-assistant
|
||||
|
||||
trigger:
|
||||
batch: true
|
||||
branches:
|
||||
include:
|
||||
- dev
|
||||
pr: none
|
||||
|
||||
resources:
|
||||
containers:
|
||||
- container: 35
|
||||
image: homeassistant/ci-azure:3.5
|
||||
- container: 36
|
||||
image: homeassistant/ci-azure:3.6
|
||||
- container: 37
|
||||
image: homeassistant/ci-azure:3.7
|
||||
|
||||
|
||||
variables:
|
||||
- name: ArtifactFeed
|
||||
value: '2df3ae11-3bf6-49bc-a809-ba0d340d6a6d'
|
||||
- name: PythonMain
|
||||
value: '35'
|
||||
|
||||
|
||||
jobs:
|
||||
|
||||
- job: 'Lint'
|
||||
pool:
|
||||
vmImage: 'ubuntu-latest'
|
||||
container: $[ variables['PythonMain'] ]
|
||||
steps:
|
||||
- script: |
|
||||
python -m venv lint
|
||||
|
||||
. lint/bin/activate
|
||||
pip install flake8
|
||||
flake8 homeassistant tests script
|
||||
displayName: 'Run flake8'
|
||||
|
||||
|
||||
- job: 'Check'
|
||||
dependsOn:
|
||||
- Lint
|
||||
pool:
|
||||
vmImage: 'ubuntu-latest'
|
||||
strategy:
|
||||
maxParallel: 1
|
||||
matrix:
|
||||
Python35:
|
||||
python.version: '3.5'
|
||||
python.container: '35'
|
||||
Python36:
|
||||
python.version: '3.6'
|
||||
python.container: '36'
|
||||
Python37:
|
||||
python.version: '3.7'
|
||||
python.container: '37'
|
||||
container: $[ variables['python.container'] ]
|
||||
steps:
|
||||
- script: |
|
||||
echo "$(python.version)" > .cache
|
||||
displayName: 'Set python $(python.version) for requirement cache'
|
||||
|
||||
- task: 1ESLighthouseEng.PipelineArtifactCaching.RestoreCacheV1.RestoreCache@1
|
||||
displayName: 'Restore artifacts based on Requirements'
|
||||
inputs:
|
||||
keyfile: 'requirements_test_all.txt, .cache'
|
||||
targetfolder: './venv'
|
||||
vstsFeed: '$(ArtifactFeed)'
|
||||
|
||||
- script: |
|
||||
set -e
|
||||
python -m venv venv
|
||||
|
||||
. venv/bin/activate
|
||||
pip install -U pip setuptools
|
||||
pip install -r requirements_test_all.txt -c homeassistant/package_constraints.txt
|
||||
displayName: 'Create Virtual Environment & Install Requirements'
|
||||
condition: and(succeeded(), ne(variables['CacheRestored'], 'true'))
|
||||
|
||||
- task: 1ESLighthouseEng.PipelineArtifactCaching.SaveCacheV1.SaveCache@1
|
||||
displayName: 'Save artifacts based on Requirements'
|
||||
inputs:
|
||||
keyfile: 'requirements_test_all.txt, .cache'
|
||||
targetfolder: './venv'
|
||||
vstsFeed: '$(ArtifactFeed)'
|
||||
|
||||
- script: |
|
||||
. venv/bin/activate
|
||||
pip install -e .
|
||||
displayName: 'Install Home Assistant for python $(python.version)'
|
||||
|
||||
- script: |
|
||||
. venv/bin/activate
|
||||
pytest --timeout=9 --durations=10 --junitxml=junit/test-results.xml -qq -o console_output_style=count -p no:sugar tests
|
||||
displayName: 'Run pytest for python $(python.version)'
|
||||
|
||||
- task: PublishTestResults@2
|
||||
condition: succeededOrFailed()
|
||||
inputs:
|
||||
testResultsFiles: '**/test-*.xml'
|
||||
testRunTitle: 'Publish test results for Python $(python.version)'
|
||||
|
||||
- job: 'FullCheck'
|
||||
dependsOn:
|
||||
- Check
|
||||
pool:
|
||||
vmImage: 'ubuntu-latest'
|
||||
container: $[ variables['PythonMain'] ]
|
||||
steps:
|
||||
- script: |
|
||||
echo "$(PythonMain)" > .cache
|
||||
displayName: 'Set python $(python.version) for requirement cache'
|
||||
- task: 1ESLighthouseEng.PipelineArtifactCaching.RestoreCacheV1.RestoreCache@1
|
||||
displayName: 'Restore artifacts based on Requirements'
|
||||
inputs:
|
||||
keyfile: 'requirements_all.txt, requirements_test.txt, .cache'
|
||||
targetfolder: './venv'
|
||||
vstsFeed: '$(ArtifactFeed)'
|
||||
|
||||
- script: |
|
||||
set -e
|
||||
python -m venv venv
|
||||
|
||||
. venv/bin/activate
|
||||
pip install -U pip setuptools
|
||||
pip install -r requirements_all.txt -c homeassistant/package_constraints.txt
|
||||
pip install -r requirements_test.txt -c homeassistant/package_constraints.txt
|
||||
displayName: 'Create Virtual Environment & Install Requirements'
|
||||
condition: and(succeeded(), ne(variables['CacheRestored'], 'true'))
|
||||
|
||||
- task: 1ESLighthouseEng.PipelineArtifactCaching.SaveCacheV1.SaveCache@1
|
||||
displayName: 'Save artifacts based on Requirements'
|
||||
inputs:
|
||||
keyfile: 'requirements_all.txt, requirements_test.txt, .cache'
|
||||
targetfolder: './venv'
|
||||
vstsFeed: '$(ArtifactFeed)'
|
||||
|
||||
- script: |
|
||||
. venv/bin/activate
|
||||
pip install -e .
|
||||
displayName: 'Install Home Assistant for python $(python.version)'
|
||||
|
||||
- script: |
|
||||
. venv/bin/activate
|
||||
pylint homeassistant
|
||||
displayName: 'Run pylint'
|
||||
|
@ -8,7 +8,7 @@ trigger:
|
||||
pr: none
|
||||
variables:
|
||||
- name: versionBuilder
|
||||
value: '3.2'
|
||||
value: '4.2'
|
||||
- group: docker
|
||||
- group: github
|
||||
- group: twine
|
||||
|
100
azure-pipelines-wheels.yml
Normal file
100
azure-pipelines-wheels.yml
Normal file
@ -0,0 +1,100 @@
|
||||
# https://dev.azure.com/home-assistant
|
||||
|
||||
trigger:
|
||||
batch: true
|
||||
branches:
|
||||
include:
|
||||
- dev
|
||||
paths:
|
||||
include:
|
||||
- requirements_all.txt
|
||||
pr: none
|
||||
variables:
|
||||
- name: versionWheels
|
||||
value: '0.7'
|
||||
- group: wheels
|
||||
|
||||
|
||||
jobs:
|
||||
|
||||
- job: 'Wheels'
|
||||
condition: or(eq(variables['Build.SourceBranchName'], 'dev'), eq(variables['Build.SourceBranchName'], 'master'))
|
||||
timeoutInMinutes: 360
|
||||
pool:
|
||||
vmImage: 'ubuntu-latest'
|
||||
strategy:
|
||||
maxParallel: 3
|
||||
matrix:
|
||||
amd64:
|
||||
buildArch: 'amd64'
|
||||
i386:
|
||||
buildArch: 'i386'
|
||||
armhf:
|
||||
buildArch: 'armhf'
|
||||
armv7:
|
||||
buildArch: 'armv7'
|
||||
aarch64:
|
||||
buildArch: 'aarch64'
|
||||
steps:
|
||||
- script: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
qemu-user-static \
|
||||
binfmt-support \
|
||||
curl
|
||||
|
||||
sudo mount binfmt_misc -t binfmt_misc /proc/sys/fs/binfmt_misc
|
||||
sudo update-binfmts --enable qemu-arm
|
||||
sudo update-binfmts --enable qemu-aarch64
|
||||
displayName: 'Initial cross build'
|
||||
- script: |
|
||||
mkdir -p .ssh
|
||||
echo -e "-----BEGIN RSA PRIVATE KEY-----\n$(wheelsSSH)\n-----END RSA PRIVATE KEY-----" >> .ssh/id_rsa
|
||||
ssh-keyscan -H $(wheelsHost) >> .ssh/known_hosts
|
||||
chmod 600 .ssh/*
|
||||
displayName: 'Install ssh key'
|
||||
- script: sudo docker pull homeassistant/$(buildArch)-wheels:$(versionWheels)
|
||||
displayName: 'Install wheels builder'
|
||||
- script: |
|
||||
cp requirements_all.txt requirements_wheels.txt
|
||||
if [[ "$(Build.Reason)" =~ (Schedule|Manual) ]]; then
|
||||
touch requirements_diff.txt
|
||||
else
|
||||
curl -s -o requirements_diff.txt https://raw.githubusercontent.com/home-assistant/home-assistant/master/requirements_all.txt
|
||||
fi
|
||||
|
||||
requirement_files="requirements_wheels.txt requirements_diff.txt"
|
||||
for requirement_file in ${requirement_files}; do
|
||||
sed -i "s|# pytradfri|pytradfri|g" ${requirement_file}
|
||||
sed -i "s|# pybluez|pybluez|g" ${requirement_file}
|
||||
sed -i "s|# bluepy|bluepy|g" ${requirement_file}
|
||||
sed -i "s|# beacontools|beacontools|g" ${requirement_file}
|
||||
sed -i "s|# RPi.GPIO|RPi.GPIO|g" ${requirement_file}
|
||||
sed -i "s|# raspihats|raspihats|g" ${requirement_file}
|
||||
sed -i "s|# rpi-rf|rpi-rf|g" ${requirement_file}
|
||||
sed -i "s|# blinkt|blinkt|g" ${requirement_file}
|
||||
sed -i "s|# fritzconnection|fritzconnection|g" ${requirement_file}
|
||||
sed -i "s|# pyuserinput|pyuserinput|g" ${requirement_file}
|
||||
sed -i "s|# evdev|evdev|g" ${requirement_file}
|
||||
sed -i "s|# smbus-cffi|smbus-cffi|g" ${requirement_file}
|
||||
sed -i "s|# i2csense|i2csense|g" ${requirement_file}
|
||||
sed -i "s|# python-eq3bt|python-eq3bt|g" ${requirement_file}
|
||||
sed -i "s|# pycups|pycups|g" ${requirement_file}
|
||||
sed -i "s|# homekit|homekit|g" ${requirement_file}
|
||||
sed -i "s|# decora_wifi|decora_wifi|g" ${requirement_file}
|
||||
sed -i "s|# decora|decora|g" ${requirement_file}
|
||||
sed -i "s|# PySwitchbot|PySwitchbot|g" ${requirement_file}
|
||||
sed -i "s|# pySwitchmate|pySwitchmate|g" ${requirement_file}
|
||||
sed -i "s|# face_recognition|face_recognition|g" ${requirement_file}
|
||||
done
|
||||
displayName: 'Prepare requirements files for Hass.io'
|
||||
- script: |
|
||||
sudo docker run --rm -v $(pwd):/data:ro -v $(pwd)/.ssh:/root/.ssh:rw \
|
||||
homeassistant/$(buildArch)-wheels:$(versionWheels) \
|
||||
--apk "build-base;cmake;git;linux-headers;bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;autoconf;automake;cups-dev;linux-headers;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev" \
|
||||
--index $(wheelsIndex) \
|
||||
--requirement requirements_wheels.txt \
|
||||
--requirement-diff requirements_diff.txt \
|
||||
--upload rsync \
|
||||
--remote wheels@$(wheelsHost):/opt/wheels
|
||||
displayName: 'Run wheels build'
|
@ -17,7 +17,6 @@ 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__)
|
||||
|
||||
@ -101,51 +100,6 @@ async def async_from_config_dict(config: Dict[str, Any],
|
||||
"upgrade Python.", "Python version", "python_version"
|
||||
)
|
||||
|
||||
# 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 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."
|
||||
)
|
||||
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))
|
||||
|
||||
hass.components.persistent_notification.async_create(
|
||||
'\n\n'.join(msg), "Config Warning", "config_warning"
|
||||
)
|
||||
|
||||
return hass
|
||||
|
||||
|
||||
|
29
homeassistant/components/adguard/.translations/ca.json
Normal file
29
homeassistant/components/adguard/.translations/ca.json
Normal file
@ -0,0 +1,29 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"single_instance_allowed": "Nom\u00e9s es permet una \u00fanica configuraci\u00f3 d'AdGuard Home."
|
||||
},
|
||||
"error": {
|
||||
"connection_error": "No s'ha pogut connectar."
|
||||
},
|
||||
"step": {
|
||||
"hassio_confirm": {
|
||||
"description": "Vols configurar Home Assistant perqu\u00e8 es connecti amb l'AdGuard Home proporcionat pel complement de Hass.io: {addon}?",
|
||||
"title": "AdGuard Home (complement de Hass.io)"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "Amfitri\u00f3",
|
||||
"password": "Contrasenya",
|
||||
"port": "Port",
|
||||
"ssl": "AdGuard Home utilitza un certificat SSL",
|
||||
"username": "Nom d'usuari",
|
||||
"verify_ssl": "AdGuard Home utilitza un certificat adequat"
|
||||
},
|
||||
"description": "Configuraci\u00f3 de la inst\u00e0ncia d'AdGuard Home, permet el control i la monitoritzaci\u00f3.",
|
||||
"title": "Enlla\u00e7ar AdGuard Home."
|
||||
}
|
||||
},
|
||||
"title": "AdGuard Home"
|
||||
}
|
||||
}
|
28
homeassistant/components/adguard/.translations/de.json
Normal file
28
homeassistant/components/adguard/.translations/de.json
Normal file
@ -0,0 +1,28 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
29
homeassistant/components/adguard/.translations/en.json
Normal file
29
homeassistant/components/adguard/.translations/en.json
Normal file
@ -0,0 +1,29 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"single_instance_allowed": "Only a single configuration of AdGuard Home is allowed."
|
||||
},
|
||||
"error": {
|
||||
"connection_error": "Failed to connect."
|
||||
},
|
||||
"step": {
|
||||
"hassio_confirm": {
|
||||
"description": "Do you want to configure Home Assistant to connect to the AdGuard Home provided by the Hass.io add-on: {addon}?",
|
||||
"title": "AdGuard Home via Hass.io add-on"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "Host",
|
||||
"password": "Password",
|
||||
"port": "Port",
|
||||
"ssl": "AdGuard Home uses a SSL certificate",
|
||||
"username": "Username",
|
||||
"verify_ssl": "AdGuard Home uses a proper certificate"
|
||||
},
|
||||
"description": "Set up your AdGuard Home instance to allow monitoring and control.",
|
||||
"title": "Link your AdGuard Home."
|
||||
}
|
||||
},
|
||||
"title": "AdGuard Home"
|
||||
}
|
||||
}
|
27
homeassistant/components/adguard/.translations/es-419.json
Normal file
27
homeassistant/components/adguard/.translations/es-419.json
Normal file
@ -0,0 +1,27 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"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": "AdGuard Home"
|
||||
}
|
||||
}
|
21
homeassistant/components/adguard/.translations/it.json
Normal file
21
homeassistant/components/adguard/.translations/it.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"single_instance_allowed": "\u00c8 consentita solo una singola configurazione di AdGuard Home."
|
||||
},
|
||||
"error": {
|
||||
"connection_error": "Impossibile connettersi."
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "Host",
|
||||
"password": "Password",
|
||||
"port": "Porta",
|
||||
"ssl": "AdGuard Home utilizza un certificato SSL",
|
||||
"username": "Nome utente"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
29
homeassistant/components/adguard/.translations/ko.json
Normal file
29
homeassistant/components/adguard/.translations/ko.json
Normal file
@ -0,0 +1,29 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"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\ud569\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"
|
||||
}
|
||||
},
|
||||
"title": "AdGuard Home"
|
||||
}
|
||||
}
|
29
homeassistant/components/adguard/.translations/lb.json
Normal file
29
homeassistant/components/adguard/.translations/lb.json
Normal file
@ -0,0 +1,29 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"title": "AdGuard Home"
|
||||
}
|
||||
}
|
29
homeassistant/components/adguard/.translations/nl.json
Normal file
29
homeassistant/components/adguard/.translations/nl.json
Normal file
@ -0,0 +1,29 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"title": "AdGuard Home"
|
||||
}
|
||||
}
|
29
homeassistant/components/adguard/.translations/no.json
Normal file
29
homeassistant/components/adguard/.translations/no.json
Normal file
@ -0,0 +1,29 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"single_instance_allowed": "Kun \u00e9n enkelt konfigurasjon av AdGuard Hjemer tillatt."
|
||||
},
|
||||
"error": {
|
||||
"connection_error": "Tilkobling mislyktes."
|
||||
},
|
||||
"step": {
|
||||
"hassio_confirm": {
|
||||
"description": "Vil du konfigurere Home Assistant til \u00e5 koble til AdGuard Hjem gitt av hass.io tillegget {addon}?",
|
||||
"title": "AdGuard Hjem via Hass.io tillegg"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "Vert",
|
||||
"password": "Passord",
|
||||
"port": "Port",
|
||||
"ssl": "AdGuard Hjem bruker et SSL-sertifikat",
|
||||
"username": "Brukernavn",
|
||||
"verify_ssl": "AdGuard Home bruker et riktig sertifikat"
|
||||
},
|
||||
"description": "Sett opp din AdGuard Hjem instans for \u00e5 tillate overv\u00e5king og kontroll.",
|
||||
"title": "Koble til ditt AdGuard Hjem."
|
||||
}
|
||||
},
|
||||
"title": "AdGuard Hjem"
|
||||
}
|
||||
}
|
29
homeassistant/components/adguard/.translations/pl.json
Normal file
29
homeassistant/components/adguard/.translations/pl.json
Normal file
@ -0,0 +1,29 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"single_instance_allowed": "Dozwolona jest tylko jedna konfiguracja AdGuard Home."
|
||||
},
|
||||
"error": {
|
||||
"connection_error": "Po\u0142\u0105czenie nieudane."
|
||||
},
|
||||
"step": {
|
||||
"hassio_confirm": {
|
||||
"description": "Czy chcesz skonfigurowa\u0107 Home Assistant'a, 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": "Host",
|
||||
"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 swoj\u0105 instancj\u0119 AdGuard Home, aby umo\u017cliwi\u0107 monitorowanie i nadz\u00f3r sieci.",
|
||||
"title": "Po\u0142\u0105cz sw\u00f3j AdGuard Home"
|
||||
}
|
||||
},
|
||||
"title": "AdGuard Home"
|
||||
}
|
||||
}
|
29
homeassistant/components/adguard/.translations/pt-BR.json
Normal file
29
homeassistant/components/adguard/.translations/pt-BR.json
Normal file
@ -0,0 +1,29 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"single_instance_allowed": "Apenas uma \u00fanica configura\u00e7\u00e3o do AdGuard Home \u00e9 permitida."
|
||||
},
|
||||
"error": {
|
||||
"connection_error": "Falhou ao conectar."
|
||||
},
|
||||
"step": {
|
||||
"hassio_confirm": {
|
||||
"description": "Deseja configurar o Home Assistant para se conectar ao AdGuard Home fornecido pelo complemento Hass.io: {addon} ?",
|
||||
"title": "AdGuard Home via add-on Hass.io"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "Host",
|
||||
"password": "Senha",
|
||||
"port": "Porta",
|
||||
"ssl": "O AdGuard Home usa um certificado SSL",
|
||||
"username": "Nome de usu\u00e1rio",
|
||||
"verify_ssl": "O AdGuard Home usa um certificado apropriado"
|
||||
},
|
||||
"description": "Configure sua inst\u00e2ncia do AdGuard Home para permitir o monitoramento e o controle.",
|
||||
"title": "Vincule o seu AdGuard Home."
|
||||
}
|
||||
},
|
||||
"title": "AdGuard Home"
|
||||
}
|
||||
}
|
29
homeassistant/components/adguard/.translations/ru.json
Normal file
29
homeassistant/components/adguard/.translations/ru.json
Normal file
@ -0,0 +1,29 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"single_instance_allowed": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430."
|
||||
},
|
||||
"error": {
|
||||
"connection_error": "\u041e\u0448\u0438\u0431\u043a\u0430 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f."
|
||||
},
|
||||
"step": {
|
||||
"hassio_confirm": {
|
||||
"description": "\u0412\u044b \u0443\u0432\u0435\u0440\u0435\u043d\u044b, \u0447\u0442\u043e \u0445\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435 \u043a AdGuard Home (\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u0435 \u0434\u043b\u044f Hass.io \"{addon}\")?",
|
||||
"title": "AdGuard Home (\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u0435 \u0434\u043b\u044f Hass.io)"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "\u0425\u043e\u0441\u0442",
|
||||
"password": "\u041f\u0430\u0440\u043e\u043b\u044c",
|
||||
"port": "\u041f\u043e\u0440\u0442",
|
||||
"ssl": "AdGuard Home \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u0442 \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442 SSL",
|
||||
"username": "\u041b\u043e\u0433\u0438\u043d",
|
||||
"verify_ssl": "AdGuard Home \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u0442 \u0441\u043e\u0431\u0441\u0442\u0432\u0435\u043d\u043d\u044b\u0439 \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442"
|
||||
},
|
||||
"description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 \u044d\u0442\u043e\u0442 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442 \u0434\u043b\u044f \u043c\u043e\u043d\u0438\u0442\u043e\u0440\u0438\u043d\u0433\u0430 \u0438 \u043a\u043e\u043d\u0442\u0440\u043e\u043b\u044f AdGuard Home.",
|
||||
"title": "AdGuard Home"
|
||||
}
|
||||
},
|
||||
"title": "AdGuard Home"
|
||||
}
|
||||
}
|
29
homeassistant/components/adguard/.translations/sl.json
Normal file
29
homeassistant/components/adguard/.translations/sl.json
Normal file
@ -0,0 +1,29 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"title": "AdGuard Home"
|
||||
}
|
||||
}
|
29
homeassistant/components/adguard/.translations/sv.json
Normal file
29
homeassistant/components/adguard/.translations/sv.json
Normal file
@ -0,0 +1,29 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"single_instance_allowed": "Endast en enda konfiguration av AdGuard Home \u00e4r till\u00e5ten."
|
||||
},
|
||||
"error": {
|
||||
"connection_error": "Det gick inte att ansluta."
|
||||
},
|
||||
"step": {
|
||||
"hassio_confirm": {
|
||||
"description": "Vill du konfigurera Home Assistant f\u00f6r att ansluta till AdGuard Home som tillhandah\u00e5lls av Hass.io Add-on: {addon}?",
|
||||
"title": "AdGuard Home via Hass.io-till\u00e4gget"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "V\u00e4rd",
|
||||
"password": "L\u00f6senord",
|
||||
"port": "Port",
|
||||
"ssl": "AdGuard Home anv\u00e4nder ett SSL-certifikat",
|
||||
"username": "Anv\u00e4ndarnamn",
|
||||
"verify_ssl": "AdGuard Home anv\u00e4nder ett korrekt certifikat"
|
||||
},
|
||||
"description": "St\u00e4ll in din AdGuard Home-instans f\u00f6r att till\u00e5ta \u00f6vervakning och kontroll.",
|
||||
"title": "L\u00e4nka din AdGuard Home."
|
||||
}
|
||||
},
|
||||
"title": "AdGuard Home"
|
||||
}
|
||||
}
|
14
homeassistant/components/adguard/.translations/vi.json
Normal file
14
homeassistant/components/adguard/.translations/vi.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
29
homeassistant/components/adguard/.translations/zh-Hant.json
Normal file
29
homeassistant/components/adguard/.translations/zh-Hant.json
Normal file
@ -0,0 +1,29 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"single_instance_allowed": "\u50c5\u5141\u8a31\u8a2d\u5b9a\u4e00\u7d44 AdGuard Home\u3002"
|
||||
},
|
||||
"error": {
|
||||
"connection_error": "\u9023\u7dda\u5931\u6557\u3002"
|
||||
},
|
||||
"step": {
|
||||
"hassio_confirm": {
|
||||
"description": "\u662f\u5426\u8981\u8a2d\u5b9a Home Assistant \u4ee5\u4f7f\u7528 Hass.io \u9644\u52a0\u7d44\u4ef6\uff1a{addon} \u9023\u7dda\u81f3 AdGuard Home\uff1f",
|
||||
"title": "\u4f7f\u7528 Hass.io \u9644\u52a0\u7d44\u4ef6 AdGuard Home"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "\u4e3b\u6a5f\u7aef",
|
||||
"password": "\u5bc6\u78bc",
|
||||
"port": "\u901a\u8a0a\u57e0",
|
||||
"ssl": "AdGuard Home \u4f7f\u7528 SSL \u8a8d\u8b49",
|
||||
"username": "\u4f7f\u7528\u8005\u540d\u7a31",
|
||||
"verify_ssl": "AdGuard Home \u4f7f\u7528\u5c0d\u61c9\u8a8d\u8b49"
|
||||
},
|
||||
"description": "\u8a2d\u5b9a AdGuard Home \u4ee5\u9032\u884c\u76e3\u63a7\u3002",
|
||||
"title": "\u9023\u7d50 AdGuard Home\u3002"
|
||||
}
|
||||
},
|
||||
"title": "AdGuard Home"
|
||||
}
|
||||
}
|
180
homeassistant/components/adguard/__init__.py
Normal file
180
homeassistant/components/adguard/__init__.py
Normal file
@ -0,0 +1,180 @@
|
||||
"""Support for AdGuard Home."""
|
||||
import logging
|
||||
from typing import Any, Dict
|
||||
|
||||
from adguardhome import AdGuardHome, AdGuardHomeError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.adguard.const import (
|
||||
CONF_FORCE, DATA_ADGUARD_CLIENT, DATA_ADGUARD_VERION, DOMAIN,
|
||||
SERVICE_ADD_URL, SERVICE_DISABLE_URL, SERVICE_ENABLE_URL, SERVICE_REFRESH,
|
||||
SERVICE_REMOVE_URL)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT, CONF_SSL, CONF_URL,
|
||||
CONF_USERNAME, CONF_VERIFY_SSL)
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.typing import ConfigType, HomeAssistantType
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
SERVICE_URL_SCHEMA = vol.Schema({vol.Required(CONF_URL): cv.url})
|
||||
SERVICE_ADD_URL_SCHEMA = vol.Schema(
|
||||
{vol.Required(CONF_NAME): cv.string, vol.Required(CONF_URL): cv.url}
|
||||
)
|
||||
SERVICE_REFRESH_SCHEMA = vol.Schema(
|
||||
{vol.Optional(CONF_FORCE, default=False): cv.boolean}
|
||||
)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool:
|
||||
"""Set up the AdGuard Home components."""
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistantType, entry: ConfigEntry
|
||||
) -> bool:
|
||||
"""Set up AdGuard Home from a config entry."""
|
||||
session = async_get_clientsession(hass, entry.data[CONF_VERIFY_SSL])
|
||||
adguard = AdGuardHome(
|
||||
entry.data[CONF_HOST],
|
||||
port=entry.data[CONF_PORT],
|
||||
username=entry.data[CONF_USERNAME],
|
||||
password=entry.data[CONF_PASSWORD],
|
||||
tls=entry.data[CONF_SSL],
|
||||
verify_ssl=entry.data[CONF_VERIFY_SSL],
|
||||
loop=hass.loop,
|
||||
session=session,
|
||||
)
|
||||
|
||||
hass.data.setdefault(DOMAIN, {})[DATA_ADGUARD_CLIENT] = adguard
|
||||
|
||||
for component in 'sensor', 'switch':
|
||||
hass.async_create_task(
|
||||
hass.config_entries.async_forward_entry_setup(entry, component)
|
||||
)
|
||||
|
||||
async def add_url(call) -> None:
|
||||
"""Service call to add a new filter subscription to AdGuard Home."""
|
||||
await adguard.filtering.add_url(
|
||||
call.data.get(CONF_NAME), call.data.get(CONF_URL)
|
||||
)
|
||||
|
||||
async def remove_url(call) -> None:
|
||||
"""Service call to remove a filter subscription from AdGuard Home."""
|
||||
await adguard.filtering.remove_url(call.data.get(CONF_URL))
|
||||
|
||||
async def enable_url(call) -> None:
|
||||
"""Service call to enable a filter subscription in AdGuard Home."""
|
||||
await adguard.filtering.enable_url(call.data.get(CONF_URL))
|
||||
|
||||
async def disable_url(call) -> None:
|
||||
"""Service call to disable a filter subscription in AdGuard Home."""
|
||||
await adguard.filtering.disable_url(call.data.get(CONF_URL))
|
||||
|
||||
async def refresh(call) -> None:
|
||||
"""Service call to refresh the filter subscriptions in AdGuard Home."""
|
||||
await adguard.filtering.refresh(call.data.get(CONF_FORCE))
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_ADD_URL, add_url, schema=SERVICE_ADD_URL_SCHEMA
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_REMOVE_URL, remove_url, schema=SERVICE_URL_SCHEMA
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_ENABLE_URL, enable_url, schema=SERVICE_URL_SCHEMA
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_DISABLE_URL, disable_url, schema=SERVICE_URL_SCHEMA
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_REFRESH, refresh, schema=SERVICE_REFRESH_SCHEMA
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(
|
||||
hass: HomeAssistantType, entry: ConfigType
|
||||
) -> bool:
|
||||
"""Unload AdGuard Home config entry."""
|
||||
hass.services.async_remove(DOMAIN, SERVICE_ADD_URL)
|
||||
hass.services.async_remove(DOMAIN, SERVICE_REMOVE_URL)
|
||||
hass.services.async_remove(DOMAIN, SERVICE_ENABLE_URL)
|
||||
hass.services.async_remove(DOMAIN, SERVICE_DISABLE_URL)
|
||||
hass.services.async_remove(DOMAIN, SERVICE_REFRESH)
|
||||
|
||||
for component in 'sensor', 'switch':
|
||||
await hass.config_entries.async_forward_entry_unload(entry, component)
|
||||
|
||||
del hass.data[DOMAIN]
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class AdGuardHomeEntity(Entity):
|
||||
"""Defines a base AdGuard Home entity."""
|
||||
|
||||
def __init__(self, adguard, name: str, icon: str) -> None:
|
||||
"""Initialize the AdGuard Home entity."""
|
||||
self._name = name
|
||||
self._icon = icon
|
||||
self._available = True
|
||||
self.adguard = adguard
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Return the name of the entity."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def icon(self) -> str:
|
||||
"""Return the mdi icon of the entity."""
|
||||
return self._icon
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
return self._available
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Update AdGuard Home entity."""
|
||||
try:
|
||||
await self._adguard_update()
|
||||
self._available = True
|
||||
except AdGuardHomeError:
|
||||
if self._available:
|
||||
_LOGGER.debug(
|
||||
"An error occurred while updating AdGuard Home sensor.",
|
||||
exc_info=True,
|
||||
)
|
||||
self._available = False
|
||||
|
||||
async def _adguard_update(self) -> None:
|
||||
"""Update AdGuard Home entity."""
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
class AdGuardHomeDeviceEntity(AdGuardHomeEntity):
|
||||
"""Defines a AdGuard Home device entity."""
|
||||
|
||||
@property
|
||||
def device_info(self) -> Dict[str, Any]:
|
||||
"""Return device information about this AdGuard Home instance."""
|
||||
return {
|
||||
'identifiers': {
|
||||
(
|
||||
DOMAIN,
|
||||
self.adguard.host,
|
||||
self.adguard.port,
|
||||
self.adguard.base_path,
|
||||
)
|
||||
},
|
||||
'name': 'AdGuard Home',
|
||||
'manufacturer': 'AdGuard Team',
|
||||
'sw_version': self.hass.data[DOMAIN].get(DATA_ADGUARD_VERION),
|
||||
}
|
168
homeassistant/components/adguard/config_flow.py
Normal file
168
homeassistant/components/adguard/config_flow.py
Normal file
@ -0,0 +1,168 @@
|
||||
"""Config flow to configure the AdGuard Home integration."""
|
||||
import logging
|
||||
|
||||
from adguardhome import AdGuardHome, AdGuardHomeConnectionError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.adguard.const import DOMAIN
|
||||
from homeassistant.config_entries import ConfigFlow
|
||||
from homeassistant.const import (
|
||||
CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_SSL, CONF_USERNAME,
|
||||
CONF_VERIFY_SSL)
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@config_entries.HANDLERS.register(DOMAIN)
|
||||
class AdGuardHomeFlowHandler(ConfigFlow):
|
||||
"""Handle a AdGuard Home config flow."""
|
||||
|
||||
VERSION = 1
|
||||
CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL
|
||||
|
||||
_hassio_discovery = None
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize AgGuard Home flow."""
|
||||
pass
|
||||
|
||||
async def _show_setup_form(self, errors=None):
|
||||
"""Show the setup form to the user."""
|
||||
return self.async_show_form(
|
||||
step_id='user',
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOST): str,
|
||||
vol.Required(CONF_PORT, default=3000): vol.Coerce(int),
|
||||
vol.Optional(CONF_USERNAME): str,
|
||||
vol.Optional(CONF_PASSWORD): str,
|
||||
vol.Required(CONF_SSL, default=True): bool,
|
||||
vol.Required(CONF_VERIFY_SSL, default=True): bool,
|
||||
}
|
||||
),
|
||||
errors=errors or {},
|
||||
)
|
||||
|
||||
async def _show_hassio_form(self, errors=None):
|
||||
"""Show the Hass.io confirmation form to the user."""
|
||||
return self.async_show_form(
|
||||
step_id='hassio_confirm',
|
||||
description_placeholders={
|
||||
'addon': self._hassio_discovery['addon']
|
||||
},
|
||||
data_schema=vol.Schema({}),
|
||||
errors=errors or {},
|
||||
)
|
||||
|
||||
async def async_step_user(self, user_input=None):
|
||||
"""Handle a flow initiated by the user."""
|
||||
if self._async_current_entries():
|
||||
return self.async_abort(reason='single_instance_allowed')
|
||||
|
||||
if user_input is None:
|
||||
return await self._show_setup_form(user_input)
|
||||
|
||||
errors = {}
|
||||
|
||||
session = async_get_clientsession(
|
||||
self.hass, user_input[CONF_VERIFY_SSL]
|
||||
)
|
||||
|
||||
adguard = AdGuardHome(
|
||||
user_input[CONF_HOST],
|
||||
port=user_input[CONF_PORT],
|
||||
username=user_input.get(CONF_USERNAME),
|
||||
password=user_input.get(CONF_PASSWORD),
|
||||
tls=user_input[CONF_SSL],
|
||||
verify_ssl=user_input[CONF_VERIFY_SSL],
|
||||
loop=self.hass.loop,
|
||||
session=session,
|
||||
)
|
||||
|
||||
try:
|
||||
await adguard.version()
|
||||
except AdGuardHomeConnectionError:
|
||||
errors['base'] = 'connection_error'
|
||||
return await self._show_setup_form(errors)
|
||||
|
||||
return self.async_create_entry(
|
||||
title=user_input[CONF_HOST],
|
||||
data={
|
||||
CONF_HOST: user_input[CONF_HOST],
|
||||
CONF_PASSWORD: user_input.get(CONF_PASSWORD),
|
||||
CONF_PORT: user_input[CONF_PORT],
|
||||
CONF_SSL: user_input[CONF_SSL],
|
||||
CONF_USERNAME: user_input.get(CONF_USERNAME),
|
||||
CONF_VERIFY_SSL: user_input[CONF_VERIFY_SSL],
|
||||
},
|
||||
)
|
||||
|
||||
async def async_step_hassio(self, user_input=None):
|
||||
"""Prepare configuration for a Hass.io AdGuard Home add-on.
|
||||
|
||||
This flow is triggered by the discovery component.
|
||||
"""
|
||||
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,
|
||||
loop=self.hass.loop,
|
||||
session=session,
|
||||
)
|
||||
|
||||
try:
|
||||
await adguard.version()
|
||||
except AdGuardHomeConnectionError:
|
||||
errors['base'] = 'connection_error'
|
||||
return await self._show_hassio_form(errors)
|
||||
|
||||
return self.async_create_entry(
|
||||
title=self._hassio_discovery['addon'],
|
||||
data={
|
||||
CONF_HOST: self._hassio_discovery[CONF_HOST],
|
||||
CONF_PORT: self._hassio_discovery[CONF_PORT],
|
||||
CONF_PASSWORD: None,
|
||||
CONF_SSL: False,
|
||||
CONF_USERNAME: None,
|
||||
CONF_VERIFY_SSL: True,
|
||||
},
|
||||
)
|
14
homeassistant/components/adguard/const.py
Normal file
14
homeassistant/components/adguard/const.py
Normal file
@ -0,0 +1,14 @@
|
||||
"""Constants for the AdGuard Home integration."""
|
||||
|
||||
DOMAIN = 'adguard'
|
||||
|
||||
DATA_ADGUARD_CLIENT = 'adguard_client'
|
||||
DATA_ADGUARD_VERION = 'adguard_version'
|
||||
|
||||
CONF_FORCE = 'force'
|
||||
|
||||
SERVICE_ADD_URL = 'add_url'
|
||||
SERVICE_DISABLE_URL = 'disable_url'
|
||||
SERVICE_ENABLE_URL = 'enable_url'
|
||||
SERVICE_REFRESH = 'refresh'
|
||||
SERVICE_REMOVE_URL = 'remove_url'
|
13
homeassistant/components/adguard/manifest.json
Normal file
13
homeassistant/components/adguard/manifest.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"domain": "adguard",
|
||||
"name": "AdGuard Home",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/components/adguard",
|
||||
"requirements": [
|
||||
"adguardhome==0.2.1"
|
||||
],
|
||||
"dependencies": [],
|
||||
"codeowners": [
|
||||
"@frenck"
|
||||
]
|
||||
}
|
232
homeassistant/components/adguard/sensor.py
Normal file
232
homeassistant/components/adguard/sensor.py
Normal file
@ -0,0 +1,232 @@
|
||||
"""Support for AdGuard Home sensors."""
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from adguardhome import AdGuardHomeConnectionError
|
||||
|
||||
from homeassistant.components.adguard import AdGuardHomeDeviceEntity
|
||||
from homeassistant.components.adguard.const import (
|
||||
DATA_ADGUARD_CLIENT, DATA_ADGUARD_VERION, DOMAIN)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.exceptions import PlatformNotReady
|
||||
from homeassistant.helpers.typing import HomeAssistantType
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=300)
|
||||
PARALLEL_UPDATES = 4
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistantType, entry: ConfigEntry, async_add_entities
|
||||
) -> None:
|
||||
"""Set up AdGuard Home sensor based on a config entry."""
|
||||
adguard = hass.data[DOMAIN][DATA_ADGUARD_CLIENT]
|
||||
|
||||
try:
|
||||
version = await adguard.version()
|
||||
except AdGuardHomeConnectionError as exception:
|
||||
raise PlatformNotReady from exception
|
||||
|
||||
hass.data[DOMAIN][DATA_ADGUARD_VERION] = version
|
||||
|
||||
sensors = [
|
||||
AdGuardHomeDNSQueriesSensor(adguard),
|
||||
AdGuardHomeBlockedFilteringSensor(adguard),
|
||||
AdGuardHomePercentageBlockedSensor(adguard),
|
||||
AdGuardHomeReplacedParentalSensor(adguard),
|
||||
AdGuardHomeReplacedSafeBrowsingSensor(adguard),
|
||||
AdGuardHomeReplacedSafeSearchSensor(adguard),
|
||||
AdGuardHomeAverageProcessingTimeSensor(adguard),
|
||||
AdGuardHomeRulesCountSensor(adguard),
|
||||
]
|
||||
|
||||
async_add_entities(sensors, True)
|
||||
|
||||
|
||||
class AdGuardHomeSensor(AdGuardHomeDeviceEntity):
|
||||
"""Defines a AdGuard Home sensor."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
adguard,
|
||||
name: str,
|
||||
icon: str,
|
||||
measurement: str,
|
||||
unit_of_measurement: str,
|
||||
) -> None:
|
||||
"""Initialize AdGuard Home sensor."""
|
||||
self._state = None
|
||||
self._unit_of_measurement = unit_of_measurement
|
||||
self.measurement = measurement
|
||||
|
||||
super().__init__(adguard, name, icon)
|
||||
|
||||
@property
|
||||
def unique_id(self) -> str:
|
||||
"""Return the unique ID for this sensor."""
|
||||
return '_'.join(
|
||||
[
|
||||
DOMAIN,
|
||||
self.adguard.host,
|
||||
str(self.adguard.port),
|
||||
'sensor',
|
||||
self.measurement,
|
||||
]
|
||||
)
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
"""Return the state of the sensor."""
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def unit_of_measurement(self) -> str:
|
||||
"""Return the unit this state is expressed in."""
|
||||
return self._unit_of_measurement
|
||||
|
||||
|
||||
class AdGuardHomeDNSQueriesSensor(AdGuardHomeSensor):
|
||||
"""Defines a AdGuard Home DNS Queries sensor."""
|
||||
|
||||
def __init__(self, adguard):
|
||||
"""Initialize AdGuard Home sensor."""
|
||||
super().__init__(
|
||||
adguard,
|
||||
'AdGuard DNS Queries',
|
||||
'mdi:magnify',
|
||||
'dns_queries',
|
||||
'queries',
|
||||
)
|
||||
|
||||
async def _adguard_update(self) -> None:
|
||||
"""Update AdGuard Home entity."""
|
||||
self._state = await self.adguard.stats.dns_queries()
|
||||
|
||||
|
||||
class AdGuardHomeBlockedFilteringSensor(AdGuardHomeSensor):
|
||||
"""Defines a AdGuard Home blocked by filtering sensor."""
|
||||
|
||||
def __init__(self, adguard):
|
||||
"""Initialize AdGuard Home sensor."""
|
||||
super().__init__(
|
||||
adguard,
|
||||
'AdGuard DNS Queries Blocked',
|
||||
'mdi:magnify-close',
|
||||
'blocked_filtering',
|
||||
'queries',
|
||||
)
|
||||
|
||||
async def _adguard_update(self) -> None:
|
||||
"""Update AdGuard Home entity."""
|
||||
self._state = await self.adguard.stats.blocked_filtering()
|
||||
|
||||
|
||||
class AdGuardHomePercentageBlockedSensor(AdGuardHomeSensor):
|
||||
"""Defines a AdGuard Home blocked percentage sensor."""
|
||||
|
||||
def __init__(self, adguard):
|
||||
"""Initialize AdGuard Home sensor."""
|
||||
super().__init__(
|
||||
adguard,
|
||||
'AdGuard DNS Queries Blocked Ratio',
|
||||
'mdi:magnify-close',
|
||||
'blocked_percentage',
|
||||
'%',
|
||||
)
|
||||
|
||||
async def _adguard_update(self) -> None:
|
||||
"""Update AdGuard Home entity."""
|
||||
percentage = await self.adguard.stats.blocked_percentage()
|
||||
self._state = "{:.2f}".format(percentage)
|
||||
|
||||
|
||||
class AdGuardHomeReplacedParentalSensor(AdGuardHomeSensor):
|
||||
"""Defines a AdGuard Home replaced by parental control sensor."""
|
||||
|
||||
def __init__(self, adguard):
|
||||
"""Initialize AdGuard Home sensor."""
|
||||
super().__init__(
|
||||
adguard,
|
||||
'AdGuard Parental Control Blocked',
|
||||
'mdi:human-male-girl',
|
||||
'blocked_parental',
|
||||
'requests',
|
||||
)
|
||||
|
||||
async def _adguard_update(self) -> None:
|
||||
"""Update AdGuard Home entity."""
|
||||
self._state = await self.adguard.stats.replaced_parental()
|
||||
|
||||
|
||||
class AdGuardHomeReplacedSafeBrowsingSensor(AdGuardHomeSensor):
|
||||
"""Defines a AdGuard Home replaced by safe browsing sensor."""
|
||||
|
||||
def __init__(self, adguard):
|
||||
"""Initialize AdGuard Home sensor."""
|
||||
super().__init__(
|
||||
adguard,
|
||||
'AdGuard Safe Browsing Blocked',
|
||||
'mdi:shield-half-full',
|
||||
'blocked_safebrowsing',
|
||||
'requests',
|
||||
)
|
||||
|
||||
async def _adguard_update(self) -> None:
|
||||
"""Update AdGuard Home entity."""
|
||||
self._state = await self.adguard.stats.replaced_safebrowsing()
|
||||
|
||||
|
||||
class AdGuardHomeReplacedSafeSearchSensor(AdGuardHomeSensor):
|
||||
"""Defines a AdGuard Home replaced by safe search sensor."""
|
||||
|
||||
def __init__(self, adguard):
|
||||
"""Initialize AdGuard Home sensor."""
|
||||
super().__init__(
|
||||
adguard,
|
||||
'Searches Safe Search Enforced',
|
||||
'mdi:shield-search',
|
||||
'enforced_safesearch',
|
||||
'requests',
|
||||
)
|
||||
|
||||
async def _adguard_update(self) -> None:
|
||||
"""Update AdGuard Home entity."""
|
||||
self._state = await self.adguard.stats.replaced_safesearch()
|
||||
|
||||
|
||||
class AdGuardHomeAverageProcessingTimeSensor(AdGuardHomeSensor):
|
||||
"""Defines a AdGuard Home average processing time sensor."""
|
||||
|
||||
def __init__(self, adguard):
|
||||
"""Initialize AdGuard Home sensor."""
|
||||
super().__init__(
|
||||
adguard,
|
||||
'AdGuard Average Processing Speed',
|
||||
'mdi:speedometer',
|
||||
'average_speed',
|
||||
'ms',
|
||||
)
|
||||
|
||||
async def _adguard_update(self) -> None:
|
||||
"""Update AdGuard Home entity."""
|
||||
average = await self.adguard.stats.avg_processing_time()
|
||||
self._state = "{:.2f}".format(average)
|
||||
|
||||
|
||||
class AdGuardHomeRulesCountSensor(AdGuardHomeSensor):
|
||||
"""Defines a AdGuard Home rules count sensor."""
|
||||
|
||||
def __init__(self, adguard):
|
||||
"""Initialize AdGuard Home sensor."""
|
||||
super().__init__(
|
||||
adguard,
|
||||
'AdGuard Rules Count',
|
||||
'mdi:counter',
|
||||
'rules_count',
|
||||
'rules',
|
||||
)
|
||||
|
||||
async def _adguard_update(self) -> None:
|
||||
"""Update AdGuard Home entity."""
|
||||
self._state = await self.adguard.filtering.rules_count()
|
37
homeassistant/components/adguard/services.yaml
Normal file
37
homeassistant/components/adguard/services.yaml
Normal file
@ -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.'
|
30
homeassistant/components/adguard/strings.json
Normal file
30
homeassistant/components/adguard/strings.json
Normal file
@ -0,0 +1,30 @@
|
||||
{
|
||||
"config": {
|
||||
"title": "AdGuard Home",
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Link your AdGuard Home.",
|
||||
"description": "Set up your AdGuard Home instance to allow monitoring and control.",
|
||||
"data": {
|
||||
"host": "Host",
|
||||
"password": "Password",
|
||||
"port": "Port",
|
||||
"username": "Username",
|
||||
"ssl": "AdGuard Home uses a SSL certificate",
|
||||
"verify_ssl": "AdGuard Home uses a proper certificate"
|
||||
}
|
||||
},
|
||||
"hassio_confirm": {
|
||||
"title": "AdGuard Home via Hass.io add-on",
|
||||
"description": "Do you want to configure Home Assistant to connect to the AdGuard Home provided by the Hass.io add-on: {addon}?"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"connection_error": "Failed to connect."
|
||||
},
|
||||
"abort": {
|
||||
"single_instance_allowed": "Only a single configuration of AdGuard Home is allowed.",
|
||||
"existing_instance_updated": "Updated existing configuration."
|
||||
}
|
||||
}
|
||||
}
|
233
homeassistant/components/adguard/switch.py
Normal file
233
homeassistant/components/adguard/switch.py
Normal file
@ -0,0 +1,233 @@
|
||||
"""Support for AdGuard Home switches."""
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from adguardhome import AdGuardHomeConnectionError, AdGuardHomeError
|
||||
|
||||
from homeassistant.components.adguard import AdGuardHomeDeviceEntity
|
||||
from homeassistant.components.adguard.const import (
|
||||
DATA_ADGUARD_CLIENT, DATA_ADGUARD_VERION, DOMAIN)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.exceptions import PlatformNotReady
|
||||
from homeassistant.helpers.entity import ToggleEntity
|
||||
from homeassistant.helpers.typing import HomeAssistantType
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=10)
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistantType, entry: ConfigEntry, async_add_entities
|
||||
) -> None:
|
||||
"""Set up AdGuard Home switch based on a config entry."""
|
||||
adguard = hass.data[DOMAIN][DATA_ADGUARD_CLIENT]
|
||||
|
||||
try:
|
||||
version = await adguard.version()
|
||||
except AdGuardHomeConnectionError as exception:
|
||||
raise PlatformNotReady from exception
|
||||
|
||||
hass.data[DOMAIN][DATA_ADGUARD_VERION] = version
|
||||
|
||||
switches = [
|
||||
AdGuardHomeProtectionSwitch(adguard),
|
||||
AdGuardHomeFilteringSwitch(adguard),
|
||||
AdGuardHomeParentalSwitch(adguard),
|
||||
AdGuardHomeSafeBrowsingSwitch(adguard),
|
||||
AdGuardHomeSafeSearchSwitch(adguard),
|
||||
AdGuardHomeQueryLogSwitch(adguard),
|
||||
]
|
||||
async_add_entities(switches, True)
|
||||
|
||||
|
||||
class AdGuardHomeSwitch(ToggleEntity, AdGuardHomeDeviceEntity):
|
||||
"""Defines a AdGuard Home switch."""
|
||||
|
||||
def __init__(self, adguard, name: str, icon: str, key: str):
|
||||
"""Initialize AdGuard Home switch."""
|
||||
self._state = False
|
||||
self._key = key
|
||||
super().__init__(adguard, name, icon)
|
||||
|
||||
@property
|
||||
def unique_id(self) -> str:
|
||||
"""Return the unique ID for this sensor."""
|
||||
return '_'.join(
|
||||
[
|
||||
DOMAIN,
|
||||
self.adguard.host,
|
||||
str(self.adguard.port),
|
||||
'switch',
|
||||
self._key,
|
||||
]
|
||||
)
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return the state of the switch."""
|
||||
return self._state
|
||||
|
||||
async def async_turn_off(self, **kwargs) -> None:
|
||||
"""Turn off the switch."""
|
||||
try:
|
||||
await self._adguard_turn_off()
|
||||
except AdGuardHomeError:
|
||||
_LOGGER.error(
|
||||
"An error occurred while turning off AdGuard Home switch."
|
||||
)
|
||||
self._available = False
|
||||
|
||||
async def _adguard_turn_off(self) -> None:
|
||||
"""Turn off the switch."""
|
||||
raise NotImplementedError()
|
||||
|
||||
async def async_turn_on(self, **kwargs) -> None:
|
||||
"""Turn on the switch."""
|
||||
try:
|
||||
await self._adguard_turn_on()
|
||||
except AdGuardHomeError:
|
||||
_LOGGER.error(
|
||||
"An error occurred while turning on AdGuard Home switch."
|
||||
)
|
||||
self._available = False
|
||||
|
||||
async def _adguard_turn_on(self) -> None:
|
||||
"""Turn on the switch."""
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
class AdGuardHomeProtectionSwitch(AdGuardHomeSwitch):
|
||||
"""Defines a AdGuard Home protection switch."""
|
||||
|
||||
def __init__(self, adguard) -> None:
|
||||
"""Initialize AdGuard Home switch."""
|
||||
super().__init__(
|
||||
adguard, "AdGuard Protection", 'mdi:shield-check', 'protection'
|
||||
)
|
||||
|
||||
async def _adguard_turn_off(self) -> None:
|
||||
"""Turn off the switch."""
|
||||
await self.adguard.disable_protection()
|
||||
|
||||
async def _adguard_turn_on(self) -> None:
|
||||
"""Turn on the switch."""
|
||||
await self.adguard.enable_protection()
|
||||
|
||||
async def _adguard_update(self) -> None:
|
||||
"""Update AdGuard Home entity."""
|
||||
self._state = await self.adguard.protection_enabled()
|
||||
|
||||
|
||||
class AdGuardHomeParentalSwitch(AdGuardHomeSwitch):
|
||||
"""Defines a AdGuard Home parental control switch."""
|
||||
|
||||
def __init__(self, adguard) -> None:
|
||||
"""Initialize AdGuard Home switch."""
|
||||
super().__init__(
|
||||
adguard, "AdGuard Parental Control", 'mdi:shield-check', 'parental'
|
||||
)
|
||||
|
||||
async def _adguard_turn_off(self) -> None:
|
||||
"""Turn off the switch."""
|
||||
await self.adguard.parental.disable()
|
||||
|
||||
async def _adguard_turn_on(self) -> None:
|
||||
"""Turn on the switch."""
|
||||
await self.adguard.parental.enable()
|
||||
|
||||
async def _adguard_update(self) -> None:
|
||||
"""Update AdGuard Home entity."""
|
||||
self._state = await self.adguard.parental.enabled()
|
||||
|
||||
|
||||
class AdGuardHomeSafeSearchSwitch(AdGuardHomeSwitch):
|
||||
"""Defines a AdGuard Home safe search switch."""
|
||||
|
||||
def __init__(self, adguard) -> None:
|
||||
"""Initialize AdGuard Home switch."""
|
||||
super().__init__(
|
||||
adguard, "AdGuard Safe Search", 'mdi:shield-check', 'safesearch'
|
||||
)
|
||||
|
||||
async def _adguard_turn_off(self) -> None:
|
||||
"""Turn off the switch."""
|
||||
await self.adguard.safesearch.disable()
|
||||
|
||||
async def _adguard_turn_on(self) -> None:
|
||||
"""Turn on the switch."""
|
||||
await self.adguard.safesearch.enable()
|
||||
|
||||
async def _adguard_update(self) -> None:
|
||||
"""Update AdGuard Home entity."""
|
||||
self._state = await self.adguard.safesearch.enabled()
|
||||
|
||||
|
||||
class AdGuardHomeSafeBrowsingSwitch(AdGuardHomeSwitch):
|
||||
"""Defines a AdGuard Home safe search switch."""
|
||||
|
||||
def __init__(self, adguard) -> None:
|
||||
"""Initialize AdGuard Home switch."""
|
||||
super().__init__(
|
||||
adguard,
|
||||
"AdGuard Safe Browsing",
|
||||
'mdi:shield-check',
|
||||
'safebrowsing',
|
||||
)
|
||||
|
||||
async def _adguard_turn_off(self) -> None:
|
||||
"""Turn off the switch."""
|
||||
await self.adguard.safebrowsing.disable()
|
||||
|
||||
async def _adguard_turn_on(self) -> None:
|
||||
"""Turn on the switch."""
|
||||
await self.adguard.safebrowsing.enable()
|
||||
|
||||
async def _adguard_update(self) -> None:
|
||||
"""Update AdGuard Home entity."""
|
||||
self._state = await self.adguard.safebrowsing.enabled()
|
||||
|
||||
|
||||
class AdGuardHomeFilteringSwitch(AdGuardHomeSwitch):
|
||||
"""Defines a AdGuard Home filtering switch."""
|
||||
|
||||
def __init__(self, adguard) -> None:
|
||||
"""Initialize AdGuard Home switch."""
|
||||
super().__init__(
|
||||
adguard, "AdGuard Filtering", 'mdi:shield-check', 'filtering'
|
||||
)
|
||||
|
||||
async def _adguard_turn_off(self) -> None:
|
||||
"""Turn off the switch."""
|
||||
await self.adguard.filtering.disable()
|
||||
|
||||
async def _adguard_turn_on(self) -> None:
|
||||
"""Turn on the switch."""
|
||||
await self.adguard.filtering.enable()
|
||||
|
||||
async def _adguard_update(self) -> None:
|
||||
"""Update AdGuard Home entity."""
|
||||
self._state = await self.adguard.filtering.enabled()
|
||||
|
||||
|
||||
class AdGuardHomeQueryLogSwitch(AdGuardHomeSwitch):
|
||||
"""Defines a AdGuard Home query log switch."""
|
||||
|
||||
def __init__(self, adguard) -> None:
|
||||
"""Initialize AdGuard Home switch."""
|
||||
super().__init__(
|
||||
adguard, "AdGuard Query Log", 'mdi:shield-check', 'querylog'
|
||||
)
|
||||
|
||||
async def _adguard_turn_off(self) -> None:
|
||||
"""Turn off the switch."""
|
||||
await self.adguard.querylog.disable()
|
||||
|
||||
async def _adguard_turn_on(self) -> None:
|
||||
"""Turn on the switch."""
|
||||
await self.adguard.querylog.enable()
|
||||
|
||||
async def _adguard_update(self) -> None:
|
||||
"""Update AdGuard Home entity."""
|
||||
self._state = await self.adguard.querylog.enabled()
|
@ -177,6 +177,11 @@ class AfterShipSensor(Entity):
|
||||
if track['title'] is None
|
||||
else track['title']
|
||||
)
|
||||
last_checkpoint = (
|
||||
"Shipment pending"
|
||||
if track['tag'] == "Pending"
|
||||
else track['checkpoints'][-1]
|
||||
)
|
||||
status_counts[status] = status_counts.get(status, 0) + 1
|
||||
trackings.append({
|
||||
'name': name,
|
||||
@ -187,7 +192,7 @@ class AfterShipSensor(Entity):
|
||||
'last_update': track['updated_at'],
|
||||
'expected_delivery': track['expected_delivery'],
|
||||
'status': track['tag'],
|
||||
'last_checkpoint': track['checkpoints'][-1]
|
||||
'last_checkpoint': last_checkpoint
|
||||
})
|
||||
|
||||
if status not in status_to_ignore:
|
||||
|
@ -19,6 +19,7 @@ SCAN_INTERVAL = timedelta(seconds=30)
|
||||
ATTR_CHANGED_BY = 'changed_by'
|
||||
FORMAT_TEXT = 'text'
|
||||
FORMAT_NUMBER = 'number'
|
||||
ATTR_CODE_ARM_REQUIRED = 'code_arm_required'
|
||||
|
||||
ENTITY_ID_FORMAT = DOMAIN + '.{}'
|
||||
|
||||
@ -87,6 +88,11 @@ class AlarmControlPanel(Entity):
|
||||
"""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()
|
||||
@ -159,6 +165,7 @@ class AlarmControlPanel(Entity):
|
||||
"""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
|
||||
|
@ -5,12 +5,13 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers import entityfilter
|
||||
from homeassistant.const import CONF_NAME
|
||||
|
||||
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_ENTITY_CONFIG, CONF_DESCRIPTION, CONF_DISPLAY_CATEGORIES)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@ -18,9 +19,9 @@ 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,
|
||||
vol.Optional(CONF_DESCRIPTION): cv.string,
|
||||
vol.Optional(CONF_DISPLAY_CATEGORIES): cv.string,
|
||||
vol.Optional(CONF_NAME): cv.string,
|
||||
})
|
||||
|
||||
SMART_HOME_SCHEMA = vol.Schema({
|
||||
@ -65,6 +66,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
|
||||
|
@ -9,7 +9,6 @@ import async_timeout
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers import aiohttp_client
|
||||
from homeassistant.util import dt
|
||||
from .const import DEFAULT_TIMEOUT
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@ -97,7 +96,7 @@ class Auth:
|
||||
|
||||
try:
|
||||
session = aiohttp_client.async_get_clientsession(self.hass)
|
||||
with async_timeout.timeout(DEFAULT_TIMEOUT):
|
||||
with async_timeout.timeout(10):
|
||||
response = await session.post(LWA_TOKEN_URI,
|
||||
headers=LWA_HEADERS,
|
||||
data=lwa_params,
|
||||
|
597
homeassistant/components/alexa/capabilities.py
Normal file
597
homeassistant/components/alexa/capabilities.py
Normal file
@ -0,0 +1,597 @@
|
||||
"""Alexa capabilities."""
|
||||
from datetime import datetime
|
||||
import logging
|
||||
|
||||
from homeassistant.const import (
|
||||
ATTR_SUPPORTED_FEATURES,
|
||||
ATTR_TEMPERATURE,
|
||||
ATTR_UNIT_OF_MEASUREMENT,
|
||||
STATE_LOCKED,
|
||||
STATE_OFF,
|
||||
STATE_ON,
|
||||
STATE_UNAVAILABLE,
|
||||
STATE_UNLOCKED,
|
||||
)
|
||||
import homeassistant.components.climate.const as climate
|
||||
from homeassistant.components import (
|
||||
light,
|
||||
fan,
|
||||
cover,
|
||||
)
|
||||
import homeassistant.util.color as color_util
|
||||
|
||||
from .const import (
|
||||
API_TEMP_UNITS,
|
||||
API_THERMOSTAT_MODES,
|
||||
DATE_FORMAT,
|
||||
PERCENTAGE_FAN_MAP,
|
||||
)
|
||||
from .errors import UnsupportedProperty
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AlexaCapibility:
|
||||
"""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):
|
||||
"""Initialize an Alexa capibility."""
|
||||
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(AlexaCapibility):
|
||||
"""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):
|
||||
"""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 False
|
||||
|
||||
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(AlexaCapibility):
|
||||
"""Implements Alexa.PowerController.
|
||||
|
||||
https://developer.amazon.com/docs/device-apis/alexa-powercontroller.html
|
||||
"""
|
||||
|
||||
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.state == STATE_OFF:
|
||||
return 'OFF'
|
||||
return 'ON'
|
||||
|
||||
|
||||
class AlexaLockController(AlexaCapibility):
|
||||
"""Implements Alexa.LockController.
|
||||
|
||||
https://developer.amazon.com/docs/device-apis/alexa-lockcontroller.html
|
||||
"""
|
||||
|
||||
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(AlexaCapibility):
|
||||
"""Implements Alexa.SceneController.
|
||||
|
||||
https://developer.amazon.com/docs/device-apis/alexa-scenecontroller.html
|
||||
"""
|
||||
|
||||
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(AlexaCapibility):
|
||||
"""Implements Alexa.BrightnessController.
|
||||
|
||||
https://developer.amazon.com/docs/device-apis/alexa-brightnesscontroller.html
|
||||
"""
|
||||
|
||||
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(AlexaCapibility):
|
||||
"""Implements Alexa.ColorController.
|
||||
|
||||
https://developer.amazon.com/docs/device-apis/alexa-colorcontroller.html
|
||||
"""
|
||||
|
||||
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_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(AlexaCapibility):
|
||||
"""Implements Alexa.ColorTemperatureController.
|
||||
|
||||
https://developer.amazon.com/docs/device-apis/alexa-colortemperaturecontroller.html
|
||||
"""
|
||||
|
||||
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_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 0
|
||||
|
||||
|
||||
class AlexaPercentageController(AlexaCapibility):
|
||||
"""Implements Alexa.PercentageController.
|
||||
|
||||
https://developer.amazon.com/docs/device-apis/alexa-percentagecontroller.html
|
||||
"""
|
||||
|
||||
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_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(AlexaCapibility):
|
||||
"""Implements Alexa.Speaker.
|
||||
|
||||
https://developer.amazon.com/docs/device-apis/alexa-speaker.html
|
||||
"""
|
||||
|
||||
def name(self):
|
||||
"""Return the Alexa API name of this interface."""
|
||||
return 'Alexa.Speaker'
|
||||
|
||||
|
||||
class AlexaStepSpeaker(AlexaCapibility):
|
||||
"""Implements Alexa.StepSpeaker.
|
||||
|
||||
https://developer.amazon.com/docs/device-apis/alexa-stepspeaker.html
|
||||
"""
|
||||
|
||||
def name(self):
|
||||
"""Return the Alexa API name of this interface."""
|
||||
return 'Alexa.StepSpeaker'
|
||||
|
||||
|
||||
class AlexaPlaybackController(AlexaCapibility):
|
||||
"""Implements Alexa.PlaybackController.
|
||||
|
||||
https://developer.amazon.com/docs/device-apis/alexa-playbackcontroller.html
|
||||
"""
|
||||
|
||||
def name(self):
|
||||
"""Return the Alexa API name of this interface."""
|
||||
return 'Alexa.PlaybackController'
|
||||
|
||||
|
||||
class AlexaInputController(AlexaCapibility):
|
||||
"""Implements Alexa.InputController.
|
||||
|
||||
https://developer.amazon.com/docs/device-apis/alexa-inputcontroller.html
|
||||
"""
|
||||
|
||||
def name(self):
|
||||
"""Return the Alexa API name of this interface."""
|
||||
return 'Alexa.InputController'
|
||||
|
||||
|
||||
class AlexaTemperatureSensor(AlexaCapibility):
|
||||
"""Implements Alexa.TemperatureSensor.
|
||||
|
||||
https://developer.amazon.com/docs/device-apis/alexa-temperaturesensor.html
|
||||
"""
|
||||
|
||||
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)
|
||||
return {
|
||||
'value': float(temp),
|
||||
'scale': API_TEMP_UNITS[unit],
|
||||
}
|
||||
|
||||
|
||||
class AlexaContactSensor(AlexaCapibility):
|
||||
"""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):
|
||||
"""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(AlexaCapibility):
|
||||
"""Implements Alexa.MotionSensor.
|
||||
|
||||
https://developer.amazon.com/docs/device-apis/alexa-motionsensor.html
|
||||
"""
|
||||
|
||||
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(AlexaCapibility):
|
||||
"""Implements Alexa.ThermostatController.
|
||||
|
||||
https://developer.amazon.com/docs/device-apis/alexa-thermostatcontroller.html
|
||||
"""
|
||||
|
||||
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 = []
|
||||
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 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 == '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],
|
||||
}
|
69
homeassistant/components/alexa/config.py
Normal file
69
homeassistant/components/alexa/config.py
Normal file
@ -0,0 +1,69 @@
|
||||
"""Config helpers for Alexa."""
|
||||
from .state_report import async_enable_proactive_mode
|
||||
|
||||
|
||||
class AbstractConfig:
|
||||
"""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
|
||||
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: # pylint: disable=broad-except
|
||||
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
|
||||
|
||||
def should_expose(self, entity_id):
|
||||
"""If an entity should be exposed."""
|
||||
# pylint: disable=no-self-use
|
||||
return False
|
||||
|
||||
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
|
@ -1,4 +1,15 @@
|
||||
"""Constants for the Alexa integration."""
|
||||
from collections import OrderedDict
|
||||
|
||||
from homeassistant.const import (
|
||||
STATE_OFF,
|
||||
TEMP_CELSIUS,
|
||||
TEMP_FAHRENHEIT,
|
||||
)
|
||||
from homeassistant.components.climate import const as climate
|
||||
from homeassistant.components import fan
|
||||
|
||||
|
||||
DOMAIN = 'alexa'
|
||||
|
||||
# Flash briefing constants
|
||||
@ -25,4 +36,73 @@ SYN_RESOLUTION_MATCH = 'ER_SUCCESS_MATCH'
|
||||
|
||||
DATE_FORMAT = '%Y-%m-%dT%H:%M:%S.0Z'
|
||||
|
||||
DEFAULT_TIMEOUT = 30
|
||||
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'
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
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'
|
||||
|
459
homeassistant/components/alexa/entities.py
Normal file
459
homeassistant/components/alexa/entities.py
Normal file
@ -0,0 +1,459 @@
|
||||
"""Alexa entity adapters."""
|
||||
from typing import List
|
||||
|
||||
from homeassistant.core import callback
|
||||
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.util.decorator import Registry
|
||||
from homeassistant.components.climate import const as climate
|
||||
from homeassistant.components import (
|
||||
alert, automation, binary_sensor, cover, fan, group,
|
||||
input_boolean, light, lock, media_player, scene, script, sensor, switch)
|
||||
|
||||
from .const import CONF_DESCRIPTION, CONF_DISPLAY_CATEGORIES
|
||||
from .capabilities import (
|
||||
AlexaBrightnessController,
|
||||
AlexaColorController,
|
||||
AlexaColorTemperatureController,
|
||||
AlexaContactSensor,
|
||||
AlexaEndpointHealth,
|
||||
AlexaInputController,
|
||||
AlexaLockController,
|
||||
AlexaMotionSensor,
|
||||
AlexaPercentageController,
|
||||
AlexaPlaybackController,
|
||||
AlexaPowerController,
|
||||
AlexaSceneController,
|
||||
AlexaSpeaker,
|
||||
AlexaStepSpeaker,
|
||||
AlexaTemperatureSensor,
|
||||
AlexaThermostatController,
|
||||
)
|
||||
|
||||
ENTITY_ADAPTERS = Registry()
|
||||
|
||||
|
||||
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"
|
||||
|
||||
|
||||
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)
|
||||
|
||||
def description(self):
|
||||
"""Return the Alexa API description."""
|
||||
return self.entity_conf.get(CONF_DESCRIPTION, self.entity.entity_id)
|
||||
|
||||
def alexa_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
|
||||
|
||||
def serialize_discovery(self):
|
||||
"""Serialize the entity for discovery."""
|
||||
return {
|
||||
'displayCategories': self.display_categories(),
|
||||
'cookie': {},
|
||||
'endpointId': self.alexa_id(),
|
||||
'friendlyName': self.friendly_name(),
|
||||
'description': self.description(),
|
||||
'manufacturerName': 'Home Assistant',
|
||||
'capabilities': [
|
||||
i.serialize_discovery() for i in self.interfaces()
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@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)]
|
||||
|
||||
|
||||
@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."""
|
||||
return [DisplayCategory.SWITCH]
|
||||
|
||||
def interfaces(self):
|
||||
"""Yield the supported interfaces."""
|
||||
return [AlexaPowerController(self.entity),
|
||||
AlexaEndpointHealth(self.hass, self.entity)]
|
||||
|
||||
|
||||
@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."""
|
||||
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):
|
||||
"""Class to represent Cover capabilities."""
|
||||
|
||||
def default_display_categories(self):
|
||||
"""Return the display categories for this entity."""
|
||||
return [DisplayCategory.DOOR]
|
||||
|
||||
def interfaces(self):
|
||||
"""Yield the supported interfaces."""
|
||||
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):
|
||||
"""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)
|
||||
|
||||
|
||||
@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.OTHER]
|
||||
|
||||
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 AlexaEndpointHealth(self.hass, self.entity)
|
||||
|
||||
|
||||
@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)]
|
||||
|
||||
|
||||
@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."""
|
||||
return [DisplayCategory.TV]
|
||||
|
||||
def interfaces(self):
|
||||
"""Yield the supported interfaces."""
|
||||
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):
|
||||
"""Class to represent Scene capabilities."""
|
||||
|
||||
def description(self):
|
||||
"""Return the description of the entity."""
|
||||
# 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 the display categories for this entity."""
|
||||
return [DisplayCategory.SCENE_TRIGGER]
|
||||
|
||||
def interfaces(self):
|
||||
"""Yield the supported interfaces."""
|
||||
return [AlexaSceneController(self.entity,
|
||||
supports_deactivation=False)]
|
||||
|
||||
|
||||
@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)]
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
|
||||
@ENTITY_ADAPTERS.register(binary_sensor.DOMAIN)
|
||||
class BinarySensorCapabilities(AlexaEntity):
|
||||
"""Class to represent BinarySensor capabilities."""
|
||||
|
||||
TYPE_CONTACT = 'contact'
|
||||
TYPE_MOTION = 'motion'
|
||||
|
||||
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]
|
||||
|
||||
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)
|
||||
|
||||
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
|
91
homeassistant/components/alexa/errors.py
Normal file
91
homeassistant/components/alexa/errors.py
Normal file
@ -0,0 +1,91 @@
|
||||
"""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 = 'The endpoint {} does not exist'.format(endpoint_id)
|
||||
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 = 'The requested temperature {} is out of range'.format(temp)
|
||||
|
||||
AlexaError.__init__(self, msg, payload)
|
||||
|
||||
|
||||
class AlexaBridgeUnreachableError(AlexaError):
|
||||
"""Class to represent BridgeUnreachable errors."""
|
||||
|
||||
namespace = 'Alexa'
|
||||
error_type = 'BRIDGE_UNREACHABLE'
|
719
homeassistant/components/alexa/handlers.py
Normal file
719
homeassistant/components/alexa/handlers.py
Normal file
@ -0,0 +1,719 @@
|
||||
"""Alexa message handlers."""
|
||||
from datetime import datetime
|
||||
import logging
|
||||
import math
|
||||
|
||||
from homeassistant import core as ha
|
||||
from homeassistant.util.decorator import Registry
|
||||
import homeassistant.util.color as color_util
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
ATTR_TEMPERATURE,
|
||||
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_MUTE,
|
||||
SERVICE_VOLUME_SET,
|
||||
SERVICE_VOLUME_UP,
|
||||
TEMP_CELSIUS,
|
||||
TEMP_FAHRENHEIT,
|
||||
)
|
||||
from homeassistant.components.climate import const as climate
|
||||
from homeassistant.components import cover, fan, group, light, media_player
|
||||
from homeassistant.util.temperature import convert as convert_temperature
|
||||
|
||||
from .const import (
|
||||
API_TEMP_UNITS,
|
||||
API_THERMOSTAT_MODES,
|
||||
Cause,
|
||||
)
|
||||
from .entities import async_get_entities
|
||||
from .state_report import async_enable_proactive_mode
|
||||
from .errors import (
|
||||
AlexaInvalidValueError,
|
||||
AlexaTempRangeError,
|
||||
AlexaUnsupportedThermostatModeError,
|
||||
)
|
||||
|
||||
_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
|
||||
|
||||
await hass.services.async_call(domain, service, {
|
||||
ATTR_ENTITY_ID: entity.entity_id
|
||||
}, blocking=False, context=context)
|
||||
|
||||
return directive.response()
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa.PowerController', 'TurnOff'))
|
||||
async def async_api_turn_off(hass, config, directive, context):
|
||||
"""Process a turn off request."""
|
||||
entity = directive.entity
|
||||
domain = entity.domain
|
||||
if entity.domain == group.DOMAIN:
|
||||
domain = ha.DOMAIN
|
||||
|
||||
service = SERVICE_TURN_OFF
|
||||
if entity.domain == cover.DOMAIN:
|
||||
service = cover.SERVICE_CLOSE_COVER
|
||||
|
||||
await hass.services.async_call(domain, service, {
|
||||
ATTR_ENTITY_ID: entity.entity_id
|
||||
}, blocking=False, context=context)
|
||||
|
||||
return directive.response()
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa.BrightnessController', 'SetBrightness'))
|
||||
async def async_api_set_brightness(hass, config, directive, context):
|
||||
"""Process a set brightness request."""
|
||||
entity = directive.entity
|
||||
brightness = int(directive.payload['brightness'])
|
||||
|
||||
await hass.services.async_call(entity.domain, SERVICE_TURN_ON, {
|
||||
ATTR_ENTITY_ID: entity.entity_id,
|
||||
light.ATTR_BRIGHTNESS_PCT: brightness,
|
||||
}, blocking=False, context=context)
|
||||
|
||||
return directive.response()
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa.BrightnessController', 'AdjustBrightness'))
|
||||
async def async_api_adjust_brightness(hass, config, directive, context):
|
||||
"""Process an adjust brightness request."""
|
||||
entity = directive.entity
|
||||
brightness_delta = int(directive.payload['brightnessDelta'])
|
||||
|
||||
# read current state
|
||||
try:
|
||||
current = math.floor(
|
||||
int(entity.attributes.get(light.ATTR_BRIGHTNESS)) / 255 * 100)
|
||||
except ZeroDivisionError:
|
||||
current = 0
|
||||
|
||||
# set brightness
|
||||
brightness = max(0, brightness_delta + current)
|
||||
await hass.services.async_call(entity.domain, SERVICE_TURN_ON, {
|
||||
ATTR_ENTITY_ID: entity.entity_id,
|
||||
light.ATTR_BRIGHTNESS_PCT: brightness,
|
||||
}, blocking=False, context=context)
|
||||
|
||||
return directive.response()
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa.ColorController', 'SetColor'))
|
||||
async def async_api_set_color(hass, config, directive, context):
|
||||
"""Process a set color request."""
|
||||
entity = directive.entity
|
||||
rgb = color_util.color_hsb_to_RGB(
|
||||
float(directive.payload['color']['hue']),
|
||||
float(directive.payload['color']['saturation']),
|
||||
float(directive.payload['color']['brightness'])
|
||||
)
|
||||
|
||||
await hass.services.async_call(entity.domain, SERVICE_TURN_ON, {
|
||||
ATTR_ENTITY_ID: entity.entity_id,
|
||||
light.ATTR_RGB_COLOR: rgb,
|
||||
}, blocking=False, context=context)
|
||||
|
||||
return directive.response()
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa.ColorTemperatureController', 'SetColorTemperature'))
|
||||
async def async_api_set_color_temperature(hass, config, directive, context):
|
||||
"""Process a set color temperature request."""
|
||||
entity = directive.entity
|
||||
kelvin = int(directive.payload['colorTemperatureInKelvin'])
|
||||
|
||||
await hass.services.async_call(entity.domain, SERVICE_TURN_ON, {
|
||||
ATTR_ENTITY_ID: entity.entity_id,
|
||||
light.ATTR_KELVIN: kelvin,
|
||||
}, blocking=False, context=context)
|
||||
|
||||
return directive.response()
|
||||
|
||||
|
||||
@HANDLERS.register(
|
||||
('Alexa.ColorTemperatureController', 'DecreaseColorTemperature'))
|
||||
async def async_api_decrease_color_temp(hass, config, directive, context):
|
||||
"""Process a decrease color temperature request."""
|
||||
entity = directive.entity
|
||||
current = int(entity.attributes.get(light.ATTR_COLOR_TEMP))
|
||||
max_mireds = int(entity.attributes.get(light.ATTR_MAX_MIREDS))
|
||||
|
||||
value = min(max_mireds, current + 50)
|
||||
await hass.services.async_call(entity.domain, SERVICE_TURN_ON, {
|
||||
ATTR_ENTITY_ID: entity.entity_id,
|
||||
light.ATTR_COLOR_TEMP: value,
|
||||
}, blocking=False, context=context)
|
||||
|
||||
return directive.response()
|
||||
|
||||
|
||||
@HANDLERS.register(
|
||||
('Alexa.ColorTemperatureController', 'IncreaseColorTemperature'))
|
||||
async def async_api_increase_color_temp(hass, config, directive, context):
|
||||
"""Process an increase color temperature request."""
|
||||
entity = directive.entity
|
||||
current = int(entity.attributes.get(light.ATTR_COLOR_TEMP))
|
||||
min_mireds = int(entity.attributes.get(light.ATTR_MIN_MIREDS))
|
||||
|
||||
value = max(min_mireds, current - 50)
|
||||
await hass.services.async_call(entity.domain, SERVICE_TURN_ON, {
|
||||
ATTR_ENTITY_ID: entity.entity_id,
|
||||
light.ATTR_COLOR_TEMP: value,
|
||||
}, blocking=False, context=context)
|
||||
|
||||
return directive.response()
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa.SceneController', 'Activate'))
|
||||
async def async_api_activate(hass, config, directive, context):
|
||||
"""Process an activate request."""
|
||||
entity = directive.entity
|
||||
domain = entity.domain
|
||||
|
||||
await hass.services.async_call(domain, SERVICE_TURN_ON, {
|
||||
ATTR_ENTITY_ID: entity.entity_id
|
||||
}, blocking=False, context=context)
|
||||
|
||||
payload = {
|
||||
'cause': {'type': Cause.VOICE_INTERACTION},
|
||||
'timestamp': '%sZ' % (datetime.utcnow().isoformat(),)
|
||||
}
|
||||
|
||||
return directive.response(
|
||||
name='ActivationStarted',
|
||||
namespace='Alexa.SceneController',
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa.SceneController', 'Deactivate'))
|
||||
async def async_api_deactivate(hass, config, directive, context):
|
||||
"""Process a deactivate request."""
|
||||
entity = directive.entity
|
||||
domain = entity.domain
|
||||
|
||||
await hass.services.async_call(domain, SERVICE_TURN_OFF, {
|
||||
ATTR_ENTITY_ID: entity.entity_id
|
||||
}, blocking=False, context=context)
|
||||
|
||||
payload = {
|
||||
'cause': {'type': Cause.VOICE_INTERACTION},
|
||||
'timestamp': '%sZ' % (datetime.utcnow().isoformat(),)
|
||||
}
|
||||
|
||||
return directive.response(
|
||||
name='DeactivationStarted',
|
||||
namespace='Alexa.SceneController',
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa.PercentageController', 'SetPercentage'))
|
||||
async def async_api_set_percentage(hass, config, directive, context):
|
||||
"""Process a set percentage request."""
|
||||
entity = directive.entity
|
||||
percentage = int(directive.payload['percentage'])
|
||||
service = None
|
||||
data = {ATTR_ENTITY_ID: entity.entity_id}
|
||||
|
||||
if entity.domain == fan.DOMAIN:
|
||||
service = fan.SERVICE_SET_SPEED
|
||||
speed = "off"
|
||||
|
||||
if percentage <= 33:
|
||||
speed = "low"
|
||||
elif percentage <= 66:
|
||||
speed = "medium"
|
||||
elif percentage <= 100:
|
||||
speed = "high"
|
||||
data[fan.ATTR_SPEED] = speed
|
||||
|
||||
elif entity.domain == cover.DOMAIN:
|
||||
service = SERVICE_SET_COVER_POSITION
|
||||
data[cover.ATTR_POSITION] = percentage
|
||||
|
||||
await hass.services.async_call(
|
||||
entity.domain, service, data, blocking=False, context=context)
|
||||
|
||||
return directive.response()
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa.PercentageController', 'AdjustPercentage'))
|
||||
async def async_api_adjust_percentage(hass, config, directive, context):
|
||||
"""Process an adjust percentage request."""
|
||||
entity = directive.entity
|
||||
percentage_delta = int(directive.payload['percentageDelta'])
|
||||
service = None
|
||||
data = {ATTR_ENTITY_ID: entity.entity_id}
|
||||
|
||||
if entity.domain == fan.DOMAIN:
|
||||
service = fan.SERVICE_SET_SPEED
|
||||
speed = entity.attributes.get(fan.ATTR_SPEED)
|
||||
|
||||
if speed == "off":
|
||||
current = 0
|
||||
elif speed == "low":
|
||||
current = 33
|
||||
elif speed == "medium":
|
||||
current = 66
|
||||
elif speed == "high":
|
||||
current = 100
|
||||
|
||||
# set percentage
|
||||
percentage = max(0, percentage_delta + current)
|
||||
speed = "off"
|
||||
|
||||
if percentage <= 33:
|
||||
speed = "low"
|
||||
elif percentage <= 66:
|
||||
speed = "medium"
|
||||
elif percentage <= 100:
|
||||
speed = "high"
|
||||
|
||||
data[fan.ATTR_SPEED] = speed
|
||||
|
||||
elif entity.domain == cover.DOMAIN:
|
||||
service = SERVICE_SET_COVER_POSITION
|
||||
|
||||
current = entity.attributes.get(cover.ATTR_POSITION)
|
||||
|
||||
data[cover.ATTR_POSITION] = max(0, percentage_delta + current)
|
||||
|
||||
await hass.services.async_call(
|
||||
entity.domain, service, data, blocking=False, context=context)
|
||||
|
||||
return directive.response()
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa.LockController', 'Lock'))
|
||||
async def async_api_lock(hass, config, directive, context):
|
||||
"""Process a lock request."""
|
||||
entity = directive.entity
|
||||
await hass.services.async_call(entity.domain, SERVICE_LOCK, {
|
||||
ATTR_ENTITY_ID: entity.entity_id
|
||||
}, blocking=False, context=context)
|
||||
|
||||
response = directive.response()
|
||||
response.add_context_property({
|
||||
'name': 'lockState',
|
||||
'namespace': 'Alexa.LockController',
|
||||
'value': 'LOCKED'
|
||||
})
|
||||
return response
|
||||
|
||||
|
||||
# Not supported by Alexa yet
|
||||
@HANDLERS.register(('Alexa.LockController', 'Unlock'))
|
||||
async def async_api_unlock(hass, config, directive, context):
|
||||
"""Process an unlock request."""
|
||||
entity = directive.entity
|
||||
await hass.services.async_call(entity.domain, SERVICE_UNLOCK, {
|
||||
ATTR_ENTITY_ID: entity.entity_id
|
||||
}, blocking=False, context=context)
|
||||
|
||||
return directive.response()
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa.Speaker', 'SetVolume'))
|
||||
async def async_api_set_volume(hass, config, directive, context):
|
||||
"""Process a set volume request."""
|
||||
volume = round(float(directive.payload['volume'] / 100), 2)
|
||||
entity = directive.entity
|
||||
|
||||
data = {
|
||||
ATTR_ENTITY_ID: entity.entity_id,
|
||||
media_player.const.ATTR_MEDIA_VOLUME_LEVEL: volume,
|
||||
}
|
||||
|
||||
await hass.services.async_call(
|
||||
entity.domain, SERVICE_VOLUME_SET,
|
||||
data, blocking=False, context=context)
|
||||
|
||||
return directive.response()
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa.InputController', 'SelectInput'))
|
||||
async def async_api_select_input(hass, config, directive, context):
|
||||
"""Process a set input request."""
|
||||
media_input = directive.payload['input']
|
||||
entity = directive.entity
|
||||
|
||||
# attempt to map the ALL UPPERCASE payload name to a source
|
||||
source_list = entity.attributes[
|
||||
media_player.const.ATTR_INPUT_SOURCE_LIST] or []
|
||||
for source in source_list:
|
||||
# response will always be space separated, so format the source in the
|
||||
# most likely way to find a match
|
||||
formatted_source = source.lower().replace('-', ' ').replace('_', ' ')
|
||||
if formatted_source in media_input.lower():
|
||||
media_input = source
|
||||
break
|
||||
else:
|
||||
msg = 'failed to map input {} to a media source on {}'.format(
|
||||
media_input, entity.entity_id)
|
||||
raise AlexaInvalidValueError(msg)
|
||||
|
||||
data = {
|
||||
ATTR_ENTITY_ID: entity.entity_id,
|
||||
media_player.const.ATTR_INPUT_SOURCE: media_input,
|
||||
}
|
||||
|
||||
await hass.services.async_call(
|
||||
entity.domain, media_player.SERVICE_SELECT_SOURCE,
|
||||
data, blocking=False, context=context)
|
||||
|
||||
return directive.response()
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa.Speaker', 'AdjustVolume'))
|
||||
async def async_api_adjust_volume(hass, config, directive, context):
|
||||
"""Process an adjust volume request."""
|
||||
volume_delta = int(directive.payload['volume'])
|
||||
|
||||
entity = directive.entity
|
||||
current_level = entity.attributes.get(
|
||||
media_player.const.ATTR_MEDIA_VOLUME_LEVEL)
|
||||
|
||||
# read current state
|
||||
try:
|
||||
current = math.floor(int(current_level * 100))
|
||||
except ZeroDivisionError:
|
||||
current = 0
|
||||
|
||||
volume = float(max(0, volume_delta + current) / 100)
|
||||
|
||||
data = {
|
||||
ATTR_ENTITY_ID: entity.entity_id,
|
||||
media_player.const.ATTR_MEDIA_VOLUME_LEVEL: volume,
|
||||
}
|
||||
|
||||
await hass.services.async_call(
|
||||
entity.domain, SERVICE_VOLUME_SET,
|
||||
data, blocking=False, context=context)
|
||||
|
||||
return directive.response()
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa.StepSpeaker', 'AdjustVolume'))
|
||||
async def async_api_adjust_volume_step(hass, config, directive, context):
|
||||
"""Process an adjust volume step request."""
|
||||
# media_player volume up/down service does not support specifying steps
|
||||
# each component handles it differently e.g. via config.
|
||||
# For now we use the volumeSteps returned to figure out if we
|
||||
# should step up/down
|
||||
volume_step = directive.payload['volumeSteps']
|
||||
entity = directive.entity
|
||||
|
||||
data = {
|
||||
ATTR_ENTITY_ID: entity.entity_id,
|
||||
}
|
||||
|
||||
if volume_step > 0:
|
||||
await hass.services.async_call(
|
||||
entity.domain, SERVICE_VOLUME_UP,
|
||||
data, blocking=False, context=context)
|
||||
elif volume_step < 0:
|
||||
await hass.services.async_call(
|
||||
entity.domain, SERVICE_VOLUME_DOWN,
|
||||
data, blocking=False, context=context)
|
||||
|
||||
return directive.response()
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa.StepSpeaker', 'SetMute'))
|
||||
@HANDLERS.register(('Alexa.Speaker', 'SetMute'))
|
||||
async def async_api_set_mute(hass, config, directive, context):
|
||||
"""Process a set mute request."""
|
||||
mute = bool(directive.payload['mute'])
|
||||
entity = directive.entity
|
||||
|
||||
data = {
|
||||
ATTR_ENTITY_ID: entity.entity_id,
|
||||
media_player.const.ATTR_MEDIA_VOLUME_MUTED: mute,
|
||||
}
|
||||
|
||||
await hass.services.async_call(
|
||||
entity.domain, SERVICE_VOLUME_MUTE,
|
||||
data, blocking=False, context=context)
|
||||
|
||||
return directive.response()
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa.PlaybackController', 'Play'))
|
||||
async def async_api_play(hass, config, directive, context):
|
||||
"""Process a play request."""
|
||||
entity = directive.entity
|
||||
data = {
|
||||
ATTR_ENTITY_ID: entity.entity_id
|
||||
}
|
||||
|
||||
await hass.services.async_call(
|
||||
entity.domain, SERVICE_MEDIA_PLAY,
|
||||
data, blocking=False, context=context)
|
||||
|
||||
return directive.response()
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa.PlaybackController', 'Pause'))
|
||||
async def async_api_pause(hass, config, directive, context):
|
||||
"""Process a pause request."""
|
||||
entity = directive.entity
|
||||
data = {
|
||||
ATTR_ENTITY_ID: entity.entity_id
|
||||
}
|
||||
|
||||
await hass.services.async_call(
|
||||
entity.domain, SERVICE_MEDIA_PAUSE,
|
||||
data, blocking=False, context=context)
|
||||
|
||||
return directive.response()
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa.PlaybackController', 'Stop'))
|
||||
async def async_api_stop(hass, config, directive, context):
|
||||
"""Process a stop request."""
|
||||
entity = directive.entity
|
||||
data = {
|
||||
ATTR_ENTITY_ID: entity.entity_id
|
||||
}
|
||||
|
||||
await hass.services.async_call(
|
||||
entity.domain, SERVICE_MEDIA_STOP,
|
||||
data, blocking=False, context=context)
|
||||
|
||||
return directive.response()
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa.PlaybackController', 'Next'))
|
||||
async def async_api_next(hass, config, directive, context):
|
||||
"""Process a next request."""
|
||||
entity = directive.entity
|
||||
data = {
|
||||
ATTR_ENTITY_ID: entity.entity_id
|
||||
}
|
||||
|
||||
await hass.services.async_call(
|
||||
entity.domain, SERVICE_MEDIA_NEXT_TRACK,
|
||||
data, blocking=False, context=context)
|
||||
|
||||
return directive.response()
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa.PlaybackController', 'Previous'))
|
||||
async def async_api_previous(hass, config, directive, context):
|
||||
"""Process a previous request."""
|
||||
entity = directive.entity
|
||||
data = {
|
||||
ATTR_ENTITY_ID: entity.entity_id
|
||||
}
|
||||
|
||||
await hass.services.async_call(
|
||||
entity.domain, SERVICE_MEDIA_PREVIOUS_TRACK,
|
||||
data, blocking=False, context=context)
|
||||
|
||||
return directive.response()
|
||||
|
||||
|
||||
def temperature_from_object(hass, temp_obj, interval=False):
|
||||
"""Get temperature from Temperature object in requested unit."""
|
||||
to_unit = hass.config.units.temperature_unit
|
||||
from_unit = TEMP_CELSIUS
|
||||
temp = float(temp_obj['value'])
|
||||
|
||||
if temp_obj['scale'] == 'FAHRENHEIT':
|
||||
from_unit = TEMP_FAHRENHEIT
|
||||
elif temp_obj['scale'] == 'KELVIN':
|
||||
# convert to Celsius if absolute temperature
|
||||
if not interval:
|
||||
temp -= 273.15
|
||||
|
||||
return convert_temperature(temp, from_unit, to_unit, interval)
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa.ThermostatController', 'SetTargetTemperature'))
|
||||
async def async_api_set_target_temp(hass, config, directive, context):
|
||||
"""Process a set target temperature request."""
|
||||
entity = directive.entity
|
||||
min_temp = entity.attributes.get(climate.ATTR_MIN_TEMP)
|
||||
max_temp = entity.attributes.get(climate.ATTR_MAX_TEMP)
|
||||
unit = hass.config.units.temperature_unit
|
||||
|
||||
data = {
|
||||
ATTR_ENTITY_ID: entity.entity_id
|
||||
}
|
||||
|
||||
payload = directive.payload
|
||||
response = directive.response()
|
||||
if 'targetSetpoint' in payload:
|
||||
temp = temperature_from_object(hass, payload['targetSetpoint'])
|
||||
if temp < min_temp or temp > max_temp:
|
||||
raise AlexaTempRangeError(hass, temp, min_temp, max_temp)
|
||||
data[ATTR_TEMPERATURE] = temp
|
||||
response.add_context_property({
|
||||
'name': 'targetSetpoint',
|
||||
'namespace': 'Alexa.ThermostatController',
|
||||
'value': {'value': temp, 'scale': API_TEMP_UNITS[unit]},
|
||||
})
|
||||
if 'lowerSetpoint' in payload:
|
||||
temp_low = temperature_from_object(hass, payload['lowerSetpoint'])
|
||||
if temp_low < min_temp or temp_low > max_temp:
|
||||
raise AlexaTempRangeError(hass, temp_low, min_temp, max_temp)
|
||||
data[climate.ATTR_TARGET_TEMP_LOW] = temp_low
|
||||
response.add_context_property({
|
||||
'name': 'lowerSetpoint',
|
||||
'namespace': 'Alexa.ThermostatController',
|
||||
'value': {'value': temp_low, 'scale': API_TEMP_UNITS[unit]},
|
||||
})
|
||||
if 'upperSetpoint' in payload:
|
||||
temp_high = temperature_from_object(hass, payload['upperSetpoint'])
|
||||
if temp_high < min_temp or temp_high > max_temp:
|
||||
raise AlexaTempRangeError(hass, temp_high, min_temp, max_temp)
|
||||
data[climate.ATTR_TARGET_TEMP_HIGH] = temp_high
|
||||
response.add_context_property({
|
||||
'name': 'upperSetpoint',
|
||||
'namespace': 'Alexa.ThermostatController',
|
||||
'value': {'value': temp_high, 'scale': API_TEMP_UNITS[unit]},
|
||||
})
|
||||
|
||||
await hass.services.async_call(
|
||||
entity.domain, climate.SERVICE_SET_TEMPERATURE, data, blocking=False,
|
||||
context=context)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa.ThermostatController', 'AdjustTargetTemperature'))
|
||||
async def async_api_adjust_target_temp(hass, config, directive, context):
|
||||
"""Process an adjust target temperature request."""
|
||||
entity = directive.entity
|
||||
min_temp = entity.attributes.get(climate.ATTR_MIN_TEMP)
|
||||
max_temp = entity.attributes.get(climate.ATTR_MAX_TEMP)
|
||||
unit = hass.config.units.temperature_unit
|
||||
|
||||
temp_delta = temperature_from_object(
|
||||
hass, directive.payload['targetSetpointDelta'], interval=True)
|
||||
target_temp = float(entity.attributes.get(ATTR_TEMPERATURE)) + temp_delta
|
||||
|
||||
if target_temp < min_temp or target_temp > max_temp:
|
||||
raise AlexaTempRangeError(hass, target_temp, min_temp, max_temp)
|
||||
|
||||
data = {
|
||||
ATTR_ENTITY_ID: entity.entity_id,
|
||||
ATTR_TEMPERATURE: target_temp,
|
||||
}
|
||||
|
||||
response = directive.response()
|
||||
await hass.services.async_call(
|
||||
entity.domain, climate.SERVICE_SET_TEMPERATURE, data, blocking=False,
|
||||
context=context)
|
||||
response.add_context_property({
|
||||
'name': 'targetSetpoint',
|
||||
'namespace': 'Alexa.ThermostatController',
|
||||
'value': {'value': target_temp, 'scale': API_TEMP_UNITS[unit]},
|
||||
})
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa.ThermostatController', 'SetThermostatMode'))
|
||||
async def async_api_set_thermostat_mode(hass, config, directive, context):
|
||||
"""Process a set thermostat mode request."""
|
||||
entity = directive.entity
|
||||
mode = directive.payload['thermostatMode']
|
||||
mode = mode if isinstance(mode, str) else mode['value']
|
||||
|
||||
operation_list = entity.attributes.get(climate.ATTR_OPERATION_LIST)
|
||||
ha_mode = next(
|
||||
(k for k, v in API_THERMOSTAT_MODES.items() if v == mode),
|
||||
None
|
||||
)
|
||||
if ha_mode not in operation_list:
|
||||
msg = 'The requested thermostat mode {} is not supported'.format(mode)
|
||||
raise AlexaUnsupportedThermostatModeError(msg)
|
||||
|
||||
data = {
|
||||
ATTR_ENTITY_ID: entity.entity_id,
|
||||
climate.ATTR_OPERATION_MODE: ha_mode,
|
||||
}
|
||||
|
||||
response = directive.response()
|
||||
await hass.services.async_call(
|
||||
entity.domain, climate.SERVICE_SET_OPERATION_MODE, data,
|
||||
blocking=False, context=context)
|
||||
response.add_context_property({
|
||||
'name': 'thermostatMode',
|
||||
'namespace': 'Alexa.ThermostatController',
|
||||
'value': mode,
|
||||
})
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@HANDLERS.register(('Alexa', 'ReportState'))
|
||||
async def async_api_reportstate(hass, config, directive, context):
|
||||
"""Process a ReportState request."""
|
||||
return directive.response(name='StateReport')
|
200
homeassistant/components/alexa/messages.py
Normal file
200
homeassistant/components/alexa/messages.py
Normal file
@ -0,0 +1,200 @@
|
||||
"""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 = 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 or not config.should_expose(self.entity_id):
|
||||
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:
|
||||
"""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 theromstat to 20 degrees", the API expects a response with
|
||||
the new value of the property, and Alexa will respond to the user
|
||||
"Thermostat set to 20 degrees".
|
||||
|
||||
async_handle_message() will call .merge_context_properties() for every
|
||||
request automatically, however often handlers will call services to
|
||||
change state but the effects of those changes are applied
|
||||
asynchronously. Thus, handlers should call this method to confirm
|
||||
changes before returning.
|
||||
"""
|
||||
self._properties().append(prop)
|
||||
|
||||
def merge_context_properties(self, endpoint):
|
||||
"""Add all properties from given endpoint if not already set.
|
||||
|
||||
Handlers should be using .add_context_property().
|
||||
"""
|
||||
properties = self._properties()
|
||||
already_set = {(p['namespace'], p['name']) for p in properties}
|
||||
|
||||
for prop in endpoint.serialize_properties():
|
||||
if (prop['namespace'], prop['name']) not in already_set:
|
||||
self.add_context_property(prop)
|
||||
|
||||
def serialize(self):
|
||||
"""Return response as a JSON-able data structure."""
|
||||
return self._response
|
File diff suppressed because it is too large
Load Diff
114
homeassistant/components/alexa/smart_home_http.py
Normal file
114
homeassistant/components/alexa/smart_home_http.py
Normal file
@ -0,0 +1,114 @@
|
||||
"""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
|
||||
)
|
||||
from .state_report import async_enable_proactive_mode
|
||||
from .smart_home import async_handle_message
|
||||
|
||||
_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, {})
|
||||
|
||||
def should_expose(self, entity_id):
|
||||
"""If an entity should be exposed."""
|
||||
return self._config[CONF_FILTER](entity_id)
|
||||
|
||||
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)
|
185
homeassistant/components/alexa/state_report.py
Normal file
185
homeassistant/components/alexa/state_report.py
Normal file
@ -0,0 +1,185 @@
|
||||
"""Alexa state report code."""
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
|
||||
import aiohttp
|
||||
import async_timeout
|
||||
|
||||
from homeassistant.const import MATCH_ALL
|
||||
|
||||
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 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
|
||||
|
||||
return hass.helpers.event.async_track_state_change(
|
||||
MATCH_ALL, async_entity_state_listener
|
||||
)
|
||||
|
||||
|
||||
async def async_send_changereport_message(hass, config, alexa_entity):
|
||||
"""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": "Bearer {}".format(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 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"])
|
||||
|
||||
|
||||
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": "Bearer {}".format(token)
|
||||
}
|
||||
|
||||
endpoints = []
|
||||
|
||||
for entity_id in entity_ids:
|
||||
domain = entity_id.split('.', 1)[0]
|
||||
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": "Bearer {}".format(token)
|
||||
}
|
||||
|
||||
endpoints = []
|
||||
|
||||
for entity_id in entity_ids:
|
||||
domain = entity_id.split('.', 1)[0]
|
||||
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)
|
@ -0,0 +1,12 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_setup": "L'account Ambiclimate \u00e8 configurato."
|
||||
},
|
||||
"title": "Ambiclimate"
|
||||
}
|
||||
}
|
23
homeassistant/components/ambiclimate/.translations/nl.json
Normal file
23
homeassistant/components/ambiclimate/.translations/nl.json
Normal file
@ -0,0 +1,23 @@
|
||||
{
|
||||
"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 <b> Toestaan </b> toegang tot uw Ambiclimate-account, kom dan terug en druk hieronder op <b> Verzenden </b> . \n (Zorg ervoor dat de opgegeven callback-URL {cb_url} )",
|
||||
"title": "Authenticatie Ambiclimate"
|
||||
}
|
||||
},
|
||||
"title": "Ambiclimate"
|
||||
}
|
||||
}
|
@ -0,0 +1,23 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"access_token": "Erro desconhecido ao gerar um token de acesso.",
|
||||
"already_setup": "A conta Ambiclimate est\u00e1 configurada.",
|
||||
"no_config": "Voc\u00ea precisa configurar o Ambiclimate antes de poder autenticar com ele. [Por favor, leia as instru\u00e7\u00f5es] (https://www.home-assistant.io/components/ambiclimate/)."
|
||||
},
|
||||
"create_entry": {
|
||||
"default": "Autenticado com sucesso no Ambiclimate"
|
||||
},
|
||||
"error": {
|
||||
"follow_link": "Por favor, siga o link e autentique-se antes de pressionar Enviar",
|
||||
"no_token": "N\u00e3o autenticado com o Ambiclimate"
|
||||
},
|
||||
"step": {
|
||||
"auth": {
|
||||
"description": "Por favor, siga este [link]({authorization_url}) e <b>Permitir</b> acesso \u00e0 sua conta Ambiclimate, em seguida, volte e pressione <b>Enviar</b> abaixo. \n (Verifique se a URL de retorno de chamada especificada \u00e9 {cb_url})",
|
||||
"title": "Autenticar Ambiclimate"
|
||||
}
|
||||
},
|
||||
"title": "Ambiclimate"
|
||||
}
|
||||
}
|
@ -56,14 +56,15 @@ async def async_setup_entry(hass, entry, async_add_entities):
|
||||
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,
|
||||
|
@ -4,7 +4,7 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/components/ambiclimate",
|
||||
"requirements": [
|
||||
"ambiclimate==0.1.2"
|
||||
"ambiclimate==0.2.0"
|
||||
],
|
||||
"dependencies": [],
|
||||
"codeowners": [
|
||||
|
@ -327,7 +327,7 @@ class AmbientStation:
|
||||
"""Define a handler to fire when the websocket is connected."""
|
||||
_LOGGER.info('Connected to websocket')
|
||||
_LOGGER.debug('Watchdog starting')
|
||||
if self._watchdog_listener:
|
||||
if self._watchdog_listener is not None:
|
||||
self._watchdog_listener()
|
||||
self._watchdog_listener = async_call_later(
|
||||
self._hass, DEFAULT_WATCHDOG_SECONDS, _ws_reconnect)
|
||||
|
@ -4,7 +4,7 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/components/ambient_station",
|
||||
"requirements": [
|
||||
"aioambient==0.3.0"
|
||||
"aioambient==0.3.1"
|
||||
],
|
||||
"dependencies": [],
|
||||
"codeowners": [
|
||||
|
@ -1,8 +1,10 @@
|
||||
"""Support for Amcrest IP cameras."""
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
import threading
|
||||
|
||||
import aiohttp
|
||||
from amcrest import AmcrestError, Http, LoginError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.auth.permissions.const import POLICY_CONTROL
|
||||
@ -17,12 +19,14 @@ from homeassistant.const import (
|
||||
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 .binary_sensor import BINARY_SENSOR_MOTION_DETECTED, BINARY_SENSORS
|
||||
from .camera import CAMERA_SERVICES, STREAM_SOURCE_LIST
|
||||
from .const import DOMAIN, DATA_AMCREST
|
||||
from .const import CAMERAS, DOMAIN, DATA_AMCREST, DEVICES, SERVICE_UPDATE
|
||||
from .helpers import service_signal
|
||||
from .sensor import SENSOR_MOTION_DETECTOR, SENSORS
|
||||
from .switch import SWITCHES
|
||||
@ -32,11 +36,14 @@ _LOGGER = logging.getLogger(__name__)
|
||||
CONF_RESOLUTION = 'resolution'
|
||||
CONF_STREAM_SOURCE = 'stream_source'
|
||||
CONF_FFMPEG_ARGUMENTS = 'ffmpeg_arguments'
|
||||
CONF_CONTROL_LIGHT = 'control_light'
|
||||
|
||||
DEFAULT_NAME = 'Amcrest Camera'
|
||||
DEFAULT_PORT = 80
|
||||
DEFAULT_RESOLUTION = 'high'
|
||||
DEFAULT_ARGUMENTS = '-pred 1'
|
||||
MAX_ERRORS = 5
|
||||
RECHECK_INTERVAL = timedelta(minutes=1)
|
||||
|
||||
NOTIFICATION_ID = 'amcrest_notification'
|
||||
NOTIFICATION_TITLE = 'Amcrest Camera Setup'
|
||||
@ -56,20 +63,21 @@ AUTHENTICATION_LIST = {
|
||||
def _deprecated_sensor_values(sensors):
|
||||
if SENSOR_MOTION_DETECTOR in sensors:
|
||||
_LOGGER.warning(
|
||||
"The 'sensors' option value '%s' is deprecated, "
|
||||
"The '%s' 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)
|
||||
"the '%s' option with value '%s' instead",
|
||||
CONF_SENSORS, SENSOR_MOTION_DETECTOR, CONF_BINARY_SENSORS,
|
||||
BINARY_SENSOR_MOTION_DETECTED)
|
||||
return sensors
|
||||
|
||||
|
||||
def _deprecated_switches(config):
|
||||
if CONF_SWITCHES in config:
|
||||
_LOGGER.warning(
|
||||
"The 'switches' option (with value %s) is deprecated, "
|
||||
"The '%s' option (with value %s) is deprecated, "
|
||||
"please remove it from your configuration and use "
|
||||
"camera services and attributes instead.",
|
||||
config[CONF_SWITCHES])
|
||||
"services and attributes instead",
|
||||
CONF_SWITCHES, config[CONF_SWITCHES])
|
||||
return config
|
||||
|
||||
|
||||
@ -103,6 +111,7 @@ AMCREST_SCHEMA = vol.All(
|
||||
_deprecated_sensor_values),
|
||||
vol.Optional(CONF_SWITCHES):
|
||||
vol.All(cv.ensure_list, [vol.In(SWITCHES)]),
|
||||
vol.Optional(CONF_CONTROL_LIGHT, default=True): cv.boolean,
|
||||
}),
|
||||
_deprecated_switches
|
||||
)
|
||||
@ -112,35 +121,81 @@ CONFIG_SCHEMA = vol.Schema({
|
||||
}, extra=vol.ALLOW_EXTRA)
|
||||
|
||||
|
||||
# pylint: disable=too-many-ancestors
|
||||
class AmcrestChecker(Http):
|
||||
"""amcrest.Http wrapper for catching errors."""
|
||||
|
||||
def __init__(self, hass, name, host, port, user, password):
|
||||
"""Initialize."""
|
||||
self._hass = hass
|
||||
self._wrap_name = name
|
||||
self._wrap_errors = 0
|
||||
self._wrap_lock = threading.Lock()
|
||||
self._unsub_recheck = None
|
||||
super().__init__(host, port, user, password, retries_connection=1,
|
||||
timeout_protocol=3.05)
|
||||
|
||||
@property
|
||||
def available(self):
|
||||
"""Return if camera's API is responding."""
|
||||
return self._wrap_errors <= MAX_ERRORS
|
||||
|
||||
def command(self, cmd, retries=None, timeout_cmd=None, stream=False):
|
||||
"""amcrest.Http.command wrapper to catch errors."""
|
||||
try:
|
||||
ret = super().command(cmd, retries, timeout_cmd, stream)
|
||||
except AmcrestError:
|
||||
with self._wrap_lock:
|
||||
was_online = self.available
|
||||
self._wrap_errors += 1
|
||||
_LOGGER.debug('%s camera errs: %i', self._wrap_name,
|
||||
self._wrap_errors)
|
||||
offline = not self.available
|
||||
if offline and was_online:
|
||||
_LOGGER.error(
|
||||
'%s camera offline: Too many errors', self._wrap_name)
|
||||
dispatcher_send(
|
||||
self._hass,
|
||||
service_signal(SERVICE_UPDATE, self._wrap_name))
|
||||
self._unsub_recheck = track_time_interval(
|
||||
self._hass, self._wrap_test_online, RECHECK_INTERVAL)
|
||||
raise
|
||||
with self._wrap_lock:
|
||||
was_offline = not self.available
|
||||
self._wrap_errors = 0
|
||||
if was_offline:
|
||||
self._unsub_recheck()
|
||||
self._unsub_recheck = None
|
||||
_LOGGER.error('%s camera back online', self._wrap_name)
|
||||
dispatcher_send(
|
||||
self._hass, service_signal(SERVICE_UPDATE, self._wrap_name))
|
||||
return ret
|
||||
|
||||
def _wrap_test_online(self, now):
|
||||
"""Test if camera is back online."""
|
||||
try:
|
||||
self.current_time
|
||||
except AmcrestError:
|
||||
pass
|
||||
|
||||
|
||||
def setup(hass, config):
|
||||
"""Set up the Amcrest IP Camera component."""
|
||||
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
|
||||
api = AmcrestChecker(
|
||||
hass, name,
|
||||
device[CONF_HOST], device[CONF_PORT],
|
||||
username, password)
|
||||
|
||||
except AmcrestError as ex:
|
||||
_LOGGER.error("Unable to connect to %s camera: %s", name, str(ex))
|
||||
hass.components.persistent_notification.create(
|
||||
'Error: {}<br />'
|
||||
'You will need to restart hass after fixing.'
|
||||
''.format(ex),
|
||||
title=NOTIFICATION_TITLE,
|
||||
notification_id=NOTIFICATION_ID)
|
||||
except LoginError as ex:
|
||||
_LOGGER.error("Login error for %s camera: %s", name, ex)
|
||||
continue
|
||||
|
||||
ffmpeg_arguments = device[CONF_FFMPEG_ARGUMENTS]
|
||||
@ -149,6 +204,7 @@ def setup(hass, config):
|
||||
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,9 +213,9 @@ def setup(hass, config):
|
||||
else:
|
||||
authentication = None
|
||||
|
||||
hass.data[DATA_AMCREST]['devices'][name] = AmcrestDevice(
|
||||
hass.data[DATA_AMCREST][DEVICES][name] = AmcrestDevice(
|
||||
api, authentication, ffmpeg_arguments, stream_source,
|
||||
resolution)
|
||||
resolution, control_light)
|
||||
|
||||
discovery.load_platform(
|
||||
hass, CAMERA, DOMAIN, {
|
||||
@ -187,7 +243,7 @@ def setup(hass, config):
|
||||
CONF_SWITCHES: switches
|
||||
}, config)
|
||||
|
||||
if not hass.data[DATA_AMCREST]['devices']:
|
||||
if not hass.data[DATA_AMCREST][DEVICES]:
|
||||
return False
|
||||
|
||||
def have_permission(user, entity_id):
|
||||
@ -205,13 +261,13 @@ def setup(hass, config):
|
||||
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)
|
||||
]
|
||||
|
||||
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):
|
||||
@ -245,10 +301,11 @@ class AmcrestDevice:
|
||||
"""Representation of a base Amcrest discovery device."""
|
||||
|
||||
def __init__(self, api, authentication, ffmpeg_arguments,
|
||||
stream_source, resolution):
|
||||
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
|
||||
|
@ -2,18 +2,27 @@
|
||||
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 (
|
||||
BinarySensorDevice, DEVICE_CLASS_CONNECTIVITY, DEVICE_CLASS_MOTION)
|
||||
from homeassistant.const import CONF_NAME, CONF_BINARY_SENSORS
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
|
||||
from .const import (
|
||||
BINARY_SENSOR_SCAN_INTERVAL_SECS, DATA_AMCREST, DEVICES, SERVICE_UPDATE)
|
||||
from .helpers import log_update_error, service_signal
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=BINARY_SENSOR_SCAN_INTERVAL_SECS)
|
||||
|
||||
BINARY_SENSOR_MOTION_DETECTED = 'motion_detected'
|
||||
BINARY_SENSOR_ONLINE = 'online'
|
||||
# Binary sensor types are defined like: Name, device class
|
||||
BINARY_SENSORS = {
|
||||
'motion_detected': 'Motion Detected'
|
||||
BINARY_SENSOR_MOTION_DETECTED: ('Motion Detected', DEVICE_CLASS_MOTION),
|
||||
BINARY_SENSOR_ONLINE: ('Online', DEVICE_CLASS_CONNECTIVITY),
|
||||
}
|
||||
|
||||
|
||||
@ -24,7 +33,7 @@ async def async_setup_platform(hass, config, async_add_entities,
|
||||
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]],
|
||||
@ -36,10 +45,18 @@ class AmcrestBinarySensor(BinarySensorDevice):
|
||||
|
||||
def __init__(self, name, device, sensor_type):
|
||||
"""Initialize entity."""
|
||||
self._name = '{} {}'.format(name, BINARY_SENSORS[sensor_type])
|
||||
self._name = '{} {}'.format(name, BINARY_SENSORS[sensor_type][0])
|
||||
self._signal_name = name
|
||||
self._api = device.api
|
||||
self._sensor_type = sensor_type
|
||||
self._state = None
|
||||
self._device_class = BINARY_SENSORS[sensor_type][1]
|
||||
self._unsub_dispatcher = None
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
"""Return True if entity has to be polled for state."""
|
||||
return self._sensor_type != BINARY_SENSOR_ONLINE
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
@ -54,17 +71,39 @@ class AmcrestBinarySensor(BinarySensorDevice):
|
||||
@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 not self.available:
|
||||
return
|
||||
_LOGGER.debug('Updating %s binary sensor', self._name)
|
||||
|
||||
try:
|
||||
self._state = self._api.is_motion_detected
|
||||
if self._sensor_type == BINARY_SENSOR_MOTION_DETECTED:
|
||||
self._state = self._api.is_motion_detected
|
||||
|
||||
elif self._sensor_type == BINARY_SENSOR_ONLINE:
|
||||
self._state = self._api.available
|
||||
except AmcrestError as error:
|
||||
_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)
|
||||
|
||||
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()
|
||||
|
@ -1,7 +1,10 @@
|
||||
"""Support for Amcrest IP cameras."""
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from urllib3.exceptions import HTTPError
|
||||
|
||||
from amcrest import AmcrestError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.camera import (
|
||||
@ -14,11 +17,14 @@ from homeassistant.helpers.aiohttp_client import (
|
||||
async_get_clientsession)
|
||||
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, DATA_AMCREST, DEVICES, SERVICE_UPDATE)
|
||||
from .helpers import log_update_error, service_signal
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=15)
|
||||
|
||||
STREAM_SOURCE_LIST = [
|
||||
'snapshot',
|
||||
'mjpeg',
|
||||
@ -76,7 +82,7 @@ async def async_setup_platform(hass, config, async_add_entities,
|
||||
return
|
||||
|
||||
name = discovery_info[CONF_NAME]
|
||||
device = hass.data[DATA_AMCREST]['devices'][name]
|
||||
device = hass.data[DATA_AMCREST][DEVICES][name]
|
||||
async_add_entities([
|
||||
AmcrestCam(name, device, hass.data[DATA_FFMPEG])], True)
|
||||
|
||||
@ -94,33 +100,36 @@ class AmcrestCam(Camera):
|
||||
self._stream_source = device.stream_source
|
||||
self._resolution = device.resolution
|
||||
self._token = self._auth = device.authentication
|
||||
self._control_light = device.control_light
|
||||
self._is_recording = False
|
||||
self._motion_detection_enabled = None
|
||||
self._brand = None
|
||||
self._model = None
|
||||
self._audio_enabled = None
|
||||
self._motion_recording_enabled = None
|
||||
self._color_bw = None
|
||||
self._rtsp_url = None
|
||||
self._snapshot_lock = asyncio.Lock()
|
||||
self._unsub_dispatcher = []
|
||||
self._update_succeeded = False
|
||||
|
||||
async def async_camera_image(self):
|
||||
"""Return a still image response from the camera."""
|
||||
from amcrest import AmcrestError
|
||||
|
||||
if not self.is_on:
|
||||
_LOGGER.error(
|
||||
'Attempt to take snaphot when %s camera is off', self.name)
|
||||
available = self.available
|
||||
if not available or not self.is_on:
|
||||
_LOGGER.warning(
|
||||
'Attempt to take snaphot when %s camera is %s', self.name,
|
||||
'offline' if not available else 'off')
|
||||
return None
|
||||
async with self._snapshot_lock:
|
||||
try:
|
||||
# Send the request to snap a picture and return raw jpg data
|
||||
response = await self.hass.async_add_executor_job(
|
||||
self._api.snapshot, self._resolution)
|
||||
self._api.snapshot)
|
||||
return response.data
|
||||
except AmcrestError as error:
|
||||
_LOGGER.error(
|
||||
'Could not get image from %s camera due to error: %s',
|
||||
self.name, error)
|
||||
except (AmcrestError, HTTPError) as error:
|
||||
log_update_error(
|
||||
_LOGGER, 'get image from', self.name, 'camera', error)
|
||||
return None
|
||||
|
||||
async def handle_async_mjpeg_stream(self, request):
|
||||
@ -129,6 +138,12 @@ class AmcrestCam(Camera):
|
||||
if self._stream_source == 'snapshot':
|
||||
return await super().handle_async_mjpeg_stream(request)
|
||||
|
||||
if not self.available:
|
||||
_LOGGER.warning(
|
||||
'Attempt to stream %s when %s camera is offline',
|
||||
self._stream_source, self.name)
|
||||
return None
|
||||
|
||||
if self._stream_source == 'mjpeg':
|
||||
# stream an MJPEG image stream directly from the camera
|
||||
websession = async_get_clientsession(self.hass)
|
||||
@ -143,7 +158,7 @@ class AmcrestCam(Camera):
|
||||
# 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)
|
||||
@ -158,6 +173,14 @@ class AmcrestCam(Camera):
|
||||
|
||||
# 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."""
|
||||
@ -176,6 +199,11 @@ class AmcrestCam(Camera):
|
||||
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 +219,7 @@ class AmcrestCam(Camera):
|
||||
@property
|
||||
def brand(self):
|
||||
"""Return the camera brand."""
|
||||
return 'Amcrest'
|
||||
return self._brand
|
||||
|
||||
@property
|
||||
def motion_detection_enabled(self):
|
||||
@ -205,7 +233,7 @@ class AmcrestCam(Camera):
|
||||
|
||||
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):
|
||||
@ -214,6 +242,10 @@ class AmcrestCam(Camera):
|
||||
|
||||
# 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():
|
||||
@ -221,28 +253,37 @@ class AmcrestCam(Camera):
|
||||
self.hass,
|
||||
service_signal(service, self.entity_id),
|
||||
getattr(self, params[1])))
|
||||
self.hass.data[DATA_AMCREST]['cameras'].append(self.entity_id)
|
||||
self._unsub_dispatcher.append(async_dispatcher_connect(
|
||||
self.hass, service_signal(SERVICE_UPDATE, self._name),
|
||||
self.async_on_demand_update))
|
||||
self.hass.data[DATA_AMCREST][CAMERAS].append(self.entity_id)
|
||||
|
||||
async def async_will_remove_from_hass(self):
|
||||
"""Remove camera from list and disconnect from signals."""
|
||||
self.hass.data[DATA_AMCREST]['cameras'].remove(self.entity_id)
|
||||
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 = (
|
||||
@ -251,10 +292,13 @@ class AmcrestCam(Camera):
|
||||
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
|
||||
|
||||
@ -322,8 +366,6 @@ class AmcrestCam(Camera):
|
||||
|
||||
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.
|
||||
@ -332,17 +374,17 @@ class AmcrestCam(Camera):
|
||||
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.
|
||||
@ -353,88 +395,89 @@ class AmcrestCam(Camera):
|
||||
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(
|
||||
'configManager.cgi?action=setConfig&LightGlobal[0].Enable={}'
|
||||
.format(str(enable).lower()))
|
||||
except AmcrestError as error:
|
||||
log_update_error(
|
||||
_LOGGER, 'enable' if enable else 'disable', self.name,
|
||||
'indicator light', error)
|
||||
|
||||
def _enable_motion_recording(self, enable):
|
||||
"""Enable or disable motion recording."""
|
||||
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)
|
||||
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,
|
||||
'camera to preset {}'.format(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,
|
||||
'camera color mode to {}'.format(cbw), error)
|
||||
else:
|
||||
self._color_bw = cbw
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
def _start_tour(self, start):
|
||||
"""Start camera tour."""
|
||||
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)
|
||||
|
@ -1,7 +1,11 @@
|
||||
"""Constants for amcrest component."""
|
||||
DOMAIN = 'amcrest'
|
||||
DATA_AMCREST = DOMAIN
|
||||
CAMERAS = 'cameras'
|
||||
DEVICES = 'devices'
|
||||
|
||||
BINARY_SENSOR_SCAN_INTERVAL_SECS = 5
|
||||
CAMERA_WEB_SESSION_TIMEOUT = 10
|
||||
SENSOR_SCAN_INTERVAL_SECS = 10
|
||||
|
||||
SERVICE_UPDATE = 'update'
|
||||
|
@ -2,9 +2,16 @@
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
def service_signal(service, entity_id=None):
|
||||
"""Encode service and entity_id into signal."""
|
||||
def service_signal(service, ident=None):
|
||||
"""Encode service and identifier into signal."""
|
||||
signal = '{}_{}'.format(DOMAIN, service)
|
||||
if entity_id:
|
||||
signal += '_{}'.format(entity_id.replace('.', '_'))
|
||||
if ident:
|
||||
signal += '_{}'.format(ident.replace('.', '_'))
|
||||
return signal
|
||||
|
||||
|
||||
def log_update_error(logger, action, name, entity_type, error):
|
||||
"""Log an update error."""
|
||||
logger.error(
|
||||
'Could not %s %s %s due to error: %s',
|
||||
action, name, entity_type, error.__class__.__name__)
|
||||
|
@ -3,7 +3,7 @@
|
||||
"name": "Amcrest",
|
||||
"documentation": "https://www.home-assistant.io/components/amcrest",
|
||||
"requirements": [
|
||||
"amcrest==1.4.0"
|
||||
"amcrest==1.5.3"
|
||||
],
|
||||
"dependencies": [
|
||||
"ffmpeg"
|
||||
|
@ -2,21 +2,28 @@
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from amcrest import AmcrestError
|
||||
|
||||
from homeassistant.const import CONF_NAME, CONF_SENSORS
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity import Entity
|
||||
|
||||
from .const import DATA_AMCREST, 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 types are defined like: Name, units, icon
|
||||
SENSOR_MOTION_DETECTOR = 'motion_detector'
|
||||
SENSOR_PTZ_PRESET = 'ptz_preset'
|
||||
SENSOR_SDCARD = 'sdcard'
|
||||
# Sensor types are defined like: Name, units, icon
|
||||
SENSORS = {
|
||||
SENSOR_MOTION_DETECTOR: ['Motion Detected', None, 'mdi:run'],
|
||||
'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', '%', 'mdi:sd'],
|
||||
}
|
||||
|
||||
|
||||
@ -27,7 +34,7 @@ async def async_setup_platform(
|
||||
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]],
|
||||
@ -40,12 +47,14 @@ 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._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 +81,53 @@ class AmcrestSensor(Entity):
|
||||
"""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 not self.available:
|
||||
return
|
||||
_LOGGER.debug("Updating %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
|
||||
try:
|
||||
if self._sensor_type == SENSOR_MOTION_DETECTOR:
|
||||
self._state = self._api.is_motion_detected
|
||||
self._attrs['Record Mode'] = self._api.record_mode
|
||||
|
||||
elif self._sensor_type == 'ptz_preset':
|
||||
self._state = self._api.ptz_presets_count
|
||||
elif self._sensor_type == SENSOR_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']
|
||||
elif self._sensor_type == SENSOR_SDCARD:
|
||||
storage = self._api.storage_all
|
||||
try:
|
||||
self._attrs['Total'] = '{:.2f} {}'.format(
|
||||
*storage['total'])
|
||||
except ValueError:
|
||||
self._attrs['Total'] = '{} {}'.format(*storage['total'])
|
||||
try:
|
||||
self._attrs['Used'] = '{:.2f} {}'.format(*storage['used'])
|
||||
except ValueError:
|
||||
self._attrs['Used'] = '{} {}'.format(*storage['used'])
|
||||
try:
|
||||
self._state = '{:.2f}'.format(storage['used_percent'])
|
||||
except ValueError:
|
||||
self._state = storage['used_percent']
|
||||
except AmcrestError as error:
|
||||
log_update_error(_LOGGER, 'update', self.name, 'sensor', error)
|
||||
|
||||
async def async_on_demand_update(self):
|
||||
"""Update state."""
|
||||
self.async_schedule_update_ha_state(True)
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
"""Subscribe to update signal."""
|
||||
self._unsub_dispatcher = async_dispatcher_connect(
|
||||
self.hass, service_signal(SERVICE_UPDATE, self._signal_name),
|
||||
self.async_on_demand_update)
|
||||
|
||||
async def async_will_remove_from_hass(self):
|
||||
"""Disconnect from update signal."""
|
||||
self._unsub_dispatcher()
|
||||
|
@ -1,17 +1,23 @@
|
||||
"""Support for toggling Amcrest IP camera settings."""
|
||||
import logging
|
||||
|
||||
from amcrest import AmcrestError
|
||||
|
||||
from homeassistant.const import CONF_NAME, CONF_SWITCHES
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity import ToggleEntity
|
||||
|
||||
from .const import DATA_AMCREST
|
||||
from .const import DATA_AMCREST, DEVICES, SERVICE_UPDATE
|
||||
from .helpers import log_update_error, service_signal
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
MOTION_DETECTION = 'motion_detection'
|
||||
MOTION_RECORDING = 'motion_recording'
|
||||
# Switch types are defined like: Name, icon
|
||||
SWITCHES = {
|
||||
'motion_detection': ['Motion Detection', 'mdi:run-fast'],
|
||||
'motion_recording': ['Motion Recording', 'mdi:record-rec']
|
||||
MOTION_DETECTION: ['Motion Detection', 'mdi:run-fast'],
|
||||
MOTION_RECORDING: ['Motion Recording', 'mdi:record-rec']
|
||||
}
|
||||
|
||||
|
||||
@ -22,7 +28,7 @@ async def async_setup_platform(
|
||||
return
|
||||
|
||||
name = discovery_info[CONF_NAME]
|
||||
device = hass.data[DATA_AMCREST]['devices'][name]
|
||||
device = hass.data[DATA_AMCREST][DEVICES][name]
|
||||
async_add_entities(
|
||||
[AmcrestSwitch(name, device, setting)
|
||||
for setting in discovery_info[CONF_SWITCHES]],
|
||||
@ -35,10 +41,12 @@ class AmcrestSwitch(ToggleEntity):
|
||||
def __init__(self, name, device, setting):
|
||||
"""Initialize the Amcrest switch."""
|
||||
self._name = '{} {}'.format(name, SWITCHES[setting][0])
|
||||
self._signal_name = name
|
||||
self._api = device.api
|
||||
self._setting = setting
|
||||
self._state = False
|
||||
self._icon = SWITCHES[setting][1]
|
||||
self._unsub_dispatcher = None
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
@ -52,30 +60,63 @@ class AmcrestSwitch(ToggleEntity):
|
||||
|
||||
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'
|
||||
if not self.available:
|
||||
return
|
||||
try:
|
||||
if self._setting == MOTION_DETECTION:
|
||||
self._api.motion_detection = 'true'
|
||||
elif self._setting == MOTION_RECORDING:
|
||||
self._api.motion_recording = 'true'
|
||||
except AmcrestError as error:
|
||||
log_update_error(_LOGGER, 'turn on', self.name, 'switch', error)
|
||||
|
||||
def turn_off(self, **kwargs):
|
||||
"""Turn setting off."""
|
||||
if self._setting == 'motion_detection':
|
||||
self._api.motion_detection = 'false'
|
||||
elif self._setting == 'motion_recording':
|
||||
self._api.motion_recording = 'false'
|
||||
if not self.available:
|
||||
return
|
||||
try:
|
||||
if self._setting == MOTION_DETECTION:
|
||||
self._api.motion_detection = 'false'
|
||||
elif self._setting == MOTION_RECORDING:
|
||||
self._api.motion_recording = 'false'
|
||||
except AmcrestError as error:
|
||||
log_update_error(_LOGGER, 'turn off', self.name, 'switch', error)
|
||||
|
||||
@property
|
||||
def available(self):
|
||||
"""Return True if entity is available."""
|
||||
return self._api.available
|
||||
|
||||
def update(self):
|
||||
"""Update setting state."""
|
||||
_LOGGER.debug("Polling state for setting: %s ", self._name)
|
||||
if not self.available:
|
||||
return
|
||||
_LOGGER.debug("Updating %s switch", 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
|
||||
try:
|
||||
if self._setting == MOTION_DETECTION:
|
||||
detection = self._api.is_motion_detector_on()
|
||||
elif self._setting == MOTION_RECORDING:
|
||||
detection = self._api.is_record_on_motion_detection()
|
||||
self._state = detection
|
||||
except AmcrestError as error:
|
||||
log_update_error(_LOGGER, 'update', self.name, 'switch', error)
|
||||
|
||||
@property
|
||||
def icon(self):
|
||||
"""Return the icon for the switch."""
|
||||
return self._icon
|
||||
|
||||
async def async_on_demand_update(self):
|
||||
"""Update state."""
|
||||
self.async_schedule_update_ha_state(True)
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
"""Subscribe to update signal."""
|
||||
self._unsub_dispatcher = async_dispatcher_connect(
|
||||
self.hass, service_signal(SERVICE_UPDATE, self._signal_name),
|
||||
self.async_on_demand_update)
|
||||
|
||||
async def async_will_remove_from_hass(self):
|
||||
"""Disconnect from update signal."""
|
||||
self._unsub_dispatcher()
|
||||
|
@ -3,7 +3,7 @@
|
||||
"name": "Androidtv",
|
||||
"documentation": "https://www.home-assistant.io/components/androidtv",
|
||||
"requirements": [
|
||||
"androidtv==0.0.15"
|
||||
"androidtv==0.0.16"
|
||||
],
|
||||
"dependencies": [],
|
||||
"codeowners": []
|
||||
|
1
homeassistant/components/aprs/__init__.py
Normal file
1
homeassistant/components/aprs/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""The APRS component."""
|
187
homeassistant/components/aprs/device_tracker.py
Normal file
187
homeassistant/components/aprs/device_tracker.py
Normal file
@ -0,0 +1,187 @@
|
||||
"""Support for APRS device tracking."""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.device_tracker import PLATFORM_SCHEMA
|
||||
from homeassistant.const import (
|
||||
ATTR_GPS_ACCURACY, ATTR_LATITUDE, ATTR_LONGITUDE,
|
||||
CONF_HOST, CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME,
|
||||
EVENT_HOMEASSISTANT_STOP)
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.util import slugify
|
||||
|
||||
DOMAIN = 'aprs'
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ATTR_ALTITUDE = 'altitude'
|
||||
ATTR_COURSE = 'course'
|
||||
ATTR_COMMENT = 'comment'
|
||||
ATTR_FROM = 'from'
|
||||
ATTR_FORMAT = 'format'
|
||||
ATTR_POS_AMBIGUITY = 'posambiguity'
|
||||
ATTR_SPEED = 'speed'
|
||||
|
||||
CONF_CALLSIGNS = 'callsigns'
|
||||
|
||||
DEFAULT_HOST = 'rotate.aprs2.net'
|
||||
DEFAULT_PASSWORD = '-1'
|
||||
DEFAULT_TIMEOUT = 30.0
|
||||
|
||||
FILTER_PORT = 14580
|
||||
|
||||
MSG_FORMATS = ['compressed', 'uncompressed', 'mic-e']
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Required(CONF_CALLSIGNS): cv.ensure_list,
|
||||
vol.Required(CONF_USERNAME): cv.string,
|
||||
vol.Optional(CONF_PASSWORD,
|
||||
default=DEFAULT_PASSWORD): cv.string,
|
||||
vol.Optional(CONF_HOST,
|
||||
default=DEFAULT_HOST): cv.string,
|
||||
vol.Optional(CONF_TIMEOUT,
|
||||
default=DEFAULT_TIMEOUT): vol.Coerce(float),
|
||||
})
|
||||
|
||||
|
||||
def make_filter(callsigns: list) -> str:
|
||||
"""Make a server-side filter from a list of callsigns."""
|
||||
return ' '.join('b/{0}'.format(cs.upper()) for cs in callsigns)
|
||||
|
||||
|
||||
def gps_accuracy(gps, posambiguity: int) -> int:
|
||||
"""Calculate the GPS accuracy based on APRS posambiguity."""
|
||||
import geopy.distance
|
||||
|
||||
pos_a_map = {0: 0,
|
||||
1: 1 / 600,
|
||||
2: 1 / 60,
|
||||
3: 1 / 6,
|
||||
4: 1}
|
||||
if posambiguity in pos_a_map:
|
||||
degrees = pos_a_map[posambiguity]
|
||||
|
||||
gps2 = (gps[0], gps[1] + degrees)
|
||||
dist_m = geopy.distance.distance(gps, gps2).m
|
||||
|
||||
accuracy = round(dist_m)
|
||||
else:
|
||||
message = "APRS position ambiguity must be 0-4, not '{0}'.".format(
|
||||
posambiguity)
|
||||
raise ValueError(message)
|
||||
|
||||
return accuracy
|
||||
|
||||
|
||||
def setup_scanner(hass, config, see, discovery_info=None):
|
||||
"""Set up the APRS tracker."""
|
||||
callsigns = config.get(CONF_CALLSIGNS)
|
||||
server_filter = make_filter(callsigns)
|
||||
|
||||
callsign = config.get(CONF_USERNAME)
|
||||
password = config.get(CONF_PASSWORD)
|
||||
host = config.get(CONF_HOST)
|
||||
timeout = config.get(CONF_TIMEOUT)
|
||||
aprs_listener = AprsListenerThread(
|
||||
callsign, password, host, server_filter, see)
|
||||
|
||||
def aprs_disconnect(event):
|
||||
"""Stop the APRS connection."""
|
||||
aprs_listener.stop()
|
||||
|
||||
aprs_listener.start()
|
||||
hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, aprs_disconnect)
|
||||
|
||||
if not aprs_listener.start_event.wait(timeout):
|
||||
_LOGGER.error("Timeout waiting for APRS to connect.")
|
||||
return
|
||||
|
||||
if not aprs_listener.start_success:
|
||||
_LOGGER.error(aprs_listener.start_message)
|
||||
return
|
||||
|
||||
_LOGGER.debug(aprs_listener.start_message)
|
||||
return True
|
||||
|
||||
|
||||
class AprsListenerThread(threading.Thread):
|
||||
"""APRS message listener."""
|
||||
|
||||
def __init__(self, callsign: str, password: str, host: str,
|
||||
server_filter: str, see):
|
||||
"""Initialize the class."""
|
||||
super().__init__()
|
||||
|
||||
import aprslib
|
||||
|
||||
self.callsign = callsign
|
||||
self.host = host
|
||||
self.start_event = threading.Event()
|
||||
self.see = see
|
||||
self.server_filter = server_filter
|
||||
self.start_message = ""
|
||||
self.start_success = False
|
||||
|
||||
self.ais = aprslib.IS(
|
||||
self.callsign, passwd=password, host=self.host, port=FILTER_PORT)
|
||||
|
||||
def start_complete(self, success: bool, message: str):
|
||||
"""Complete startup process."""
|
||||
self.start_message = message
|
||||
self.start_success = success
|
||||
self.start_event.set()
|
||||
|
||||
def run(self):
|
||||
"""Connect to APRS and listen for data."""
|
||||
self.ais.set_filter(self.server_filter)
|
||||
from aprslib import ConnectionError as AprsConnectionError
|
||||
from aprslib import LoginError
|
||||
|
||||
try:
|
||||
_LOGGER.info("Opening connection to %s with callsign %s.",
|
||||
self.host, self.callsign)
|
||||
self.ais.connect()
|
||||
self.start_complete(
|
||||
True,
|
||||
"Connected to {0} with callsign {1}.".format(
|
||||
self.host, self.callsign))
|
||||
self.ais.consumer(callback=self.rx_msg, immortal=True)
|
||||
except (AprsConnectionError, LoginError) as err:
|
||||
self.start_complete(False, str(err))
|
||||
except OSError:
|
||||
_LOGGER.info("Closing connection to %s with callsign %s.",
|
||||
self.host, self.callsign)
|
||||
|
||||
def stop(self):
|
||||
"""Close the connection to the APRS network."""
|
||||
self.ais.close()
|
||||
|
||||
def rx_msg(self, msg: dict):
|
||||
"""Receive message and process if position."""
|
||||
_LOGGER.debug("APRS message received: %s", str(msg))
|
||||
if msg[ATTR_FORMAT] in MSG_FORMATS:
|
||||
dev_id = slugify(msg[ATTR_FROM])
|
||||
lat = msg[ATTR_LATITUDE]
|
||||
lon = msg[ATTR_LONGITUDE]
|
||||
|
||||
attrs = {}
|
||||
if ATTR_POS_AMBIGUITY in msg:
|
||||
pos_amb = msg[ATTR_POS_AMBIGUITY]
|
||||
try:
|
||||
attrs[ATTR_GPS_ACCURACY] = gps_accuracy((lat, lon),
|
||||
pos_amb)
|
||||
except ValueError:
|
||||
_LOGGER.warning(
|
||||
"APRS message contained invalid posambiguity: %s",
|
||||
str(pos_amb))
|
||||
for attr in [ATTR_ALTITUDE,
|
||||
ATTR_COMMENT,
|
||||
ATTR_COURSE,
|
||||
ATTR_SPEED]:
|
||||
if attr in msg:
|
||||
attrs[attr] = msg[attr]
|
||||
|
||||
self.see(dev_id=dev_id, gps=(lat, lon), attributes=attrs)
|
11
homeassistant/components/aprs/manifest.json
Normal file
11
homeassistant/components/aprs/manifest.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"domain": "aprs",
|
||||
"name": "APRS",
|
||||
"documentation": "https://www.home-assistant.io/components/aprs",
|
||||
"dependencies": [],
|
||||
"codeowners": ["@PhilRW"],
|
||||
"requirements": [
|
||||
"aprslib==0.6.46",
|
||||
"geopy==1.19.0"
|
||||
]
|
||||
}
|
@ -47,6 +47,6 @@ class AsusWrtDeviceScanner(DeviceScanner):
|
||||
|
||||
Return boolean if scanning successful.
|
||||
"""
|
||||
_LOGGER.info('Checking Devices')
|
||||
_LOGGER.debug('Checking Devices')
|
||||
|
||||
self.last_results = await self.connection.async_get_connected_devices()
|
||||
|
@ -50,7 +50,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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__)
|
||||
@ -223,23 +223,25 @@ class AutomationEntity(ToggleEntity, RestoreEntity):
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Startup with initial state or previous state."""
|
||||
await super().async_added_to_hass()
|
||||
|
||||
state = await self.async_get_last_state()
|
||||
if state:
|
||||
enable_automation = state.state == STATE_ON
|
||||
self._last_triggered = state.attributes.get('last_triggered')
|
||||
_LOGGER.debug("Loaded automation %s with state %s from state "
|
||||
" storage last state %s", self.entity_id,
|
||||
enable_automation, state)
|
||||
else:
|
||||
enable_automation = DEFAULT_INITIAL_STATE
|
||||
_LOGGER.debug("Automation %s not in state storage, state %s from "
|
||||
"default is used.", self.entity_id,
|
||||
enable_automation)
|
||||
|
||||
if self._initial_state is not None:
|
||||
enable_automation = self._initial_state
|
||||
_LOGGER.debug("Automation %s initial state %s 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)
|
||||
_LOGGER.debug("Automation %s initial state %s overridden from "
|
||||
"config initial_state", self.entity_id,
|
||||
enable_automation)
|
||||
|
||||
if enable_automation:
|
||||
await self.async_enable()
|
||||
|
18
homeassistant/components/automation/device.py
Normal file
18
homeassistant/components/automation/device.py
Normal file
@ -0,0 +1,18 @@
|
||||
"""Offer device oriented automation."""
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import CONF_DOMAIN, CONF_PLATFORM
|
||||
from homeassistant.loader import async_get_integration
|
||||
|
||||
|
||||
TRIGGER_SCHEMA = vol.Schema({
|
||||
vol.Required(CONF_PLATFORM): 'device',
|
||||
vol.Required(CONF_DOMAIN): str,
|
||||
}, extra=vol.ALLOW_EXTRA)
|
||||
|
||||
|
||||
async def async_trigger(hass, config, action, automation_info):
|
||||
"""Listen for trigger."""
|
||||
integration = await async_get_integration(hass, config[CONF_DOMAIN])
|
||||
platform = integration.get_platform('device_automation')
|
||||
return await platform.async_trigger(hass, config, action, automation_info)
|
@ -4,8 +4,10 @@ import logging
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.const import CONF_VALUE_TEMPLATE, CONF_PLATFORM
|
||||
from homeassistant.helpers.event import async_track_template
|
||||
from homeassistant.const import CONF_VALUE_TEMPLATE, CONF_PLATFORM, CONF_FOR
|
||||
from homeassistant.helpers import condition
|
||||
from homeassistant.helpers.event import (
|
||||
async_track_same_state, async_track_template)
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@ -13,6 +15,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
TRIGGER_SCHEMA = IF_ACTION_SCHEMA = vol.Schema({
|
||||
vol.Required(CONF_PLATFORM): 'template',
|
||||
vol.Required(CONF_VALUE_TEMPLATE): cv.template,
|
||||
vol.Optional(CONF_FOR): vol.All(cv.time_period, cv.positive_timedelta),
|
||||
})
|
||||
|
||||
|
||||
@ -20,17 +23,44 @@ async def async_trigger(hass, config, action, automation_info):
|
||||
"""Listen for state changes based on configuration."""
|
||||
value_template = config.get(CONF_VALUE_TEMPLATE)
|
||||
value_template.hass = hass
|
||||
time_delta = config.get(CONF_FOR)
|
||||
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)))
|
||||
nonlocal unsub_track_same
|
||||
|
||||
return async_track_template(hass, value_template, template_listener)
|
||||
@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,
|
||||
},
|
||||
}, context=(to_s.context if to_s else None)))
|
||||
|
||||
if not time_delta:
|
||||
call_action()
|
||||
return
|
||||
|
||||
unsub_track_same = async_track_same_state(
|
||||
hass, time_delta, call_action,
|
||||
lambda _, _2, _3: condition.async_template(hass, value_template),
|
||||
value_template.extract_entities())
|
||||
|
||||
unsub = async_track_template(
|
||||
hass, value_template, template_listener)
|
||||
|
||||
@callback
|
||||
def async_remove():
|
||||
"""Remove state listeners async."""
|
||||
unsub()
|
||||
if unsub_track_same:
|
||||
# pylint: disable=not-callable
|
||||
unsub_track_same()
|
||||
|
||||
return async_remove
|
||||
|
@ -6,5 +6,7 @@
|
||||
"python_awair==0.0.4"
|
||||
],
|
||||
"dependencies": [],
|
||||
"codeowners": []
|
||||
"codeowners": [
|
||||
"@danielsjf"
|
||||
]
|
||||
}
|
||||
|
@ -219,6 +219,6 @@ class AwairData:
|
||||
# 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)
|
||||
|
@ -3,10 +3,12 @@
|
||||
"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"
|
||||
"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 pel dispositiu ja est\u00e0 en curs.",
|
||||
"device_unavailable": "El dispositiu no est\u00e0 disponible",
|
||||
"faulty_credentials": "Credencials d'usuari incorrectes"
|
||||
},
|
||||
|
@ -3,10 +3,12 @@
|
||||
"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"
|
||||
"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"
|
||||
},
|
||||
|
@ -3,10 +3,12 @@
|
||||
"abort": {
|
||||
"already_configured": "Device is already configured",
|
||||
"bad_config_file": "Bad data from config file",
|
||||
"link_local_address": "Link local addresses are not supported"
|
||||
"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"
|
||||
},
|
||||
|
@ -1,9 +1,17 @@
|
||||
{
|
||||
"config": {
|
||||
"error": {
|
||||
"already_configured": "Az eszk\u00f6zt m\u00e1r konfigur\u00e1ltuk",
|
||||
"device_unavailable": "Az eszk\u00f6z nem \u00e9rhet\u0151 el",
|
||||
"faulty_credentials": "Rossz felhaszn\u00e1l\u00f3i hiteles\u00edt\u0151 adatok"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "Hoszt"
|
||||
"host": "Hoszt",
|
||||
"password": "Jelsz\u00f3",
|
||||
"port": "Port",
|
||||
"username": "Felhaszn\u00e1l\u00f3n\u00e9v"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -2,10 +2,12 @@
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato",
|
||||
"bad_config_file": "Dati errati dal file di configurazione"
|
||||
"bad_config_file": "Dati errati dal file di configurazione",
|
||||
"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"
|
||||
},
|
||||
|
@ -3,10 +3,12 @@
|
||||
"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"
|
||||
"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"
|
||||
},
|
||||
|
@ -3,10 +3,12 @@
|
||||
"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"
|
||||
"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"
|
||||
},
|
||||
|
@ -1,6 +1,14 @@
|
||||
{
|
||||
"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"
|
||||
},
|
||||
@ -11,8 +19,10 @@
|
||||
"password": "Wachtwoord",
|
||||
"port": "Poort",
|
||||
"username": "Gebruikersnaam"
|
||||
}
|
||||
},
|
||||
"title": "Stel het Axis-apparaat in"
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "Axis-apparaat"
|
||||
}
|
||||
}
|
@ -7,6 +7,7 @@
|
||||
},
|
||||
"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"
|
||||
},
|
||||
|
@ -3,10 +3,12 @@
|
||||
"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"
|
||||
"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": "Konfigurowanie urz\u0105dzenia jest ju\u017c w toku.",
|
||||
"device_unavailable": "Urz\u0105dzenie jest niedost\u0119pne",
|
||||
"faulty_credentials": "B\u0142\u0119dne dane uwierzytelniaj\u0105ce"
|
||||
},
|
||||
|
22
homeassistant/components/axis/.translations/pt-BR.json
Normal file
22
homeassistant/components/axis/.translations/pt-BR.json
Normal file
@ -0,0 +1,22 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"not_axis_device": "Dispositivo descoberto n\u00e3o \u00e9 um dispositivo Axis"
|
||||
},
|
||||
"error": {
|
||||
"already_in_progress": "O fluxo de configura\u00e7\u00e3o para o dispositivo j\u00e1 est\u00e1 em andamento.",
|
||||
"faulty_credentials": "Credenciais do usu\u00e1rio inv\u00e1lidas"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "Host",
|
||||
"password": "Senha",
|
||||
"port": "Porta",
|
||||
"username": "Nome de usu\u00e1rio"
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "Dispositivo Axis"
|
||||
}
|
||||
}
|
@ -3,10 +3,12 @@
|
||||
"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"
|
||||
"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 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430",
|
||||
"already_in_progress": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0443\u0436\u0435 \u043d\u0430\u0447\u0430\u0442\u0430.",
|
||||
"device_unavailable": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0435\u0434\u043e\u0441\u0442\u0443\u043f\u043d\u043e",
|
||||
"faulty_credentials": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0435 \u0443\u0447\u0435\u0442\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435"
|
||||
},
|
||||
|
@ -7,6 +7,7 @@
|
||||
},
|
||||
"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"
|
||||
},
|
||||
|
@ -7,6 +7,7 @@
|
||||
},
|
||||
"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"
|
||||
},
|
||||
|
@ -3,10 +3,12 @@
|
||||
"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"
|
||||
"link_local_address": "\u4e0d\u652f\u63f4\u9023\u7d50\u672c\u5730\u7aef\u4f4d\u5740",
|
||||
"not_axis_device": "\u6240\u767c\u73fe\u7684\u88dd\u7f6e\u4e26\u975e Axis \u88dd\u7f6e"
|
||||
},
|
||||
"error": {
|
||||
"already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210",
|
||||
"already_in_progress": "\u88dd\u7f6e\u8a2d\u5b9a\u5df2\u7d93\u9032\u884c\u4e2d\u3002",
|
||||
"device_unavailable": "\u88dd\u7f6e\u7121\u6cd5\u4f7f\u7528",
|
||||
"faulty_credentials": "\u4f7f\u7528\u8005\u6191\u8b49\u7121\u6548"
|
||||
},
|
||||
|
@ -3,7 +3,7 @@
|
||||
"name": "Blink",
|
||||
"documentation": "https://www.home-assistant.io/components/blink",
|
||||
"requirements": [
|
||||
"blinkpy==0.14.0"
|
||||
"blinkpy==0.14.1"
|
||||
],
|
||||
"dependencies": [],
|
||||
"codeowners": [
|
||||
|
@ -3,7 +3,7 @@
|
||||
"name": "Broadlink",
|
||||
"documentation": "https://www.home-assistant.io/components/broadlink",
|
||||
"requirements": [
|
||||
"broadlink==0.10.0"
|
||||
"broadlink==0.11.1"
|
||||
],
|
||||
"dependencies": [],
|
||||
"codeowners": [
|
||||
|
@ -322,6 +322,8 @@ class BroadlinkMP1Switch:
|
||||
|
||||
def get_outlet_status(self, slot):
|
||||
"""Get status of outlet from cached status list."""
|
||||
if self._states is None:
|
||||
return None
|
||||
return self._states['s{}'.format(slot)]
|
||||
|
||||
@Throttle(TIME_BETWEEN_UPDATES)
|
||||
|
178
homeassistant/components/buienradar/camera.py
Normal file
178
homeassistant/components/buienradar/camera.py
Normal file
@ -0,0 +1,178 @@
|
||||
"""Provide animated GIF loops of Buienradar imagery."""
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
import aiohttp
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.camera import PLATFORM_SCHEMA, Camera
|
||||
from homeassistant.const import CONF_NAME
|
||||
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
|
||||
CONF_DIMENSION = 'dimension'
|
||||
CONF_DELTA = 'delta'
|
||||
|
||||
RADAR_MAP_URL_TEMPLATE = ('https://api.buienradar.nl/image/1.0/'
|
||||
'RadarMapNL?w={w}&h={h}')
|
||||
|
||||
_LOG = logging.getLogger(__name__)
|
||||
|
||||
# Maximum range according to docs
|
||||
DIM_RANGE = vol.All(vol.Coerce(int), vol.Range(min=120, max=700))
|
||||
|
||||
PLATFORM_SCHEMA = vol.All(
|
||||
PLATFORM_SCHEMA.extend({
|
||||
vol.Optional(CONF_DIMENSION, default=512): DIM_RANGE,
|
||||
vol.Optional(CONF_DELTA, default=600.0): vol.All(vol.Coerce(float),
|
||||
vol.Range(min=0)),
|
||||
vol.Optional(CONF_NAME, default="Buienradar loop"): cv.string,
|
||||
}))
|
||||
|
||||
|
||||
async def async_setup_platform(hass, config, async_add_entities,
|
||||
discovery_info=None):
|
||||
"""Set up buienradar radar-loop camera component."""
|
||||
dimension = config[CONF_DIMENSION]
|
||||
delta = config[CONF_DELTA]
|
||||
name = config[CONF_NAME]
|
||||
|
||||
async_add_entities([BuienradarCam(name, dimension, delta)])
|
||||
|
||||
|
||||
class BuienradarCam(Camera):
|
||||
"""
|
||||
A camera component producing animated buienradar radar-imagery GIFs.
|
||||
|
||||
Rain radar imagery camera based on image URL taken from [0].
|
||||
|
||||
[0]: https://www.buienradar.nl/overbuienradar/gratis-weerdata
|
||||
"""
|
||||
|
||||
def __init__(self, name: str, dimension: int, delta: float):
|
||||
"""
|
||||
Initialize the component.
|
||||
|
||||
This constructor must be run in the event loop.
|
||||
"""
|
||||
super().__init__()
|
||||
|
||||
self._name = name
|
||||
|
||||
# dimension (x and y) of returned radar image
|
||||
self._dimension = dimension
|
||||
|
||||
# time a cached image stays valid for
|
||||
self._delta = delta
|
||||
|
||||
# Condition that guards the loading indicator.
|
||||
#
|
||||
# Ensures that only one reader can cause an http request at the same
|
||||
# time, and that all readers are notified after this request completes.
|
||||
#
|
||||
# invariant: this condition is private to and owned by this instance.
|
||||
self._condition = asyncio.Condition()
|
||||
|
||||
self._last_image = None # type: Optional[bytes]
|
||||
# value of the last seen last modified header
|
||||
self._last_modified = None # type: Optional[str]
|
||||
# loading status
|
||||
self._loading = False
|
||||
# deadline for image refresh - self.delta after last successful load
|
||||
self._deadline = None # type: Optional[datetime]
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Return the component name."""
|
||||
return self._name
|
||||
|
||||
def __needs_refresh(self) -> bool:
|
||||
if not (self._delta and self._deadline and self._last_image):
|
||||
return True
|
||||
|
||||
return dt_util.utcnow() > self._deadline
|
||||
|
||||
async def __retrieve_radar_image(self) -> bool:
|
||||
"""Retrieve new radar image and return whether this succeeded."""
|
||||
session = async_get_clientsession(self.hass)
|
||||
|
||||
url = RADAR_MAP_URL_TEMPLATE.format(w=self._dimension,
|
||||
h=self._dimension)
|
||||
|
||||
if self._last_modified:
|
||||
headers = {'If-Modified-Since': self._last_modified}
|
||||
else:
|
||||
headers = {}
|
||||
|
||||
try:
|
||||
async with session.get(url, timeout=5, headers=headers) as res:
|
||||
res.raise_for_status()
|
||||
|
||||
if res.status == 304:
|
||||
_LOG.debug("HTTP 304 - success")
|
||||
return True
|
||||
|
||||
last_modified = res.headers.get('Last-Modified', None)
|
||||
if last_modified:
|
||||
self._last_modified = last_modified
|
||||
|
||||
self._last_image = await res.read()
|
||||
_LOG.debug("HTTP 200 - Last-Modified: %s", last_modified)
|
||||
|
||||
return True
|
||||
except (asyncio.TimeoutError, aiohttp.ClientError) as err:
|
||||
_LOG.error("Failed to fetch image, %s", type(err))
|
||||
return False
|
||||
|
||||
async def async_camera_image(self) -> Optional[bytes]:
|
||||
"""
|
||||
Return a still image response from the camera.
|
||||
|
||||
Uses ayncio conditions to make sure only one task enters the critical
|
||||
section at the same time. Otherwise, two http requests would start
|
||||
when two tabs with home assistant are open.
|
||||
|
||||
The condition is entered in two sections because otherwise the lock
|
||||
would be held while doing the http request.
|
||||
|
||||
A boolean (_loading) is used to indicate the loading status instead of
|
||||
_last_image since that is initialized to None.
|
||||
|
||||
For reference:
|
||||
* :func:`asyncio.Condition.wait` releases the lock and acquires it
|
||||
again before continuing.
|
||||
* :func:`asyncio.Condition.notify_all` requires the lock to be held.
|
||||
"""
|
||||
if not self.__needs_refresh():
|
||||
return self._last_image
|
||||
|
||||
# get lock, check iff loading, await notification if loading
|
||||
async with self._condition:
|
||||
# can not be tested - mocked http response returns immediately
|
||||
if self._loading:
|
||||
_LOG.debug("already loading - waiting for notification")
|
||||
await self._condition.wait()
|
||||
return self._last_image
|
||||
|
||||
# Set loading status **while holding lock**, makes other tasks wait
|
||||
self._loading = True
|
||||
|
||||
try:
|
||||
now = dt_util.utcnow()
|
||||
was_updated = await self.__retrieve_radar_image()
|
||||
# was updated? Set new deadline relative to now before loading
|
||||
if was_updated:
|
||||
self._deadline = now + timedelta(seconds=self._delta)
|
||||
|
||||
return self._last_image
|
||||
finally:
|
||||
# get lock, unset loading status, notify all waiting tasks
|
||||
async with self._condition:
|
||||
self._loading = False
|
||||
self._condition.notify_all()
|
@ -6,5 +6,5 @@
|
||||
"buienradar==0.91"
|
||||
],
|
||||
"dependencies": [],
|
||||
"codeowners": []
|
||||
"codeowners": ["@ties"]
|
||||
}
|
||||
|
@ -5,7 +5,8 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.components.weather import (
|
||||
ATTR_FORECAST_CONDITION, ATTR_FORECAST_TEMP, ATTR_FORECAST_TEMP_LOW,
|
||||
ATTR_FORECAST_TIME, PLATFORM_SCHEMA, WeatherEntity)
|
||||
ATTR_FORECAST_TIME, PLATFORM_SCHEMA, WeatherEntity,
|
||||
ATTR_FORECAST_PRECIPITATION)
|
||||
from homeassistant.const import (
|
||||
CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, TEMP_CELSIUS)
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
@ -149,7 +150,7 @@ class BrWeather(WeatherEntity):
|
||||
@property
|
||||
def forecast(self):
|
||||
"""Return the forecast array."""
|
||||
from buienradar.buienradar import (CONDITION, CONDCODE, DATETIME,
|
||||
from buienradar.buienradar import (CONDITION, CONDCODE, RAIN, DATETIME,
|
||||
MIN_TEMP, MAX_TEMP)
|
||||
|
||||
if self._forecast:
|
||||
@ -166,6 +167,7 @@ class BrWeather(WeatherEntity):
|
||||
data_out[ATTR_FORECAST_CONDITION] = cond[condcode]
|
||||
data_out[ATTR_FORECAST_TEMP_LOW] = data_in.get(MIN_TEMP)
|
||||
data_out[ATTR_FORECAST_TEMP] = data_in.get(MAX_TEMP)
|
||||
data_out[ATTR_FORECAST_PRECIPITATION] = data_in.get(RAIN)
|
||||
|
||||
fcdata_out.append(data_out)
|
||||
|
||||
|
@ -4,8 +4,9 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/components/cast",
|
||||
"requirements": [
|
||||
"pychromecast==3.2.1"
|
||||
"pychromecast==3.2.2"
|
||||
],
|
||||
"dependencies": [],
|
||||
"zeroconf": ["_googlecast._tcp.local."],
|
||||
"codeowners": []
|
||||
}
|
||||
|
@ -4,7 +4,7 @@ import logging
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.auth.const import GROUP_ID_ADMIN
|
||||
from homeassistant.components.alexa import smart_home as alexa_sh
|
||||
from homeassistant.components.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,
|
||||
@ -21,7 +21,8 @@ from .const import (
|
||||
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_USER_POOL_ID, DOMAIN, MODE_DEV, MODE_PROD, CONF_ALEXA_ACCESS_TOKEN_URL
|
||||
)
|
||||
from .prefs import CloudPreferences
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@ -33,9 +34,9 @@ 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,
|
||||
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({
|
||||
@ -61,7 +62,6 @@ 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,
|
||||
@ -73,6 +73,7 @@ CONFIG_SCHEMA = vol.Schema({
|
||||
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): str,
|
||||
}),
|
||||
}, extra=vol.ALLOW_EXTRA)
|
||||
|
||||
@ -192,8 +193,16 @@ async def async_setup(hass, config):
|
||||
hass.helpers.service.async_register_admin_service(
|
||||
DOMAIN, SERVICE_REMOTE_DISCONNECT, _service_handler)
|
||||
|
||||
loaded_binary_sensor = False
|
||||
|
||||
async def _on_connect():
|
||||
"""Discover RemoteUI binary sensor."""
|
||||
nonlocal loaded_binary_sensor
|
||||
|
||||
if loaded_binary_sensor:
|
||||
return
|
||||
|
||||
loaded_binary_sensor = True
|
||||
hass.async_create_task(hass.helpers.discovery.async_load_platform(
|
||||
'binary_sensor', DOMAIN, {}, config))
|
||||
|
||||
|
259
homeassistant/components/cloud/alexa_config.py
Normal file
259
homeassistant/components/cloud/alexa_config.py
Normal file
@ -0,0 +1,259 @@
|
||||
"""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.const import CLOUD_NEVER_EXPOSED_ENTITIES
|
||||
from homeassistant.helpers import entity_registry
|
||||
from homeassistant.helpers.event import async_call_later
|
||||
from homeassistant.util.dt import utcnow
|
||||
from homeassistant.components.alexa import (
|
||||
config as alexa_config,
|
||||
errors as alexa_errors,
|
||||
entities as alexa_entities,
|
||||
state_report as alexa_state_report,
|
||||
)
|
||||
|
||||
|
||||
from .const import (
|
||||
CONF_ENTITY_CONFIG, CONF_FILTER, PREF_SHOULD_EXPOSE, DEFAULT_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 entity_config(self):
|
||||
"""Return entity config."""
|
||||
return self._config.get(CONF_ENTITY_CONFIG, {})
|
||||
|
||||
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)
|
||||
|
||||
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 == 400:
|
||||
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(
|
||||
"There was an error reporting state to Alexa ({}). "
|
||||
"Please re-link your Alexa skill via the Alexa app to "
|
||||
"continue using it.".format(body['reason']),
|
||||
"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()
|
||||
|
||||
# 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."""
|
||||
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
|
||||
|
||||
action = event.data['action']
|
||||
entity_id = event.data['entity_id']
|
||||
to_update = []
|
||||
to_remove = []
|
||||
|
||||
if action == 'create' and self.should_expose(entity_id):
|
||||
to_update.append(entity_id)
|
||||
elif action == 'remove' and self.should_expose(entity_id):
|
||||
to_remove.append(entity_id)
|
||||
|
||||
try:
|
||||
await self._sync_helper(to_update, to_remove)
|
||||
except alexa_errors.NoTokenAvailable:
|
||||
pass
|
@ -2,42 +2,45 @@
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
import logging
|
||||
|
||||
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.components.google_assistant import smart_home as ga
|
||||
from homeassistant.helpers.typing import HomeAssistantType
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.util.aiohttp import MockRequest
|
||||
from homeassistant.components.alexa import (
|
||||
smart_home as alexa_sh,
|
||||
errors as alexa_errors,
|
||||
)
|
||||
|
||||
from . import utils
|
||||
from .const import (
|
||||
CONF_ENTITY_CONFIG, CONF_FILTER, DOMAIN, DISPATCHER_REMOTE_UPDATE,
|
||||
PREF_SHOULD_EXPOSE, DEFAULT_SHOULD_EXPOSE,
|
||||
PREF_DISABLE_2FA, DEFAULT_DISABLE_2FA)
|
||||
from . import utils, alexa_config, google_config
|
||||
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]):
|
||||
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
|
||||
self.cloud = None
|
||||
|
||||
@property
|
||||
def base_path(self) -> Path:
|
||||
@ -75,70 +78,40 @@ class CloudClient(Interface):
|
||||
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:
|
||||
def google_config(self) -> google_config.CloudGoogleConfig:
|
||||
"""Return Google config."""
|
||||
if not self._google_config:
|
||||
google_conf = self._google_user_config
|
||||
|
||||
def should_expose(entity):
|
||||
"""If an entity should be exposed."""
|
||||
if entity.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
|
||||
return False
|
||||
|
||||
if not google_conf['filter'].empty_filter:
|
||||
return google_conf['filter'](entity.entity_id)
|
||||
|
||||
entity_configs = self.prefs.google_entity_configs
|
||||
entity_config = entity_configs.get(entity.entity_id, {})
|
||||
return entity_config.get(
|
||||
PREF_SHOULD_EXPOSE, DEFAULT_SHOULD_EXPOSE)
|
||||
|
||||
def should_2fa(entity):
|
||||
"""If an entity should be checked for 2FA."""
|
||||
entity_configs = self.prefs.google_entity_configs
|
||||
entity_config = entity_configs.get(entity.entity_id, {})
|
||||
return not entity_config.get(
|
||||
PREF_DISABLE_2FA, DEFAULT_DISABLE_2FA)
|
||||
|
||||
username = self._hass.data[DOMAIN].claims["cognito:username"]
|
||||
|
||||
self._google_config = ga_h.Config(
|
||||
should_expose=should_expose,
|
||||
should_2fa=should_2fa,
|
||||
secure_devices_pin=self._prefs.google_secure_devices_pin,
|
||||
entity_config=google_conf.get(CONF_ENTITY_CONFIG),
|
||||
agent_user_id=username,
|
||||
)
|
||||
|
||||
# Set it to the latest.
|
||||
self._google_config.secure_devices_pin = \
|
||||
self._prefs.google_secure_devices_pin
|
||||
assert self.cloud is not None
|
||||
self._google_config = google_config.CloudGoogleConfig(
|
||||
self.google_user_config, self._prefs, self.cloud)
|
||||
|
||||
return self._google_config
|
||||
|
||||
@property
|
||||
def google_user_config(self) -> Dict[str, Any]:
|
||||
"""Return google action user config."""
|
||||
return self._google_user_config
|
||||
async def async_initialize(self, cloud) -> None:
|
||||
"""Initialize the client."""
|
||||
self.cloud = cloud
|
||||
|
||||
if (not self.alexa_config.should_report_state or
|
||||
not self.cloud.is_logged_in):
|
||||
return
|
||||
|
||||
try:
|
||||
await self.alexa_config.async_enable_proactive_mode()
|
||||
except alexa_errors.NoTokenAvailable:
|
||||
pass
|
||||
|
||||
async def cleanups(self) -> None:
|
||||
"""Cleanup some stuff after logout."""
|
||||
self._alexa_config = None
|
||||
self._google_config = None
|
||||
|
||||
@callback
|
||||
|
@ -9,12 +9,15 @@ 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_OVERRIDE_NAME = 'override_name'
|
||||
PREF_DISABLE_2FA = 'disable_2fa'
|
||||
PREF_ALIASES = 'aliases'
|
||||
PREF_SHOULD_EXPOSE = 'should_expose'
|
||||
DEFAULT_SHOULD_EXPOSE = True
|
||||
DEFAULT_DISABLE_2FA = False
|
||||
DEFAULT_ALEXA_REPORT_STATE = False
|
||||
|
||||
CONF_ALEXA = 'alexa'
|
||||
CONF_ALIASES = 'aliases'
|
||||
@ -29,6 +32,7 @@ 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'
|
||||
|
||||
MODE_DEV = "development"
|
||||
MODE_PROD = "production"
|
||||
@ -42,3 +46,7 @@ class InvalidTrustedNetworks(Exception):
|
||||
|
||||
class InvalidTrustedProxies(Exception):
|
||||
"""Raised when invalid trusted proxies config."""
|
||||
|
||||
|
||||
class RequireRelink(Exception):
|
||||
"""The skill needs to be relinked."""
|
||||
|
52
homeassistant/components/cloud/google_config.py
Normal file
52
homeassistant/components/cloud/google_config.py
Normal file
@ -0,0 +1,52 @@
|
||||
"""Google config for Cloud."""
|
||||
from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES
|
||||
from homeassistant.components.google_assistant.helpers import AbstractConfig
|
||||
|
||||
from .const import (
|
||||
PREF_SHOULD_EXPOSE, DEFAULT_SHOULD_EXPOSE, CONF_ENTITY_CONFIG,
|
||||
PREF_DISABLE_2FA, DEFAULT_DISABLE_2FA)
|
||||
|
||||
|
||||
class CloudGoogleConfig(AbstractConfig):
|
||||
"""HA Cloud Configuration for Google Assistant."""
|
||||
|
||||
def __init__(self, config, prefs, cloud):
|
||||
"""Initialize the Alexa config."""
|
||||
self._config = config
|
||||
self._prefs = prefs
|
||||
self._cloud = cloud
|
||||
|
||||
@property
|
||||
def agent_user_id(self):
|
||||
"""Return Agent User Id to use for query responses."""
|
||||
return self._cloud.claims["cognito:username"]
|
||||
|
||||
@property
|
||||
def entity_config(self):
|
||||
"""Return entity config."""
|
||||
return self._config.get(CONF_ENTITY_CONFIG)
|
||||
|
||||
@property
|
||||
def secure_devices_pin(self):
|
||||
"""Return entity config."""
|
||||
return self._prefs.google_secure_devices_pin
|
||||
|
||||
def should_expose(self, state):
|
||||
"""If an entity should be exposed."""
|
||||
if state.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
|
||||
return False
|
||||
|
||||
if not self._config['filter'].empty_filter:
|
||||
return self._config['filter'](state.entity_id)
|
||||
|
||||
entity_configs = self._prefs.google_entity_configs
|
||||
entity_config = entity_configs.get(state.entity_id, {})
|
||||
return entity_config.get(
|
||||
PREF_SHOULD_EXPOSE, DEFAULT_SHOULD_EXPOSE)
|
||||
|
||||
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)
|
@ -13,13 +13,17 @@ 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.websocket_api import const as ws_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 .const import (
|
||||
DOMAIN, REQUEST_TIMEOUT, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE,
|
||||
PREF_GOOGLE_SECURE_DEVICES_PIN, InvalidTrustedNetworks,
|
||||
InvalidTrustedProxies)
|
||||
InvalidTrustedProxies, PREF_ALEXA_REPORT_STATE)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@ -90,6 +94,10 @@ async def async_setup(hass):
|
||||
hass.components.websocket_api.async_register_command(
|
||||
google_assistant_update)
|
||||
|
||||
hass.components.websocket_api.async_register_command(alexa_list)
|
||||
hass.components.websocket_api.async_register_command(alexa_update)
|
||||
hass.components.websocket_api.async_register_command(alexa_sync)
|
||||
|
||||
hass.http.register_view(GoogleActionsSyncView)
|
||||
hass.http.register_view(CloudLoginView)
|
||||
hass.http.register_view(CloudLogoutView)
|
||||
@ -360,6 +368,7 @@ async def websocket_subscription(hass, connection, msg):
|
||||
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_SECURE_DEVICES_PIN): vol.Any(None, str),
|
||||
})
|
||||
async def websocket_update_prefs(hass, connection, msg):
|
||||
@ -369,6 +378,24 @@ async def websocket_update_prefs(hass, connection, msg):
|
||||
changes = dict(msg)
|
||||
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:
|
||||
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']))
|
||||
@ -420,8 +447,7 @@ def _account_data(cloud):
|
||||
'cloud': cloud.iot.state,
|
||||
'prefs': client.prefs.as_dict(),
|
||||
'google_entities': client.google_user_config['filter'].config,
|
||||
'alexa_entities': client.alexa_config.should_expose.config,
|
||||
'alexa_domains': list(alexa_sh.ENTITY_ADAPTERS),
|
||||
'alexa_entities': client.alexa_user_config['filter'].config,
|
||||
'remote_domain': remote.instance_domain,
|
||||
'remote_connected': remote.is_connected,
|
||||
'remote_certificate': certificate,
|
||||
@ -497,7 +523,7 @@ async def google_assistant_list(hass, connection, msg):
|
||||
vol.Optional('disable_2fa'): bool,
|
||||
})
|
||||
async def google_assistant_update(hass, connection, msg):
|
||||
"""List all google assistant entities."""
|
||||
"""Update google assistant config."""
|
||||
cloud = hass.data[DOMAIN]
|
||||
changes = dict(msg)
|
||||
changes.pop('type')
|
||||
@ -508,3 +534,80 @@ async def google_assistant_update(hass, connection, msg):
|
||||
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')
|
||||
|
@ -3,7 +3,7 @@
|
||||
"name": "Cloud",
|
||||
"documentation": "https://www.home-assistant.io/components/cloud",
|
||||
"requirements": [
|
||||
"hass-nabucasa==0.14"
|
||||
"hass-nabucasa==0.15"
|
||||
],
|
||||
"dependencies": [
|
||||
"http",
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user